Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions .github/workflows/update-nutrient-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ jobs:
update:
timeout-minutes: 90
runs-on: ubuntu-latest
env:
# Root install runs `prepare`, which points core.hooksPath at .husky and
# would gate the bot's commit on lint-staged and the Biome version check.
HUSKY: "0"
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
Expand Down Expand Up @@ -114,21 +110,22 @@ jobs:
- name: Commit and push the bump
if: steps.check.outputs.should_update == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.check.outputs.version }}
BRANCH: ${{ steps.check.outputs.branch }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
# `pnpm run format` writes unsafe Biome fixes repository-wide, and a dev
# server may leave build output behind; neither belongs in a bump.
git add -u examples/
if git diff --cached --quiet; then
echo "Detection reported ${VERSION} was needed but nothing changed." >&2
exit 1
fi
git commit -m "Update examples with Nutrient SDK version $VERSION"
git push origin "$BRANCH"
# The base branch requires signed commits and the runner holds no
# signing key, so the commit is made through the API, which GitHub
# signs itself.
node scripts/commit-signed.js "$BRANCH" \
"Update examples with Nutrient SDK version $VERSION"

- name: Open the pull request
if: steps.check.outputs.should_update == 'true'
Expand Down
124 changes: 124 additions & 0 deletions scripts/commit-signed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");

const GRAPHQL_URL = "https://api.github.com/graphql";

const branch = process.argv[2];
const headline = process.argv[3];

if (!branch || !headline) {
console.error("Usage: node scripts/commit-signed.js <branch> <message>");
process.exit(1);
}

const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;

if (!token) {
console.error("GH_TOKEN (or GITHUB_TOKEN) must be set.");
process.exit(1);
}

const git = (...args) =>
execFileSync("git", args, { encoding: "utf8", maxBuffer: 1024 * 1024 * 64 });

const repository =
process.env.GITHUB_REPOSITORY ||
git("remote", "get-url", "origin")
.trim()
.replace(/^.*github\.com[/:]/, "")
.replace(/\.git$/, "");

if (!/^[^/]+\/[^/]+$/.test(repository)) {
console.error(`Could not determine owner/repo, got "${repository}".`);
process.exit(1);
}

// The mutation replays the index against a commit that must already be on the
// remote, so the branch is created there first and HEAD is the parent.
const parent = git("rev-parse", "HEAD").trim();

// -z, because a path with a space or a quote is mangled by the default
// quoting and would be committed under the wrong name.
const staged = git("diff", "--cached", "--name-status", "-z").split("\0");
const additions = [];
const deletions = [];

for (let i = 0; i < staged.length - 1; i += 2) {
const status = staged[i];
const path = staged[i + 1];

if (status.startsWith("R") || status.startsWith("C")) {
console.error(
`Renames and copies are not supported (${status} ${path}); stage them as a delete plus an add.`,
);
process.exit(1);
}

if (status === "D") {
deletions.push({ path });
} else {
// The mutation cannot set a file mode: an existing file keeps the mode it
// has, and a new one is created non-executable.
additions.push({
path,
contents: fs.readFileSync(path).toString("base64"),
});
}
}

if (additions.length === 0 && deletions.length === 0) {
console.error("Nothing is staged.");
process.exit(1);
}

git("push", "origin", `${parent}:refs/heads/${branch}`);

const query = `
mutation ($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit {
oid
}
}
}
`;

const input = {
branch: { repositoryNameWithOwner: repository, branchName: branch },
expectedHeadOid: parent,
message: { headline },
fileChanges: { additions, deletions },
};

async function main() {
const response = await fetch(GRAPHQL_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables: { input } }),
});

const body = await response.json();

if (!response.ok) {
console.error(
`GitHub answered ${response.status}: ${JSON.stringify(body)}`,
);
process.exit(1);
}

// GraphQL reports a rejected mutation inside a 200, so the status alone
// would let a failed commit pass as a success.
if (body.errors) {
console.error(`GitHub rejected the commit: ${JSON.stringify(body.errors)}`);
process.exit(1);
}

const oid = body.data.createCommitOnBranch.commit.oid;

console.log(`Committed ${oid} to ${branch}.`);
}

main();
Loading