From 5e5d3fba7d6f809f227598c5daecfe1e06af2278 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:30:43 +0200 Subject: [PATCH 1/5] Make expected-sha input mandatory Changed expected-sha input to be required for release. --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca8ff7a10f..8d56802635 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ on: default: true expected-sha: description: "Immutable release commit this dispatch must publish (fail if the branch moved)" - required: false + required: true type: string permissions: @@ -54,7 +54,8 @@ jobs: EXPECTED_SHA: ${{ inputs.expected-sha }} run: | if [ -z "$EXPECTED_SHA" ]; then - echo "::warning::no expected-sha supplied; publishing whatever the branch currently points at" + echo "::error::expected-sha is required; refusing to publish without an audited commit" + exit 1 elif [ "$GITHUB_SHA" != "$EXPECTED_SHA" ]; then echo "::error::branch moved after the release audit (expected ${EXPECTED_SHA}, got ${GITHUB_SHA}) — refusing to publish an unaudited commit" exit 1 From 3e2733e53488875676d15401425898db8b35d8a5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:22:33 +0200 Subject: [PATCH 2/5] fix(release): add trusted dispatch guard --- .github/scripts/release-dispatch-guard.cjs | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/scripts/release-dispatch-guard.cjs diff --git a/.github/scripts/release-dispatch-guard.cjs b/.github/scripts/release-dispatch-guard.cjs new file mode 100644 index 0000000000..507cd3e787 --- /dev/null +++ b/.github/scripts/release-dispatch-guard.cjs @@ -0,0 +1,44 @@ +"use strict"; + +const ALLOWED_RELEASE_REFS = new Set([ + "refs/heads/main", + "refs/heads/preview", +]); + +function validateReleaseDispatch({ + eventName, + ref, + expectedSha, + actualSha, +}) { + if (eventName !== "workflow_dispatch") { + return `Release must be triggered by workflow_dispatch; got ${eventName || "(empty)"}.`; + } + + if (!ALLOWED_RELEASE_REFS.has(ref)) { + return `Release must run from main or preview; got ${ref || "(empty)"}.`; + } + + if (!expectedSha) { + return "expected-sha is required; refusing to publish without an audited commit."; + } + + if (!/^[0-9a-f]{40}$/.test(expectedSha)) { + return `expected-sha must be a full 40-character commit SHA; got ${expectedSha}.`; + } + + if (actualSha !== expectedSha) { + return ( + `branch moved after the release audit ` + + `(expected ${expectedSha}, got ${actualSha || "(empty)"}) — ` + + "refusing to publish an unaudited commit." + ); + } + + return null; +} + +module.exports = { + ALLOWED_RELEASE_REFS, + validateReleaseDispatch, +}; From 4722f87acd73a811807d1f4cdba59a44cd372ae3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:34:33 +0200 Subject: [PATCH 3/5] test(release): cover trusted dispatch guard --- .../scripts/release-dispatch-guard.test.cjs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/scripts/release-dispatch-guard.test.cjs diff --git a/.github/scripts/release-dispatch-guard.test.cjs b/.github/scripts/release-dispatch-guard.test.cjs new file mode 100644 index 0000000000..7833ca0976 --- /dev/null +++ b/.github/scripts/release-dispatch-guard.test.cjs @@ -0,0 +1,66 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { validateReleaseDispatch } = require("./release-dispatch-guard.cjs"); + +const SHA = "0123456789abcdef0123456789abcdef01234567"; +const OTHER_SHA = "89abcdef0123456789abcdef0123456789abcdef"; + +function validate(overrides = {}) { + return validateReleaseDispatch({ + eventName: "workflow_dispatch", + ref: "refs/heads/main", + expectedSha: SHA, + actualSha: SHA, + ...overrides, + }); +} + +describe("release dispatch guard", () => { + it("accepts an exact audited SHA on main", () => { + assert.equal(validate(), null); + }); + + it("accepts an exact audited SHA on preview", () => { + assert.equal( + validate({ ref: "refs/heads/preview" }), + null, + ); + }); + + it("rejects non-workflow_dispatch events", () => { + assert.match( + validate({ eventName: "push" }), + /must be triggered by workflow_dispatch/, + ); + }); + + it("rejects release dispatches from unapproved refs", () => { + assert.match( + validate({ ref: "refs/heads/dev" }), + /must run from main or preview/, + ); + }); + + it("requires expected-sha", () => { + assert.match( + validate({ expectedSha: "" }), + /expected-sha is required/, + ); + }); + + it("requires a full 40-character commit SHA", () => { + assert.match( + validate({ expectedSha: "0123456" }), + /full 40-character commit SHA/, + ); + }); + + it("rejects when the selected ref moved after audit", () => { + assert.match( + validate({ actualSha: OTHER_SHA }), + /branch moved after the release audit/, + ); + }); +}); From d4bfae51006818d12842027bdceba4fee4a23fd6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:48:27 +0200 Subject: [PATCH 4/5] fix(release): gate publish behind trusted dispatch validation --- .github/workflows/release.yml | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d56802635..1ba8cbd998 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,20 +29,55 @@ on: required: true type: string -permissions: - contents: write # create the matching GitHub Release + version tag after npm publish - actions: read # verify the release commit already passed Cross-platform CI - pull-requests: read # credit-takeovers looks up landing/source PR authors via gh api - id-token: write # OIDC auth for Trusted Publishing + automatic provenance attestation +permissions: {} concurrency: group: release cancel-in-progress: false jobs: + validate-dispatch: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout trusted dispatch guard + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + path: trusted-dispatch + + - name: Validate release dispatch + env: + EXPECTED_SHA: ${{ inputs.expected-sha }} + run: | + node - <<'NODE' + const { validateReleaseDispatch } = require( + "./trusted-dispatch/.github/scripts/release-dispatch-guard.cjs", + ); + + const failure = validateReleaseDispatch({ + eventName: process.env.GITHUB_EVENT_NAME, + ref: process.env.GITHUB_REF, + expectedSha: process.env.EXPECTED_SHA, + actualSha: process.env.GITHUB_SHA, + }); + + if (failure) { + console.error(`::error::${failure}`); + process.exit(1); + } + NODE publish: + needs: validate-dispatch runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: write + actions: read + pull-requests: read + id-token: write steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 From d373694b1cd8c1660ca4793d444035f457222243 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:58:50 +0200 Subject: [PATCH 5/5] test(release): pin scoped dispatch permissions --- tests/ci-workflows.test.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index f8d67f42b3..1f8865b903 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -557,17 +557,36 @@ describe("GitHub Actions hardening", () => { const workflow = await readText(".github/workflows/release.yml"); const release = Bun.YAML.parse(workflow) as { permissions?: Record; - jobs?: { publish?: { "runs-on"?: string } }; + jobs?: { + "validate-dispatch"?: { + "runs-on"?: string; + permissions?: Record; + }; + publish?: { + "runs-on"?: string; + needs?: string; + permissions?: Record; + }; + }; }; - - // Least privilege + never cancel a publish mid-flight. - expect(release.permissions).toEqual({ + + // Keep the workflow unprivileged by default. Dispatch validation gets only + // read access; write + OIDC permissions exist only on the gated publish job. + expect(release.permissions).toEqual({}); + + expect(release.jobs?.["validate-dispatch"]?.["runs-on"]).toBe("ubuntu-latest"); + expect(release.jobs?.["validate-dispatch"]?.permissions).toEqual({ + contents: "read", + }); + + expect(release.jobs?.publish?.needs).toBe("validate-dispatch"); + expect(release.jobs?.publish?.["runs-on"]).toBe("ubuntu-latest"); + expect(release.jobs?.publish?.permissions).toEqual({ contents: "write", actions: "read", "pull-requests": "read", "id-token": "write", }); - expect(release.jobs?.publish?.["runs-on"]).toBe("ubuntu-latest"); expect(workflow).toContain("actions: read"); expect(workflow).toContain("pull-requests: read"); expect(workflow).toContain("id-token: write");