diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdad0d483..744fc0a14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,8 @@ on: tags: ['v*'] permissions: + actions: read + checks: read contents: read jobs: @@ -12,6 +14,8 @@ jobs: name: Verify Release runs-on: ubuntu-latest outputs: + release_sha: ${{ steps.release_gate.outputs.release_sha }} + tag_object_sha: ${{ steps.release_gate.outputs.tag_object_sha }} already_published: ${{ steps.npm_publish_state.outputs.already_published }} dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }} publish_tag: ${{ steps.npm_publish_state.outputs.publish_tag }} @@ -43,6 +47,13 @@ jobs: node-version: '20.x' registry-url: 'https://registry.npmjs.org' + - name: Verify signed tag and exact-SHA CI gates + id: release_gate + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: RELEASE_SHA="$(git rev-parse HEAD)" node scripts/ci/verify-release-gates.js + - name: Install dependencies run: npm ci --ignore-scripts @@ -194,6 +205,25 @@ 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: Checkout verified gate source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.verify.outputs.release_sha }} + path: release-gate-source + persist-credentials: false + sparse-checkout: scripts/ci/verify-release-gates.js + sparse-checkout-cone-mode: false + + # This read-only API check uses the existing publish job token. It is a + # snapshot; preventing subsequent tag movement requires protected tags. + - name: Recheck verified tag before publish + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_SHA: ${{ needs.verify.outputs.release_sha }} + RELEASE_TAG_OBJECT_SHA: ${{ needs.verify.outputs.tag_object_sha }} + run: node release-gate-source/scripts/ci/verify-release-gates.js --tag-only + - name: Publish npm package if: needs.verify.outputs.already_published != 'true' env: diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index b038b1b8c..74ab4c003 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -18,6 +18,8 @@ on: type: string permissions: + actions: read + checks: read contents: read jobs: @@ -25,6 +27,8 @@ jobs: name: Verify Release runs-on: ubuntu-latest outputs: + release_sha: ${{ steps.release_gate.outputs.release_sha }} + tag_object_sha: ${{ steps.release_gate.outputs.tag_object_sha }} already_published: ${{ steps.npm_publish_state.outputs.already_published }} dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }} publish_tag: ${{ steps.npm_publish_state.outputs.publish_tag }} @@ -57,6 +61,13 @@ jobs: node-version: '20.x' registry-url: 'https://registry.npmjs.org' + - name: Verify signed tag and exact-SHA CI gates + id: release_gate + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: RELEASE_SHA="$(git rev-parse HEAD)" node scripts/ci/verify-release-gates.js + - name: Install dependencies run: npm ci --ignore-scripts @@ -208,6 +219,25 @@ 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: Checkout verified gate source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.verify.outputs.release_sha }} + path: release-gate-source + persist-credentials: false + sparse-checkout: scripts/ci/verify-release-gates.js + sparse-checkout-cone-mode: false + + # This read-only API check uses the existing publish job token. It is a + # snapshot; preventing subsequent tag movement requires protected tags. + - name: Recheck verified tag before publish + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_SHA: ${{ needs.verify.outputs.release_sha }} + RELEASE_TAG_OBJECT_SHA: ${{ needs.verify.outputs.tag_object_sha }} + run: node release-gate-source/scripts/ci/verify-release-gates.js --tag-only + - name: Publish npm package if: needs.verify.outputs.already_published != 'true' env: diff --git a/scripts/ci/verify-release-gates.js b/scripts/ci/verify-release-gates.js new file mode 100644 index 000000000..3c8224c6e --- /dev/null +++ b/scripts/ci/verify-release-gates.js @@ -0,0 +1,351 @@ +'use strict'; + +const fs = require('node:fs'); +const { performance } = require('node:perf_hooks'); + +const API_VERSION = '2022-11-28'; +const SHA = /^[0-9a-f]{40}$/; +const MAX_PAGES = 10; +const MAX_ITEMS = 1000; +const DEFAULT_ATTEMPTS = 20; +const DEFAULT_DELAY_MS = 30_000; +const TOTAL_TIMEOUT_MS = 600_000; +const REQUEST_TIMEOUT_MS = 15_000; +const CI_PATH = '.github/workflows/ci.yml'; +const CODEQL_PATH = 'dynamic/github-code-scanning/codeql'; +// Repository policy: default CodeQL must complete all three categories in ONE +// attempt. A new category requires an explicit policy update, not silent approval. +const REQUIRED_CODEQL = ['Analyze (actions)', 'Analyze (javascript-typescript)', 'Analyze (python)']; +const ACTIONS_APP = { id: 15368, slug: 'github-actions' }; + +const record = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const id = value => Number.isSafeInteger(value) && value > 0; +const sha = value => typeof value === 'string' && SHA.test(value); +const text = value => typeof value === 'string' && value.length > 0; +const resultShape = value => record(value) && text(value.status) + && (value.conclusion === null || text(value.conclusion)); +const repoShape = value => record(value) && id(value.id) && text(value.full_name); +const objectShape = value => record(value) && text(value.type) && sha(value.sha); +const referenceShape = value => record(value) && text(value.ref) && objectShape(value.object); +const tagShape = value => record(value) && sha(value.sha) && text(value.tag) + && objectShape(value.object) && record(value.verification) + && typeof value.verification.verified === 'boolean' && text(value.verification.reason); +const workflowShape = value => record(value) && id(value.id) && text(value.path) && text(value.state); +const runShape = value => resultShape(value) && id(value.id) && id(value.workflow_id) + && text(value.path) && sha(value.head_sha) && text(value.head_branch) && text(value.event) + && id(value.run_attempt) && id(value.check_suite_id) + && repoShape(value.repository) && repoShape(value.head_repository); +const checkShape = value => resultShape(value) && id(value.id) && text(value.name) + && sha(value.head_sha) && record(value.check_suite) && id(value.check_suite.id) + && record(value.app) && id(value.app.id) && text(value.app.slug); +const jobShape = value => resultShape(value) && id(value.id) && text(value.name) + && id(value.run_id) && id(value.run_attempt) && sha(value.head_sha) + && text(value.head_branch) && text(value.check_run_url); + +function requiredEnvironment(env = process.env) { + const inputs = { + repository: env.GITHUB_REPOSITORY, + releaseSha: env.RELEASE_SHA, + releaseTag: env.RELEASE_TAG, + token: env.GITHUB_TOKEN, + tagObjectSha: env.RELEASE_TAG_OBJECT_SHA, + }; + for (const name of ['repository', 'releaseSha', 'releaseTag', 'token']) { + if (!text(inputs[name])) throw new Error(`Missing required release gate input: ${name}`); + } + validateInputs(inputs); + return inputs; +} + +function validateInputs(inputs) { + if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]*\/[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(inputs.repository || '')) { + throw new Error('Invalid release repository'); + } + if (!sha(inputs.releaseSha)) throw new Error('RELEASE_SHA must be a full lowercase commit SHA'); + if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(inputs.releaseTag || '')) { + throw new Error('RELEASE_TAG is not a supported version tag'); + } + if (!text(inputs.token)) throw new Error('Missing release gate token'); + if (inputs.tagObjectSha !== undefined && !sha(inputs.tagObjectSha)) { + throw new Error('Invalid expected tag object SHA'); + } +} + +function setting(value, fallback, maximum) { + const parsed = value === undefined ? fallback : Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > maximum) { + throw new Error('Release gate settings must be positive integers within their finite limits'); + } + return parsed; +} + +function createGithubClient(inputs, fetchImpl = fetch, options = {}) { + validateInputs(inputs); + const now = options.now || (() => performance.now()); + const deadline = now() + setting(options.timeoutMs, TOTAL_TIMEOUT_MS, TOTAL_TIMEOUT_MS); + const requestMs = setting(options.requestTimeoutMs, REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS); + const base = `https://api.github.com/repos/${inputs.repository}`; + + function remaining() { + const left = deadline - now(); + if (left <= 0) throw new Error('Release gate global deadline exceeded'); + return left; + } + + async function bounded(operation, limit) { + const controller = new AbortController(); + let timer; + try { + return await Promise.race([ + Promise.resolve().then(() => operation(controller.signal)), + new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error('Release gate request or global deadline exceeded')); + }, Math.min(limit, remaining())); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + function urlFor(pathOrUrl) { + const url = new URL(pathOrUrl === '' || pathOrUrl.startsWith('/') ? base + pathOrUrl : pathOrUrl); + if (url.origin !== 'https://api.github.com' || url.username || url.password || url.hash + || (url.pathname !== `/repos/${inputs.repository}` && !url.pathname.startsWith(`/repos/${inputs.repository}/`))) { + throw new Error('GitHub API URL escaped the release repository'); + } + return url; + } + + async function page(url, validator) { + remaining(); + return bounded(async signal => { + const response = await fetchImpl(url.toString(), { + redirect: 'error', signal, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${inputs.token}`, + 'X-GitHub-Api-Version': API_VERSION, + }, + }); + if (!response.ok) throw new Error(`GitHub API failed with status ${response.status}`); + let payload; + try { payload = await response.json(); } catch { throw new Error('Invalid GitHub API JSON response'); } + if (!validator(payload)) throw new Error('GitHub API response validation failed'); + remaining(); + return { payload, link: response.headers?.get?.('link') }; + }, requestMs); + } + + async function get(path, validator) { + return (await page(urlFor(path), validator)).payload; + } + + async function pages(path, key, itemValidator) { + const first = urlFor(path); + const seen = new Set(); + const identities = new Set(); + let next = first; + let total; + const items = []; + while (next) { + const identity = paginationIdentity(next, first); + if (seen.has(identity)) throw new Error('GitHub API pagination cycle'); + if (seen.size >= MAX_PAGES) throw new Error('GitHub API page limit exceeded'); + seen.add(identity); + const { payload, link } = await page(next, value => record(value) + && Number.isSafeInteger(value.total_count) && value.total_count >= 0 + && Array.isArray(value[key])); + if (payload.total_count > MAX_ITEMS || payload[key].length > 100) { + throw new Error('GitHub API item limit exceeded'); + } + if (total !== undefined && total !== payload.total_count) throw new Error('GitHub API collection total changed'); + total = payload.total_count; + for (const item of payload[key]) { + if (!itemValidator(item)) throw new Error('GitHub API response validation failed'); + if (identities.has(item.id)) throw new Error('Ambiguous duplicate GitHub API item'); + identities.add(item.id); + items.push(item); + } + if (items.length > MAX_ITEMS || items.length > total) throw new Error('GitHub API item limit or total exceeded'); + const linkUrl = nextPageUrl(link); + next = linkUrl ? urlFor(linkUrl) : null; + } + if (items.length !== total) throw new Error('Incomplete GitHub API collection total'); + return items; + } + + return { get, pages, pause: sleep => bounded(signal => sleep(signal), remaining()), remaining }; +} + +function paginationIdentity(url, first) { + const query = candidate => { + const keys = [...candidate.searchParams.keys()]; + if (new Set(keys).size !== keys.length) throw new Error('Ambiguous pagination query'); + return [...candidate.searchParams].filter(([key]) => key !== 'page').sort().map(pair => JSON.stringify(pair)).join(','); + }; + const page = url.searchParams.get('page'); + if (url.pathname !== first.pathname || query(url) !== query(first) + || (page !== null && !/^[1-9][0-9]*$/.test(page))) { + throw new Error('GitHub API pagination escaped the endpoint collection'); + } + return `${url.pathname}?${query(url)}&page=${page || '1'}`; +} + +function nextPageUrl(header) { + if (!header) return null; + let next = null; + for (const entry of header.split(',')) { + const match = entry.trim().match(/^<([^>]+)>;\s*rel="(next|prev|first|last)"$/); + if (!match) throw new Error('Malformed GitHub API pagination Link'); + if (match[2] === 'next') { + if (next) throw new Error('Ambiguous GitHub API next page'); + next = match[1]; + } + } + return next; +} + +async function verifySignedAnnotatedTag(inputs, fetchImpl = fetch, options = {}) { + validateInputs(inputs); + const client = options.client || createGithubClient(inputs, fetchImpl, options); + const reference = await client.get(`/git/ref/tags/${encodeURIComponent(inputs.releaseTag)}`, referenceShape); + if (reference.ref !== `refs/tags/${inputs.releaseTag}` || reference.object.type !== 'tag') { + throw new Error('Release ref must match the requested annotated tag; lightweight tags are rejected'); + } + if (inputs.tagObjectSha && reference.object.sha !== inputs.tagObjectSha) { + throw new Error('Release tag object changed after initial verification'); + } + const tag = await client.get(`/git/tags/${reference.object.sha}`, tagShape); + if (tag.sha !== reference.object.sha || tag.tag !== inputs.releaseTag) { + throw new Error('Signed tag object identity or name does not match the release ref'); + } + // GitHub signature validity is not a project-specific authorized-signer list. + if (tag.verification.verified !== true || tag.verification.reason !== 'valid') { + throw new Error('Release tag signature is not verified'); + } + if (tag.object.type !== 'commit' || tag.object.sha !== inputs.releaseSha) { + throw new Error('Verified release tag does not point at the checked-out commit'); + } + return tag.sha; +} + +async function trustedProducers(client, inputs) { + const repository = await client.get('', value => repoShape(value) && value.default_branch === 'main'); + if (repository.full_name !== inputs.repository) throw new Error('Repository identity mismatch'); + const workflows = await client.pages('/actions/workflows?per_page=100', 'workflows', workflowShape); + const select = path => { + const matches = workflows.filter(workflow => workflow.path === path); + if (matches.length !== 1 || matches[0].state !== 'active') throw new Error('Missing or ambiguous active trusted workflow'); + return matches[0]; + }; + return { repository, ci: select(CI_PATH), codeql: select(CODEQL_PATH) }; +} + +function selectRuns(runs, inputs, trusted) { + const sameRepo = repo => repo.id === trusted.repository.id && repo.full_name === inputs.repository; + const select = (workflow, event) => runs.filter(run => run.workflow_id === workflow.id + && run.path === workflow.path && run.head_sha === inputs.releaseSha && run.head_branch === 'main' + && run.event === event && sameRepo(run.repository) && sameRepo(run.head_repository)) + .sort((a, b) => b.id - a.id || b.run_attempt - a.run_attempt)[0]; + return { ci: select(trusted.ci, 'push'), codeql: select(trusted.codeql, 'dynamic') }; +} + +function statusOf(result, label) { + if (!result || result.status !== 'completed') return { state: 'pending' }; + return result.conclusion === 'success' ? { state: 'passed' } + : { state: 'failed', reason: `${label} concluded ${result.conclusion}` }; +} + +function assessExactShaGates(selected, checks, jobs, inputs) { + for (const [name, run] of Object.entries(selected)) { + const assessment = statusOf(run, name); + if (assessment.state !== 'passed') return assessment; + } + const run = selected.codeql; + if (jobs.some(job => !REQUIRED_CODEQL.includes(job.name))) { + throw new Error('Unexpected CodeQL category; review the explicit required-category policy'); + } + for (const name of REQUIRED_CODEQL) { + const matches = jobs.filter(job => job.name === name); + if (matches.length > 1) throw new Error('Ambiguous required CodeQL job'); + const job = matches[0]; + if (!job) return { state: 'pending' }; + if (job.run_id !== run.id || job.run_attempt !== run.run_attempt + || job.head_sha !== inputs.releaseSha || job.head_branch !== 'main') { + throw new Error('CodeQL job does not belong to the selected run attempt'); + } + const check = checks.find(candidate => job.check_run_url + === `https://api.github.com/repos/${inputs.repository}/check-runs/${candidate.id}`); + if (!check || check.name !== name || check.head_sha !== inputs.releaseSha + || check.check_suite.id !== run.check_suite_id || check.app.id !== ACTIONS_APP.id + || check.app.slug !== ACTIONS_APP.slug) return { state: 'pending' }; + for (const result of [job, check]) { + const assessment = statusOf(result, name); + if (assessment.state !== 'passed') return assessment; + } + } + return { state: 'passed' }; +} + +function defaultSleep(delay, signal) { + return new Promise(resolve => { + const timer = setTimeout(resolve, delay); + signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); + }); +} + +async function waitForExactShaGates(inputs, fetchImpl = fetch, sleep = defaultSleep, options = {}) { + const client = options.client || createGithubClient(inputs, fetchImpl, options); + const attempts = setting(options.attempts ?? process.env.RELEASE_GATE_ATTEMPTS, DEFAULT_ATTEMPTS, DEFAULT_ATTEMPTS); + const delay = setting(options.delayMs ?? process.env.RELEASE_GATE_DELAY_MS, DEFAULT_DELAY_MS, DEFAULT_DELAY_MS); + const trusted = await trustedProducers(client, inputs); + const readRuns = async () => selectRuns(await client.pages( + `/actions/runs?head_sha=${inputs.releaseSha}&branch=main&per_page=100`, 'workflow_runs', runShape + ), inputs, trusted); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const selected = await readRuns(); + let assessment = statusOf(selected.ci, 'CI'); + if (assessment.state === 'passed') assessment = statusOf(selected.codeql, 'CodeQL'); + if (assessment.state === 'passed') { + const run = selected.codeql; + const jobs = await client.pages(`/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`, 'jobs', jobShape); + const checks = await client.pages(`/check-suites/${run.check_suite_id}/check-runs?filter=all&per_page=100`, 'check_runs', checkShape); + assessment = assessExactShaGates(selected, checks, jobs, inputs); + if (assessment.state === 'passed') { + // Do not approve an attempt superseded while its jobs/checks were read. + const finalRuns = await readRuns(); + if (JSON.stringify(finalRuns) === JSON.stringify(selected)) return; + assessment = { state: 'pending' }; + } + } + if (assessment.state === 'failed') throw new Error(assessment.reason); + if (attempt < attempts) await client.pause(signal => sleep(delay, signal)); + } + throw new Error('Timed out waiting for successful exact-SHA CI and CodeQL checks'); +} + +async function main() { + const inputs = requiredEnvironment(); + const tagOnly = process.argv.includes('--tag-only'); + if (tagOnly && !inputs.tagObjectSha) throw new Error('Tag-only recheck requires the original tag object SHA'); + const client = createGithubClient(inputs); + const tagObjectSha = await verifySignedAnnotatedTag(inputs, fetch, { client }); + if (!tagOnly) await waitForExactShaGates(inputs, fetch, defaultSleep, { client }); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `release_sha=${inputs.releaseSha}\ntag_object_sha=${tagObjectSha}\n`); + } + console.log(tagOnly ? 'Verified unchanged release tag snapshot.' + : 'Verified signed annotated tag and successful exact-SHA CI/CodeQL gates.'); +} + +if (require.main === module) { + main().catch(error => { + console.error(`Release gate verification failed: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { assessExactShaGates, createGithubClient, requiredEnvironment, verifySignedAnnotatedTag, waitForExactShaGates }; diff --git a/scripts/release.sh b/scripts/release.sh index bca4a0381..21e9e4311 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -77,7 +77,7 @@ fi if [[ "$OLD_VERSION" == "$VERSION" ]]; then echo "Error: Version $VERSION is already declared in release metadata." echo "After the merged commit passes CI, publish it through the tag workflow:" - echo " git tag \"v$VERSION\"" + echo " git tag -s \"v$VERSION\" -m \"Release v$VERSION\"" echo " git push origin \"v$VERSION\"" exit 1 fi @@ -328,10 +328,11 @@ node scripts/build-opencode.js node tests/scripts/build-opencode.test.js node tests/plugin-manifest.test.js -# Stage, commit, tag, and push +# Stage, commit, explicitly sign an annotated tag, and push. Signing failure +# stops here under set -e; no personal tag.gpgSign default is assumed. git add "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$CODEX_MARKETPLACE_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ROOT_ZH_CN_README_FILE" "$TR_README_FILE" "$PT_BR_README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC" git commit -m "chore: bump plugin version to $VERSION" -git tag "v$VERSION" +git tag -s "v$VERSION" -m "Release v$VERSION" git push origin main "v$VERSION" echo "Released v$VERSION" diff --git a/tests/ci/release-packed-artifact-workflow.test.js b/tests/ci/release-packed-artifact-workflow.test.js index 4ec23ffc4..772a988c9 100644 --- a/tests/ci/release-packed-artifact-workflow.test.js +++ b/tests/ci/release-packed-artifact-workflow.test.js @@ -3,27 +3,45 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); +const yaml = require('js-yaml'); const repoRoot = path.resolve(__dirname, '..', '..'); const workflowPaths = [ '.github/workflows/release.yml', '.github/workflows/reusable-release.yml', ]; +const { + createGithubClient, + requiredEnvironment, + verifySignedAnnotatedTag, + waitForExactShaGates, +} = require('../../scripts/ci/verify-release-gates.js'); const lifecycleRunnerSource = load('tests/ci/packed-artifact-lifecycle.js'); let passed = 0; let failed = 0; +let pendingTests = Promise.resolve(); function test(name, fn) { - try { - fn(); - console.log(` ✓ ${name}`); - passed += 1; - } catch (error) { - console.log(` ✗ ${name}`); - console.log(` Error: ${error.message}`); - failed += 1; - } + pendingTests = pendingTests.then(async () => { + try { + await fn(); + pass(name); + } catch (error) { + fail(name, error); + } + }); +} + +function pass(name) { + console.log(` ✓ ${name}`); + passed += 1; +} + +function fail(name, error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed += 1; } function load(relativePath) { @@ -49,6 +67,27 @@ console.log('\n=== Testing packed-artifact release workflows ===\n'); for (const workflowPath of workflowPaths) { const source = load(workflowPath); + test(`${workflowPath} verifies signed tags and exact-SHA CI gates before building`, () => { + const verify = jobBlock(source, 'verify', 'lifecycle'); + const workflow = yaml.load(source); + const verifyJob = workflow.jobs.verify; + const gateStep = verifyJob.steps.find( + step => step.name === 'Verify signed tag and exact-SHA CI gates' + ); + const gateIndex = verify.indexOf('name: Verify signed tag and exact-SHA CI gates'); + const installIndex = verify.indexOf('name: Install dependencies'); + const effectivePermissions = verifyJob.permissions || workflow.permissions || {}; + + assert.ok(gateIndex >= 0, 'missing release provenance gate'); + assert.ok(installIndex > gateIndex, 'release provenance must be verified before dependencies run'); + assert.ok(gateStep, 'missing named release provenance gate step'); + assert.match(gateStep.run, /node scripts\/ci\/verify-release-gates\.js/); + assert.match(gateStep.run, /RELEASE_SHA=/); + assert.ok(gateStep.env?.RELEASE_TAG, 'gate step must receive RELEASE_TAG'); + assert.strictEqual(effectivePermissions.actions, 'read'); + assert.strictEqual(effectivePermissions.checks, 'read'); + }); + test(`${workflowPath} packs once and exports the package name and SHA-256`, () => { assert.strictEqual( (source.match(/npm pack --json/g) || []).length, @@ -151,6 +190,336 @@ for (const workflowPath of workflowPaths) { }); } +// Synthetic repository facts mirror the trusted workflow/attempt API contracts. +const releaseSha = 'a'.repeat(40); +const tagSha = 'b'.repeat(40); +const repository = 'affaan-m/ECC'; +const inputs = { repository, releaseSha, releaseTag: 'v1.2.3', token: 'synthetic-token' }; +const repoIdentity = { id: 1136590548, full_name: repository, default_branch: 'main' }; +const requiredNames = ['Analyze (actions)', 'Analyze (javascript-typescript)', 'Analyze (python)']; +const workflows = [ + { id: 228254391, path: '.github/workflows/ci.yml', state: 'active' }, + { id: 292501745, path: 'dynamic/github-code-scanning/codeql', state: 'active' }, +]; +function fixture() { + const runs = workflows.map((workflow, index) => ({ + id: 10 + index, workflow_id: workflow.id, path: workflow.path, + head_sha: releaseSha, head_branch: 'main', event: index ? 'dynamic' : 'push', + run_attempt: 1, check_suite_id: 100 + index, status: 'completed', conclusion: 'success', + repository: { ...repoIdentity }, head_repository: { ...repoIdentity }, + })); + const checks = requiredNames.map((name, index) => ({ + id: 200 + index, name, head_sha: releaseSha, status: 'completed', conclusion: 'success', + check_suite: { id: 101 }, app: { id: 15368, slug: 'github-actions' }, + })); + const jobs = checks.map(check => ({ + id: check.id, name: check.name, run_id: 11, run_attempt: 1, + head_sha: releaseSha, head_branch: 'main', status: 'completed', conclusion: 'success', + check_run_url: `https://api.github.com/repos/${repository}/check-runs/${check.id}`, + })); + return { runs, checks, jobs, workflows: structuredClone(workflows), repo: { ...repoIdentity } }; +} +function response(payload, link = null) { + return { ok: true, status: 200, headers: { get: () => link }, json: async () => payload }; +} +function fakeApi(data = fixture(), modify = () => null) { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push(url); + const replacement = modify(url, options, calls); + if (replacement) return replacement; + const pathname = new URL(url).pathname.replace(`/repos/${repository}`, ''); + if (pathname === '') return response(data.repo); + if (pathname === '/actions/workflows') return response({ total_count: data.workflows.length, workflows: data.workflows }); + if (pathname === '/actions/runs') return response({ total_count: data.runs.length, workflow_runs: data.runs }); + if (pathname === '/actions/runs/11/attempts/1/jobs') return response({ total_count: data.jobs.length, jobs: data.jobs }); + if (pathname === '/check-suites/101/check-runs') return response({ total_count: data.checks.length, check_runs: data.checks }); + if (pathname === '/git/ref/tags/v1.2.3') return response({ ref: 'refs/tags/v1.2.3', object: { type: 'tag', sha: tagSha } }); + if (pathname === `/git/tags/${tagSha}`) return response({ sha: tagSha, tag: 'v1.2.3', object: { type: 'commit', sha: releaseSha }, verification: { verified: true, reason: 'valid' } }); + throw new Error(`Unexpected synthetic API path ${pathname}`); + }; + return { fetchImpl, calls }; +} +const once = { attempts: 1, timeoutMs: 1000, requestTimeoutMs: 100 }; +async function gates(data, modify) { + const api = fakeApi(data, modify); + await waitForExactShaGates(inputs, api.fetchImpl, async () => {}, once); + return api.calls; +} + +test('pre-install verifier loads with built-ins only and still rejects malformed responses', async () => { + const vm = require('node:vm'); + const { isBuiltin } = require('node:module'); + const exported = {}; + const localModule = { exports: exported }; + vm.runInNewContext(load('scripts/ci/verify-release-gates.js'), { + module: localModule, exports: exported, + require: name => { assert.ok(isBuiltin(name), `pre-install dependency: ${name}`); return require(name); }, + process: { env: {} }, URL, AbortController, setTimeout, clearTimeout, fetch: () => { throw new Error('Unexpected live fetch'); }, + }); + await assert.rejects(localModule.exports.verifySignedAnnotatedTag(inputs, async () => response({ object: { type: 'tag' } })), /validation|Invalid/); + assert.strictEqual(await localModule.exports.verifySignedAnnotatedTag(inputs, fakeApi().fetchImpl), tagSha); + await localModule.exports.waitForExactShaGates(inputs, fakeApi().fetchImpl, async () => {}, once); +}); + +test('complete trusted CI and default CodeQL categories pass without display-name trust', async () => { + const data = fixture(); + data.runs[0].name = 'Renamed CI'; + data.runs[1].name = 'Push on main'; + const calls = await gates(data); + assert.ok(calls.some(url => url.includes('/attempts/1/jobs'))); + assert.ok(calls.some(url => url.includes('/check-suites/101/check-runs'))); +}); + +for (const [name, mutate] of [ + ['impostor CI workflow', d => { d.runs[0].workflow_id = 999; d.runs[0].name = 'CI'; }], + ['wrong workflow path', d => { d.runs[0].path = '.github/workflows/spoof.yml'; }], + ['wrong CI event', d => { d.runs[0].event = 'pull_request'; }], + ['wrong main branch', d => { d.runs[0].head_branch = 'release/x'; }], + ['wrong run SHA', d => { d.runs[0].head_sha = 'c'.repeat(40); }], + ['foreign run repository', d => { d.runs[0].repository.id = 1; }], + ['foreign head repository', d => { d.runs[0].head_repository.full_name = 'impostor/ECC'; }], + ['untrusted check app', d => { d.checks[0].app.id = 1; }], + ['wrong app slug', d => { d.checks[0].app.slug = 'spoof'; }], + ['wrong check suite', d => { d.checks[0].check_suite.id = 999; }], + ['wrong check SHA', d => { d.checks[0].head_sha = 'c'.repeat(40); }], + ['missing required category', d => { d.jobs.pop(); }], + ['missing bound check', d => { d.checks.pop(); }], + ['new pending category', d => { d.jobs.push({ ...d.jobs[0], id: 999, name: 'Analyze (ruby)', status: 'queued', conclusion: null }); }], + ['ambiguous category jobs', d => { d.jobs.push({ ...d.jobs[0], id: 999 }); }], + ['wrong attempt job', d => { d.jobs[0].run_attempt = 2; }], + ['wrong run job', d => { d.jobs[0].run_id = 90; }], + ['wrong job branch', d => { d.jobs[0].head_branch = 'feature'; }], + ['foreign check URL', d => { d.jobs[0].check_run_url = 'https://api.github.com/repos/spoof/ECC/check-runs/200'; }], + ['job name does not match bound check', d => { d.checks[0].name = 'CodeQL'; }], + ['ambiguous workflow metadata', d => { d.workflows.push({ ...d.workflows[0], id: 999 }); }], + ['inactive trusted workflow', d => { d.workflows[0].state = 'disabled_manually'; }], +]) { + test(`release gate rejects ${name}`, async () => { + const data = fixture(); mutate(data); + await assert.rejects(gates(data)); + }); +} + +for (const conclusion of ['failure', 'cancelled', 'skipped', 'neutral', 'timed_out', 'action_required']) { + test(`required trusted check ${conclusion} fails despite newer spoof success`, async () => { + const data = fixture(); + data.checks[0].conclusion = conclusion; + data.checks.push({ ...data.checks[0], id: 999, conclusion: 'success', app: { id: 1, slug: 'spoof' } }); + await assert.rejects(gates(data), /concluded/); + }); +} + +test('newer display-name impostor cannot replace a failed trusted CI run', async () => { + const data = fixture(); data.runs[0].conclusion = 'failure'; + data.runs.push({ ...data.runs[0], id: 999, workflow_id: 999, name: 'CI', conclusion: 'success' }); + await assert.rejects(gates(data), /CI concluded failure/); +}); + +test('workflow IDs come from exact-path metadata and unrelated spoof results do not gate', async () => { + const data = fixture(); + data.workflows.forEach((workflow, index) => { workflow.id = 900 + index; data.runs[index].workflow_id = workflow.id; }); + data.runs.push({ ...data.runs[0], id: 999, workflow_id: 999, name: 'CI', conclusion: 'failure' }); + data.checks.push({ ...data.checks[0], id: 999, conclusion: 'failure', app: { id: 1, slug: 'spoof' } }); + await gates(data); +}); + +test('newer trusted pending run does not reuse older success', async () => { + const data = fixture(); + data.runs.push({ ...data.runs[1], id: 12, status: 'queued', conclusion: null }); + await assert.rejects(gates(data), /Timed out|deadline/); +}); + +test('newer trusted attempt cannot reuse previous attempt jobs', async () => { + const data = fixture(); + data.runs[1].run_attempt = 2; + await assert.rejects(gates(data, url => url.includes('/attempts/2/jobs') ? response({ total_count: 2, jobs: data.jobs.slice(0, 2).map(job => ({ ...job, run_attempt: 2 })) }) : null)); +}); + +test('a new trusted run appearing during collection fails readiness', async () => { + const data = fixture(); let runReads = 0; + await assert.rejects(gates(data, url => { + if (url.includes('/actions/runs?') && ++runReads === 2) { + return response({ total_count: 3, workflow_runs: [...data.runs, { ...data.runs[1], id: 12, status: 'queued', conclusion: null }] }); + } + return null; + }), /Timed out|deadline/); +}); + +test('failure on a later check page cannot be hidden', async () => { + const data = fixture(); data.checks[2].conclusion = 'failure'; + await assert.rejects(gates(data, url => { + if (!url.includes('/check-runs?')) return null; + return url.includes('page=2') + ? response({ total_count: 3, check_runs: data.checks.slice(2) }) + : response({ total_count: 3, check_runs: data.checks.slice(0, 2) }, `<${url}&page=2>; rel="next"`); + }), /concluded failure/); +}); + +test('API requests reject redirects and carry abort signals without dependency loading', async () => { + const api = fakeApi(fixture(), (_url, options) => { + assert.strictEqual(options.redirect, 'error'); + assert.ok(options.signal instanceof AbortSignal); + return null; + }); + await verifySignedAnnotatedTag(inputs, api.fetchImpl); +}); + +test('release input and retry bounds reject unsafe or unbounded values', () => { + const env = { GITHUB_REPOSITORY: repository, RELEASE_SHA: releaseSha, RELEASE_TAG: 'v1.2.3', GITHUB_TOKEN: inputs.token }; + assert.strictEqual(requiredEnvironment(env).releaseSha, releaseSha); + for (const change of [ + { GITHUB_REPOSITORY: '../ECC' }, { RELEASE_SHA: 'short' }, + { RELEASE_TAG: 'v1.2.3\nextra' }, { GITHUB_TOKEN: '' }, { RELEASE_TAG_OBJECT_SHA: 'bad' }, + ]) assert.throws(() => requiredEnvironment({ ...env, ...change })); + for (const options of [{ timeoutMs: 0 }, { timeoutMs: 600001 }, { requestTimeoutMs: 15001 }]) { + assert.throws(() => createGithubClient(inputs, fakeApi().fetchImpl, options), /limits/); + } +}); + +test('an aborted global deadline covers a stalled retry sleep', async () => { + const data = fixture(); data.runs[0].status = 'queued'; data.runs[0].conclusion = null; + let signal; + await assert.rejects(waitForExactShaGates(inputs, fakeApi(data).fetchImpl, (_delay, provided) => { + signal = provided; + assert.ok(signal instanceof AbortSignal, 'abort signal required'); + return new Promise(() => {}); + }, { attempts: 2, timeoutMs: 20, requestTimeoutMs: 10 }), /deadline/); + assert.strictEqual(signal.aborted, true); +}); + +test('signed annotated tag binds full ref, object SHA, name and direct commit', async () => { + assert.strictEqual(await verifySignedAnnotatedTag(inputs, fakeApi().fetchImpl), tagSha); +}); +for (const [name, pathPart, mutate] of [ + ['different ref', '/git/ref/', p => { p.ref = 'refs/tags/v0.0.0'; }], + ['lightweight tag', '/git/ref/', p => { p.object.type = 'commit'; }], + ['malformed object SHA', '/git/ref/', p => { p.object.sha = 'bad'; }], + ['wrong signed name', '/git/tags/', p => { p.tag = 'v0.0.0'; }], + ['wrong returned object SHA', '/git/tags/', p => { p.sha = 'c'.repeat(40); }], + ['unverified signature', '/git/tags/', p => { p.verification.verified = false; }], + ['invalid verification reason', '/git/tags/', p => { p.verification.reason = 'unsigned'; }], + ['nested tag', '/git/tags/', p => { p.object.type = 'tag'; }], + ['wrong target commit', '/git/tags/', p => { p.object.sha = 'c'.repeat(40); }], +]) { + test(`signed tag rejects ${name}`, async () => { + const base = fakeApi(); + await assert.rejects(verifySignedAnnotatedTag(inputs, async (url, options) => { + const result = await base.fetchImpl(url, options); + const payload = await result.json(); + if (url.includes(pathPart)) mutate(payload); + return response(payload); + })); + }); +} + +test('final tag-only recheck requires the original verified object SHA', async () => { + await assert.rejects(verifySignedAnnotatedTag({ ...inputs, tagObjectSha: 'c'.repeat(40) }, fakeApi().fetchImpl), /changed/); + const api = fakeApi(); + assert.strictEqual(await verifySignedAnnotatedTag({ ...inputs, tagObjectSha: tagSha }, api.fetchImpl), tagSha); + assert.strictEqual(api.calls.length, 2); +}); + +for (const [name, link] of [ + ['self loop', url => `<${url}>; rel="next"`], + ['foreign host', () => '; rel="next"'], + ['foreign repository', () => '; rel="next"'], + ['foreign endpoint', () => '; rel="next"'], + ['changed query', url => `<${url.replace('per_page=100', 'per_page=1')}&page=2>; rel="next"`], + ['malformed next', () => 'not-a-link; rel="next"'], + ['duplicate next', url => `<${url}&page=2>; rel="next", <${url}&page=3>; rel="next"`], +]) { + test(`API pagination rejects ${name}`, async () => { + let requests = 0; + await assert.rejects(gates(fixture(), url => { + if (!url.includes('/actions/workflows?')) return null; + requests += 1; + assert.ok(requests <= 2, 'pagination must terminate'); + return response({ total_count: 2, workflows }, link(url)); + })); + assert.ok(requests <= 2); + }); +} + +test('API pagination rejects two-page cycles and incomplete totals', async () => { + const first = `https://api.github.com/repos/${repository}/actions/workflows?per_page=100`; + await assert.rejects(gates(fixture(), url => url.includes('/actions/workflows?') + ? response({ total_count: 4, workflows: url.includes('page=2') ? workflows.map(workflow => ({ ...workflow, id: workflow.id + 2 })) : workflows }, `<${url.includes('page=2') ? first : first + '&page=2'}>; rel="next"`) : null), /cycle/); + await assert.rejects(gates(fixture(), url => url.includes('/actions/workflows?') + ? response({ total_count: 3, workflows }) : null), /complete|total/); +}); + +test('API page and item caps fail closed', async () => { + await assert.rejects(gates(fixture(), url => url.includes('/actions/workflows?') + ? response({ total_count: 1001, workflows }) : null), /cap|limit/); + await assert.rejects(gates(fixture(), url => url.includes('/actions/workflows?') + ? response({ total_count: 101, workflows: Array.from({ length: 101 }, (_, id) => ({ ...workflows[0], id: id + 1 })) }) : null), /cap|limit/); + let page = 0; + await assert.rejects(gates(fixture(), url => url.includes('/actions/workflows?') + ? response({ total_count: 20, workflows: [{ ...workflows[0], id: ++page }] }, `; rel="next"`) : null), /cap|limit/); + assert.ok(page <= 10); +}); + +for (const status of [403, 500]) { + test(`API ${status} fails without leaking the token`, async () => { + await assert.rejects(gates(fixture(), () => ({ ok: false, status })), error => { + assert.match(error.message, new RegExp(String(status))); + assert.ok(!error.message.includes(inputs.token)); return true; + }); + }); +} + +test('invalid JSON and malformed collection shapes fail closed', async () => { + await assert.rejects(gates(fixture(), () => ({ ...response(null), json: async () => { throw new Error('invalid JSON'); } })), /JSON/); + await assert.rejects(gates(fixture(), url => url.includes('/actions/workflows?') ? response({ workflows: 'wrong', total_count: 2 }) : null), /validation|Invalid/); +}); + +test('stalled headers and response bodies are aborted by the request deadline', async () => { + for (const body of [false, true]) { + let signal; + const never = () => new Promise(() => {}); + await assert.rejects(verifySignedAnnotatedTag(inputs, async (_url, options) => { + signal = options.signal; + assert.ok(signal instanceof AbortSignal, 'abort signal required'); + return body ? { ...response(null), json: never } : never(); + }, { timeoutMs: 100, requestTimeoutMs: 5 }), /deadline|timed out/); + assert.strictEqual(signal.aborted, true); + } +}); + +test('global deadline includes retries and prevents further requests', async () => { + const data = fixture(); data.runs[0].status = 'queued'; data.runs[0].conclusion = null; + let clock = 0; let sleeps = 0; + await assert.rejects(waitForExactShaGates(inputs, fakeApi(data).fetchImpl, async delay => { clock += delay; sleeps += 1; }, { + attempts: 5, delayMs: 10, timeoutMs: 15, requestTimeoutMs: 10, now: () => clock, + }), /deadline/); + assert.strictEqual(sleeps, 2); +}); + +for (const workflowPath of workflowPaths) { + test(`${workflowPath} rechecks captured tag identity immediately before publication`, () => { + const workflow = yaml.load(load(workflowPath)); + const verify = workflow.jobs.verify; + assert.strictEqual(verify.outputs.release_sha, '${{ steps.release_gate.outputs.release_sha }}'); + assert.strictEqual(verify.outputs.tag_object_sha, '${{ steps.release_gate.outputs.tag_object_sha }}'); + assert.strictEqual(verify.steps.find(step => step.name === 'Verify signed tag and exact-SHA CI gates').id, 'release_gate'); + 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['persist-credentials'], false); + assert.strictEqual(checkout.with.path, 'release-gate-source'); + const index = publish.steps.findIndex(step => step.name === 'Recheck verified tag before publish'); + assert.ok(index > 0); + assert.strictEqual(publish.steps[index + 1].name, 'Publish npm package'); + const gate = publish.steps[index]; + assert.strictEqual(gate.env.RELEASE_SHA, '${{ needs.verify.outputs.release_sha }}'); + assert.strictEqual(gate.env.RELEASE_TAG_OBJECT_SHA, '${{ needs.verify.outputs.tag_object_sha }}'); + assert.match(gate.run, /^node release-gate-source\/scripts\/ci\/verify-release-gates\.js --tag-only$/); + assert.doesNotMatch(JSON.stringify(publish), /npm ci|npm install|actions:read|checks:read/); + }); +} + 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'); @@ -278,6 +647,8 @@ test('packed lifecycle installs and verifies the opt-in Ito distribution surface assert.match(lifecycleRunnerSource, /packed Itô bridge executed a PATH collision/); }); -console.log(`\nPassed: ${passed}`); -console.log(`Failed: ${failed}`); -process.exit(failed > 0 ? 1 : 0); +pendingTests.then(() => { + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +}); diff --git a/tests/scripts/release.test.js b/tests/scripts/release.test.js index 30567ffaf..7bc39d1b0 100644 --- a/tests/scripts/release.test.js +++ b/tests/scripts/release.test.js @@ -134,12 +134,22 @@ function runTests() { 'release.sh should detect metadata that already declares the requested version' ); assert.ok( - source.includes('echo " git tag \\"v$VERSION\\""') && + source.includes('echo " git tag -s \\"v$VERSION\\" -m \\"Release v$VERSION\\""') && source.includes('echo " git push origin \\"v$VERSION\\""'), 'same-version guidance should point maintainers to the tag-driven publish path' ); })) passed++; else failed++; + if (test('release signs an annotated tag and stops before push if signing fails', () => { + assert.match(source, /^set -euo pipefail$/m); + const tagCommand = 'git tag -s "v$VERSION" -m "Release v$VERSION"'; + const tagIndex = source.indexOf('\n' + tagCommand + '\n'); + const pushIndex = source.indexOf('\ngit push origin main "v$VERSION"'); + assert.ok(tagIndex > source.indexOf('git commit -m'), 'sign after the release commit'); + assert.ok(pushIndex > tagIndex, 'push only after successful signing'); + assert.doesNotMatch(source.slice(tagIndex, pushIndex), /\|\||set \+e/); + })) passed++; else failed++; + if (test('release workflows mark prerelease tags as GitHub prereleases', () => { assert.ok( releaseWorkflowSource.includes('prerelease: ${{ contains(github.ref_name, \'-\') }}'),