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
7 changes: 1 addition & 6 deletions .github/workflows/compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down
32 changes: 21 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/maintainers/trusted-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>-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.
Expand Down
83 changes: 83 additions & 0 deletions scripts/workflow-supply-chain-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand Down
106 changes: 106 additions & 0 deletions test/workflowSupplyChain.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down