diff --git a/.github/workflows/update-nutrient-sdk.yml b/.github/workflows/update-nutrient-sdk.yml index 47bba4a..57ce03f 100644 --- a/.github/workflows/update-nutrient-sdk.yml +++ b/.github/workflows/update-nutrient-sdk.yml @@ -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 @@ -114,12 +110,10 @@ 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/ @@ -127,8 +121,11 @@ jobs: 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' diff --git a/scripts/commit-signed.js b/scripts/commit-signed.js new file mode 100644 index 0000000..eec2e09 --- /dev/null +++ b/scripts/commit-signed.js @@ -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 "); + 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();