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
44 changes: 44 additions & 0 deletions .github/scripts/release-dispatch-guard.cjs
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

module.exports = {
ALLOWED_RELEASE_REFS,
validateReleaseDispatch,
};
66 changes: 66 additions & 0 deletions .github/scripts/release-dispatch-guard.test.cjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
50 changes: 43 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,58 @@ 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:
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
Expand All @@ -54,7 +89,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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
29 changes: 24 additions & 5 deletions tests/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
jobs?: { publish?: { "runs-on"?: string } };
jobs?: {
"validate-dispatch"?: {
"runs-on"?: string;
permissions?: Record<string, string>;
};
publish?: {
"runs-on"?: string;
needs?: string;
permissions?: Record<string, string>;
};
};
};

// 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");
Expand Down
Loading