diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index db61de0..ad613cf 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -2,11 +2,6 @@ name: Compatibility on: workflow_call: - inputs: - source_sha: - description: Optional exact source SHA supplied by the trusted release caller - required: false - type: string workflow_dispatch: pull_request: paths: @@ -75,7 +70,7 @@ jobs: - name: Checkout exact source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.sha }} - name: Setup Java if: matrix.platform == 'android' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82fd1d7..12e4b63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,9 +28,8 @@ concurrency: jobs: compatibility: name: Full compatibility release gate + if: inputs.confirm_publish == true && github.ref == 'refs/heads/master' && github.sha == inputs.source_sha uses: ./.github/workflows/compatibility.yml - with: - source_sha: ${{ inputs.source_sha }} permissions: contents: read @@ -50,18 +49,20 @@ jobs: echo "confirm_publish must be true." >&2 exit 1 - - name: Checkout exact source + - name: Checkout trusted workflow source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.source_sha }} + ref: ${{ github.sha }} fetch-depth: 0 - - name: Verify protected master identity + - name: Verify protected master identity before code execution env: EXPECTED_SHA: ${{ inputs.source_sha }} SOURCE_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.sha }} run: | test "$SOURCE_REF" = "refs/heads/master" + test "$WORKFLOW_SHA" = "$EXPECTED_SHA" test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" git fetch --no-tags origin master test "$(git rev-parse origin/master)" = "$EXPECTED_SHA" @@ -88,8 +89,6 @@ jobs: uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "24" - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Install release npm CLI run: npm install --global npm@12.0.1 @@ -185,10 +184,23 @@ jobs: id-token: write steps: - - name: Checkout exact source + - name: Checkout trusted workflow source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.source_sha }} + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Verify protected master identity before code execution + env: + EXPECTED_SHA: ${{ inputs.source_sha }} + SOURCE_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + test "$SOURCE_REF" = "refs/heads/master" + test "$WORKFLOW_SHA" = "$EXPECTED_SHA" + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + git fetch --no-tags origin master + test "$(git rev-parse origin/master)" = "$EXPECTED_SHA" - name: Setup pnpm uses: ./.github/actions/setup-pnpm @@ -197,8 +209,6 @@ jobs: uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "24" - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Install release npm CLI run: npm install --global npm@12.0.1 diff --git a/docs/maintainers/trusted-release.md b/docs/maintainers/trusted-release.md index d92cf5e..427f751 100644 --- a/docs/maintainers/trusted-release.md +++ b/docs/maintainers/trusted-release.md @@ -33,6 +33,13 @@ recovery codes in repository secrets. 3. Dispatch `.github/workflows/release.yml` with the exact version, source SHA, and `confirm_publish=true`. The exact version must have a matching `docs/launch/v-release-notes.md` file. + The workflow checks out GitHub's immutable event SHA rather than the + caller-supplied SHA. The compatibility gate first requires the event ref to + be `master` and the event SHA to equal the supplied SHA. In the preflight and + publish jobs, before any repository action, dependency installation, build, + or publish step, the event ref, event SHA, checked-out HEAD, supplied SHA, + and freshly fetched `origin/master` must identify the same protected commit. + Release jobs use no dependency cache. A mismatch fails closed. 4. Review the compatibility and exact-artifact preflight results. Approve the `npm-production` deployment only if its version, SHA, tarball SHA-256/SRI, inventory, release state, and registry action are correct. diff --git a/scripts/workflow-supply-chain-core.mjs b/scripts/workflow-supply-chain-core.mjs index 694e293..7ba425f 100644 --- a/scripts/workflow-supply-chain-core.mjs +++ b/scripts/workflow-supply-chain-core.mjs @@ -670,6 +670,89 @@ export function validateTrustedReleaseWorkflow(workflowSources) { ); assert(workflow, 'Trusted Release workflow is required.'); + const compatibilityWorkflow = workflowSources.find( + ({ workflow: workflowPath }) => + workflowPath === '.github/workflows/compatibility.yml' + ); + assert(compatibilityWorkflow, 'Compatibility workflow is required.'); + + const compatibilityJob = extractYamlMappingBlock( + workflow.source, + 2, + 'compatibility' + ); + assert( + !compatibilityJob.includes('source_sha:'), + 'Trusted Release must not forward a workflow_dispatch input as a reusable-workflow checkout ref.' + ); + assert( + compatibilityJob.includes( + "if: inputs.confirm_publish == true && github.ref == 'refs/heads/master' && github.sha == inputs.source_sha" + ), + 'Trusted Release compatibility must require confirmation, protected master, and exact event/input SHA equality.' + ); + assert( + !compatibilityWorkflow.source.includes('source_sha'), + 'Compatibility must select pull-request or workflow-event source, never a caller-controlled checkout ref.' + ); + assert( + compatibilityWorkflow.source.includes( + 'ref: ${{ github.sha }}' + ), + 'Compatibility must checkout only the trusted workflow-event SHA.' + ); + + assert( + !/^\s*ref:\s*.*inputs\./m.test(workflow.source), + 'Trusted Release must never checkout workflow_dispatch input as executable source.' + ); + assert( + workflow.source.match(/ref: \$\{\{ github\.sha \}\}/g)?.length === 2, + 'Trusted Release must checkout the trusted workflow-event SHA in both executable jobs.' + ); + assert( + !/^\s*cache(?:-dependency-path)?:/m.test(workflow.source), + 'Trusted Release must not restore or save dependency caches in the privileged publish workflow.' + ); + + for (const jobName of ['preflight', 'publish']) { + const job = extractYamlMappingBlock(workflow.source, 2, jobName); + const checkoutIndex = job.indexOf('- name: Checkout trusted workflow source'); + const identityIndex = job.indexOf( + '- name: Verify protected master identity before code execution' + ); + const firstRepositoryCodeIndex = job.indexOf('- name: Setup pnpm'); + + assert( + checkoutIndex >= 0 && + identityIndex > checkoutIndex && + firstRepositoryCodeIndex > identityIndex, + `Trusted Release ${jobName} must verify trusted source before executing repository code.` + ); + const betweenCheckoutAndIdentity = job.slice(checkoutIndex, identityIndex); + assert( + !/\n {6}- /.test(betweenCheckoutAndIdentity), + `Trusted Release ${jobName} identity verification must immediately follow checkout.` + ); + + for (const required of [ + 'ref: ${{ github.sha }}', + 'EXPECTED_SHA: ${{ inputs.source_sha }}', + 'SOURCE_REF: ${{ github.ref }}', + 'WORKFLOW_SHA: ${{ github.sha }}', + 'test "$SOURCE_REF" = "refs/heads/master"', + 'test "$WORKFLOW_SHA" = "$EXPECTED_SHA"', + 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"', + 'git fetch --no-tags origin master', + 'test "$(git rev-parse origin/master)" = "$EXPECTED_SHA"', + ]) { + assert( + job.includes(required), + `Trusted Release ${jobName} is missing protected-source contract: ${required}` + ); + } + } + for (const required of [ 'release_notes="docs/launch/v${VERSION}-release-notes.md"', 'test -f "$release_notes"', diff --git a/test/workflowSupplyChain.test.mjs b/test/workflowSupplyChain.test.mjs index f96584e..3b0bdb3 100644 --- a/test/workflowSupplyChain.test.mjs +++ b/test/workflowSupplyChain.test.mjs @@ -581,6 +581,112 @@ describe('GitHub Actions workflow supply-chain gate', () => { } }); + it('keeps privileged release execution on the trusted event SHA before repository code', () => { + const mutations = [ + { + label: 'preflight-input-checkout', + workflow: 'release.yml', + mutate: (source) => + source.replace( + 'ref: ${{ github.sha }}', + 'ref: ${{ inputs.source_sha }}' + ), + }, + { + label: 'publish-input-checkout', + workflow: 'release.yml', + mutate: (source) => { + const trustedRef = 'ref: ${{ github.sha }}'; + const first = source.indexOf(trustedRef); + const second = source.indexOf(trustedRef, first + trustedRef.length); + return ( + source.slice(0, second) + + 'ref: ${{ inputs.source_sha }}' + + source.slice(second + trustedRef.length) + ); + }, + }, + { + label: 'forwarded-input', + workflow: 'release.yml', + mutate: (source) => + source.replace( + ' uses: ./.github/workflows/compatibility.yml\n permissions:', + ' uses: ./.github/workflows/compatibility.yml\n with:\n source_sha: ${{ inputs.source_sha }}\n permissions:' + ), + }, + { + label: 'missing-event-equality', + workflow: 'release.yml', + mutate: (source) => + source.replace(' test "$WORKFLOW_SHA" = "$EXPECTED_SHA"\n', ''), + }, + { + label: 'missing-master-equality', + workflow: 'release.yml', + mutate: (source) => + source.replace( + ' test "$(git rev-parse origin/master)" = "$EXPECTED_SHA"\n', + '' + ), + }, + { + label: 'privileged-cache', + workflow: 'release.yml', + mutate: (source) => + source.replace( + ' node-version: "24"', + ' node-version: "24"\n cache: pnpm\n cache-dependency-path: pnpm-lock.yaml' + ), + }, + { + label: 'caller-controlled-compatibility', + workflow: 'compatibility.yml', + mutate: (source) => + source.replace( + 'github.sha', + 'inputs.source_sha || github.event.pull_request.head.sha || github.sha' + ), + }, + { + label: 'identity-after-repository-code', + workflow: 'release.yml', + mutate: (source) => { + const identityStart = source.indexOf( + ' - name: Verify protected master identity before code execution' + ); + const setupStart = source.indexOf(' - name: Setup pnpm', identityStart); + const setupEnd = source.indexOf('\n\n', setupStart) + 2; + const identityEnd = source.indexOf('\n\n', identityStart) + 2; + const identity = source.slice(identityStart, identityEnd); + return `${source.slice(0, identityStart)}${source.slice(identityEnd, setupEnd)}${identity}${source.slice(setupEnd)}`; + }, + }, + { + label: 'step-between-checkout-and-identity', + workflow: 'release.yml', + mutate: (source) => + source.replace( + ' - name: Verify protected master identity before code execution', + ' - run: node scripts/verify-site.mjs\n\n - name: Verify protected master identity before code execution' + ), + }, + ]; + + for (const { label, workflow, mutate } of mutations) { + const { parent, rootDir } = copiedRepository(`trusted-release-${label}`); + try { + mutateWorkflow(rootDir, workflow, mutate); + const result = verify(rootDir); + expect(result.status).toBe('failed'); + expect(result.checks.workflows).toBe(false); + expect(result.error).toMatch(/Trusted Release|Compatibility/); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + } + }); + it('preserves an existing report and removes the temporary file on atomic failure', () => { const parent = mkdtempSync(path.join(os.tmpdir(), 'rnick-workflow-atomic-')); try {