diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 744fc0a14..b23a2862e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,10 +205,16 @@ jobs: ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }} run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')" + - name: Bind gate source to triggering commit + env: + EVENT_SHA: ${{ github.sha }} + VERIFIED_RELEASE_SHA: ${{ needs.verify.outputs.release_sha }} + run: node -e "const actual = process.env.EVENT_SHA; const expected = process.env.VERIFIED_RELEASE_SHA; if (typeof actual !== 'string' || actual.length !== 40 || !/^[a-f0-9]{40}$/.test(actual) || expected !== actual) throw new Error('Verified release differs from triggering commit')" + - name: Checkout verified gate source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.verify.outputs.release_sha }} + ref: ${{ github.sha }} path: release-gate-source persist-credentials: false sparse-checkout: scripts/ci/verify-release-gates.js diff --git a/tests/ci/release-packed-artifact-workflow.test.js b/tests/ci/release-packed-artifact-workflow.test.js index 1a44d0dbb..fd6082d13 100644 --- a/tests/ci/release-packed-artifact-workflow.test.js +++ b/tests/ci/release-packed-artifact-workflow.test.js @@ -4,6 +4,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); +const vm = require('vm'); const repoRoot = path.resolve(__dirname, '..', '..'); const workflowPaths = [ @@ -583,7 +584,8 @@ for (const workflowPath of workflowPaths) { const publish = workflow.jobs.publish; assert.deepStrictEqual(publish.permissions, { contents: 'write', 'id-token': 'write' }); const checkout = publish.steps.find(step => step.uses?.startsWith('actions/checkout@')); - assert.strictEqual(checkout.with.ref, '${{ needs.verify.outputs.release_sha }}'); + assert.strictEqual(checkout.with.ref, workflowPath === '.github/workflows/release.yml' + ? '${{ github.sha }}' : '${{ needs.verify.outputs.release_sha }}'); assert.strictEqual(checkout.with['persist-credentials'], false); assert.strictEqual(checkout.with.path, 'release-gate-source'); const index = publish.steps.findIndex(step => step.name === 'Recheck verified tag before publish'); @@ -597,6 +599,33 @@ for (const workflowPath of workflowPaths) { }); } +test('tag-push publish binds its event checkout to the verified release before loading code', () => { + const workflow = yaml.load(load('.github/workflows/release.yml')); + assert.deepStrictEqual(workflow.on, { push: { tags: ['v*'] } }); + const steps = workflow.jobs.publish.steps; + const binding = steps.findIndex(step => step.name === 'Bind gate source to triggering commit'); + const checkout = steps.findIndex(step => step.name === 'Checkout verified gate source'); + assert.ok(binding >= 0 && binding < checkout); + assert.strictEqual(steps[binding].if, undefined, 'binding must fail the job rather than silently skip'); + assert.strictEqual(steps[checkout].if, undefined); + assert.deepStrictEqual(steps[binding].env, { + EVENT_SHA: '${{ github.sha }}', + VERIFIED_RELEASE_SHA: '${{ needs.verify.outputs.release_sha }}', + }); + const program = /^node -e "([^\n"]+)"$/.exec(steps[binding].run); + assert.ok(program, 'binding must be a fixed environment-only Node check'); + const execute = env => vm.runInNewContext(program[1], { process: { env: Object.freeze(env) } }, { timeout: 100 }); + assert.doesNotThrow(() => execute({ EVENT_SHA: releaseSha, VERIFIED_RELEASE_SHA: releaseSha })); + for (const env of [ + {}, + { EVENT_SHA: releaseSha }, + { VERIFIED_RELEASE_SHA: releaseSha }, + { EVENT_SHA: releaseSha, VERIFIED_RELEASE_SHA: 'b'.repeat(40) }, + { EVENT_SHA: 'invalid', VERIFIED_RELEASE_SHA: 'invalid' }, + { EVENT_SHA: releaseSha + '\n', VERIFIED_RELEASE_SHA: releaseSha + '\n' }, + ]) assert.throws(() => execute(env), /Verified release differs from triggering commit/); +}); + test('reusable release requires its input to resolve through the tag namespace', () => { const source = load('.github/workflows/reusable-release.yml'); const verify = jobBlock(source, 'verify', 'lifecycle');