From 1e4576e685f4dada1e43356748f63258a27a01a0 Mon Sep 17 00:00:00 2001 From: Viggo Phillips <326137805+PhillipsT-Ai2@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:46:22 +0200 Subject: [PATCH 1/3] fix: enforce signed release provenance gates --- .github/workflows/release.yml | 8 + .github/workflows/reusable-release.yml | 8 + scripts/ci/verify-release-gates.js | 151 ++++++++++++++++++ .../release-packed-artifact-workflow.test.js | 49 ++++++ 4 files changed, 216 insertions(+) create mode 100644 scripts/ci/verify-release-gates.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdad0d483..80fe58991 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: @@ -43,6 +45,12 @@ jobs: node-version: '20.x' registry-url: 'https://registry.npmjs.org' + - name: Verify signed tag and exact-SHA CI gates + 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 diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index b038b1b8c..0ee5001a8 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: @@ -57,6 +59,12 @@ jobs: node-version: '20.x' registry-url: 'https://registry.npmjs.org' + - name: Verify signed tag and exact-SHA CI gates + 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 diff --git a/scripts/ci/verify-release-gates.js b/scripts/ci/verify-release-gates.js new file mode 100644 index 000000000..698c302c5 --- /dev/null +++ b/scripts/ci/verify-release-gates.js @@ -0,0 +1,151 @@ +'use strict'; + +const API_VERSION = '2022-11-28'; +const DEFAULT_ATTEMPTS = 20; +const DEFAULT_DELAY_MS = 30_000; + +function requiredEnvironment(env = process.env) { + const values = { + repository: env.GITHUB_REPOSITORY, + releaseSha: env.RELEASE_SHA, + releaseTag: env.RELEASE_TAG, + token: env.GITHUB_TOKEN, + }; + for (const [name, value] of Object.entries(values)) { + if (!value) throw new Error(`Missing required release gate input: ${name}`); + } + if (!/^[0-9a-f]{40}$/.test(values.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(values.releaseTag)) { + throw new Error('RELEASE_TAG is not a supported version tag'); + } + return values; +} + +async function githubApi(path, { repository, token }, fetchImpl = fetch) { + const response = await fetchImpl(`https://api.github.com/repos/${repository}${path}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': API_VERSION, + }, + }); + if (!response.ok) { + throw new Error(`GitHub API ${path} failed with status ${response.status}`); + } + return response.json(); +} + +async function verifySignedAnnotatedTag(inputs, fetchImpl = fetch) { + const reference = await githubApi( + `/git/ref/tags/${encodeURIComponent(inputs.releaseTag)}`, + inputs, + fetchImpl + ); + if (reference.object.type !== 'tag') { + throw new Error('Release tag must be annotated; lightweight tags are rejected'); + } + const tagObject = await githubApi(`/git/tags/${reference.object.sha}`, inputs, fetchImpl); + if (tagObject.verification.verified !== true) { + const reason = tagObject.verification.reason || 'unknown'; + throw new Error(`Release tag signature is not verified: ${reason}`); + } + if (tagObject.object.type !== 'commit' || tagObject.object.sha !== inputs.releaseSha) { + throw new Error('Verified release tag does not point at the checked-out commit'); + } +} + +function assessExactShaGates(runs, checks, releaseSha) { + const latestCi = runs + .filter(run => run.head_sha === releaseSha && run.name === 'CI') + .sort((left, right) => Number(right.id || 0) - Number(left.id || 0))[0]; + const latestCodeql = latestByName( + checks.filter(check => check.head_sha === releaseSha && /codeql/i.test(check.name || '')) + ); + if (latestCi?.status === 'completed' && latestCi.conclusion !== 'success') { + return { state: 'failed', reason: `CI concluded ${latestCi.conclusion}` }; + } + const failedCodeql = latestCodeql.find( + check => check.status === 'completed' && check.conclusion !== 'success' + ); + if (failedCodeql) { + return { state: 'failed', reason: `${failedCodeql.name} concluded ${failedCodeql.conclusion}` }; + } + const ciPassed = latestCi?.status === 'completed' && latestCi.conclusion === 'success'; + const codeqlPassed = + latestCodeql.length > 0 && + latestCodeql.every( + check => check.status === 'completed' && check.conclusion === 'success' + ); + return ciPassed && codeqlPassed + ? { state: 'passed' } + : { state: 'pending', reason: 'waiting for successful CI and CodeQL on the release SHA' }; +} + +function latestByName(checks) { + const latest = new Map(); + for (const check of checks) { + const prior = latest.get(check.name); + if (!prior || Number(check.id || 0) > Number(prior.id || 0)) latest.set(check.name, check); + } + return [...latest.values()]; +} + +async function waitForExactShaGates(inputs, fetchImpl = fetch, sleep = defaultSleep) { + const attempts = positiveInteger(process.env.RELEASE_GATE_ATTEMPTS, DEFAULT_ATTEMPTS); + const delayMs = positiveInteger(process.env.RELEASE_GATE_DELAY_MS, DEFAULT_DELAY_MS); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const [workflowPayload, checkPayload] = await Promise.all([ + githubApi( + `/actions/runs?head_sha=${inputs.releaseSha}&event=push&per_page=100`, + inputs, + fetchImpl + ), + githubApi(`/commits/${inputs.releaseSha}/check-runs?per_page=100`, inputs, fetchImpl), + ]); + const assessment = assessExactShaGates( + workflowPayload.workflow_runs || [], + checkPayload.check_runs || [], + inputs.releaseSha + ); + if (assessment.state === 'passed') return; + if (assessment.state === 'failed') throw new Error(assessment.reason); + if (attempt < attempts) await sleep(delayMs); + } + throw new Error('Timed out waiting for successful exact-SHA CI and CodeQL checks'); +} + +function positiveInteger(value, fallback) { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error('Release gate retry settings must be positive integers'); + } + return parsed; +} + +function defaultSleep(delayMs) { + return new Promise(resolve => setTimeout(resolve, delayMs)); +} + +async function main() { + const inputs = requiredEnvironment(); + await verifySignedAnnotatedTag(inputs); + await waitForExactShaGates(inputs); + console.log('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, + requiredEnvironment, + verifySignedAnnotatedTag, + waitForExactShaGates, +}; diff --git a/tests/ci/release-packed-artifact-workflow.test.js b/tests/ci/release-packed-artifact-workflow.test.js index 4ec23ffc4..3659ff51a 100644 --- a/tests/ci/release-packed-artifact-workflow.test.js +++ b/tests/ci/release-packed-artifact-workflow.test.js @@ -9,6 +9,7 @@ const workflowPaths = [ '.github/workflows/release.yml', '.github/workflows/reusable-release.yml', ]; +const { assessExactShaGates } = require('../../scripts/ci/verify-release-gates.js'); const lifecycleRunnerSource = load('tests/ci/packed-artifact-lifecycle.js'); let passed = 0; @@ -49,6 +50,20 @@ 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 gateIndex = verify.indexOf('name: Verify signed tag and exact-SHA CI gates'); + const installIndex = verify.indexOf('name: Install dependencies'); + + assert.ok(gateIndex >= 0, 'missing release provenance gate'); + assert.ok(installIndex > gateIndex, 'release provenance must be verified before dependencies run'); + assert.match(verify, /node scripts\/ci\/verify-release-gates\.js/); + assert.match(verify, /RELEASE_SHA(?:=|:)/); + assert.match(verify, /RELEASE_TAG:/); + assert.match(source, /actions:\s*read/); + assert.match(source, /checks:\s*read/); + }); + test(`${workflowPath} packs once and exports the package name and SHA-256`, () => { assert.strictEqual( (source.match(/npm pack --json/g) || []).length, @@ -151,6 +166,40 @@ for (const workflowPath of workflowPaths) { }); } +test('release gate verifier requires a signed annotated tag, CI, and CodeQL', () => { + const verifierPath = path.join(repoRoot, 'scripts/ci/verify-release-gates.js'); + assert.ok(fs.existsSync(verifierPath), 'missing release gate verifier'); + const source = load('scripts/ci/verify-release-gates.js'); + assert.match(source, /verification\.verified/); + assert.match(source, /object\.type[^\n]+tag/); + assert.match(source, /run\.name === 'CI'/); + assert.match(source, /codeql/i); + assert.match(source, /head_sha === releaseSha/); +}); + +test('release gate verifier accepts only successful checks for the exact SHA', () => { + const releaseSha = 'a'.repeat(40); + const passed = assessExactShaGates( + [{ name: 'CI', head_sha: releaseSha, status: 'completed', conclusion: 'success' }], + [{ name: 'CodeQL', head_sha: releaseSha, status: 'completed', conclusion: 'success' }], + releaseSha + ); + const wrongSha = assessExactShaGates( + [{ name: 'CI', head_sha: 'b'.repeat(40), status: 'completed', conclusion: 'success' }], + [{ name: 'CodeQL', head_sha: releaseSha, status: 'completed', conclusion: 'success' }], + releaseSha + ); + const failedCodeql = assessExactShaGates( + [{ name: 'CI', head_sha: releaseSha, status: 'completed', conclusion: 'success' }], + [{ name: 'CodeQL', head_sha: releaseSha, status: 'completed', conclusion: 'failure' }], + releaseSha + ); + + assert.strictEqual(passed.state, 'passed'); + assert.strictEqual(wrongSha.state, 'pending'); + assert.strictEqual(failedCodeql.state, 'failed'); +}); + 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'); From 5b05a3f0063d2b9bb3d9290f82e399e096ab1d66 Mon Sep 17 00:00:00 2001 From: Viggo Phillips <326137805+PhillipsT-Ai2@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:05:55 +0200 Subject: [PATCH 2/3] fix: address release gate review findings --- scripts/ci/verify-release-gates.js | 144 ++++++++++++++++-- .../release-packed-artifact-workflow.test.js | 137 +++++++++++++++-- 2 files changed, 251 insertions(+), 30 deletions(-) diff --git a/scripts/ci/verify-release-gates.js b/scripts/ci/verify-release-gates.js index 698c302c5..d3c28c5f6 100644 --- a/scripts/ci/verify-release-gates.js +++ b/scripts/ci/verify-release-gates.js @@ -1,8 +1,67 @@ 'use strict'; +const Ajv = require('ajv'); + const API_VERSION = '2022-11-28'; const DEFAULT_ATTEMPTS = 20; const DEFAULT_DELAY_MS = 30_000; +const ajv = new Ajv({ allErrors: true }); + +const referenceSchema = { + type: 'object', + required: ['object'], + properties: { + object: { + type: 'object', + required: ['type', 'sha'], + properties: { type: { type: 'string' }, sha: { type: 'string' } }, + }, + }, +}; +const tagSchema = { + type: 'object', + required: ['verification', 'object'], + properties: { + verification: { + type: 'object', + required: ['verified'], + properties: { + verified: { type: 'boolean' }, + reason: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + object: { + type: 'object', + required: ['type', 'sha'], + properties: { type: { type: 'string' }, sha: { type: 'string' } }, + }, + }, +}; +const workflowRunsSchema = collectionSchema('workflow_runs'); +const checkRunsSchema = collectionSchema('check_runs'); + +function collectionSchema(property) { + return { + type: 'object', + required: [property], + properties: { + [property]: { + type: 'array', + items: { + type: 'object', + required: ['id', 'name', 'head_sha', 'status', 'conclusion'], + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + head_sha: { type: 'string' }, + status: { type: 'string' }, + conclusion: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + }, + }, + }; +} function requiredEnvironment(env = process.env) { const values = { @@ -23,8 +82,14 @@ function requiredEnvironment(env = process.env) { return values; } -async function githubApi(path, { repository, token }, fetchImpl = fetch) { - const response = await fetchImpl(`https://api.github.com/repos/${repository}${path}`, { +async function githubApi(path, inputs, fetchImpl = fetch, schema) { + const { payload } = await githubApiPage(path, inputs, fetchImpl, schema); + return payload; +} + +async function githubApiPage(pathOrUrl, { repository, token }, fetchImpl, schema) { + const url = githubApiUrl(pathOrUrl, repository); + const response = await fetchImpl(url, { headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, @@ -32,21 +97,64 @@ async function githubApi(path, { repository, token }, fetchImpl = fetch) { }, }); if (!response.ok) { - throw new Error(`GitHub API ${path} failed with status ${response.status}`); + throw new Error(`GitHub API ${url} failed with status ${response.status}`); } - return response.json(); + const payload = await response.json(); + const validate = ajv.compile(schema); + if (!validate(payload)) { + throw new Error(`GitHub API response validation failed: ${ajv.errorsText(validate.errors)}`); + } + return { payload, next: nextPageUrl(response.headers?.get?.('link'), repository) }; +} + +function githubApiUrl(pathOrUrl, repository) { + if (!pathOrUrl.startsWith('https://')) { + return `https://api.github.com/repos/${repository}${pathOrUrl}`; + } + const url = new URL(pathOrUrl); + if (url.origin !== 'https://api.github.com' || !url.pathname.startsWith(`/repos/${repository}/`)) { + throw new Error('GitHub API pagination link escaped the release repository'); + } + return url.toString(); +} + +function nextPageUrl(linkHeader, repository) { + if (!linkHeader) return null; + const next = linkHeader + .split(',') + .map(value => value.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"$/)) + .find(match => match?.[2] === 'next'); + if (!next) return null; + return githubApiUrl(next[1], repository); +} + +async function githubApiPages(path, itemsKey, inputs, fetchImpl, schema) { + const items = []; + let next = path; + while (next) { + const page = await githubApiPage(next, inputs, fetchImpl, schema); + items.push(...page.payload[itemsKey]); + next = page.next; + } + return items; } async function verifySignedAnnotatedTag(inputs, fetchImpl = fetch) { const reference = await githubApi( `/git/ref/tags/${encodeURIComponent(inputs.releaseTag)}`, inputs, - fetchImpl + fetchImpl, + referenceSchema ); if (reference.object.type !== 'tag') { throw new Error('Release tag must be annotated; lightweight tags are rejected'); } - const tagObject = await githubApi(`/git/tags/${reference.object.sha}`, inputs, fetchImpl); + const tagObject = await githubApi( + `/git/tags/${reference.object.sha}`, + inputs, + fetchImpl, + tagSchema + ); if (tagObject.verification.verified !== true) { const reason = tagObject.verification.reason || 'unknown'; throw new Error(`Release tag signature is not verified: ${reason}`); @@ -96,19 +204,23 @@ async function waitForExactShaGates(inputs, fetchImpl = fetch, sleep = defaultSl const attempts = positiveInteger(process.env.RELEASE_GATE_ATTEMPTS, DEFAULT_ATTEMPTS); const delayMs = positiveInteger(process.env.RELEASE_GATE_DELAY_MS, DEFAULT_DELAY_MS); for (let attempt = 1; attempt <= attempts; attempt += 1) { - const [workflowPayload, checkPayload] = await Promise.all([ - githubApi( + const [runs, checks] = await Promise.all([ + githubApiPages( `/actions/runs?head_sha=${inputs.releaseSha}&event=push&per_page=100`, + 'workflow_runs', inputs, - fetchImpl + fetchImpl, + workflowRunsSchema + ), + githubApiPages( + `/commits/${inputs.releaseSha}/check-runs?per_page=100`, + 'check_runs', + inputs, + fetchImpl, + checkRunsSchema ), - githubApi(`/commits/${inputs.releaseSha}/check-runs?per_page=100`, inputs, fetchImpl), ]); - const assessment = assessExactShaGates( - workflowPayload.workflow_runs || [], - checkPayload.check_runs || [], - inputs.releaseSha - ); + const assessment = assessExactShaGates(runs, checks, inputs.releaseSha); if (assessment.state === 'passed') return; if (assessment.state === 'failed') throw new Error(assessment.reason); if (attempt < attempts) await sleep(delayMs); @@ -145,6 +257,8 @@ if (require.main === module) { module.exports = { assessExactShaGates, + githubApi, + githubApiPages, requiredEnvironment, verifySignedAnnotatedTag, waitForExactShaGates, diff --git a/tests/ci/release-packed-artifact-workflow.test.js b/tests/ci/release-packed-artifact-workflow.test.js index 3659ff51a..1a6533832 100644 --- a/tests/ci/release-packed-artifact-workflow.test.js +++ b/tests/ci/release-packed-artifact-workflow.test.js @@ -3,30 +3,48 @@ 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 { assessExactShaGates } = require('../../scripts/ci/verify-release-gates.js'); +const { + assessExactShaGates, + verifySignedAnnotatedTag, + waitForExactShaGates, +} = require('../../scripts/ci/verify-release-gates.js'); const lifecycleRunnerSource = load('tests/ci/packed-artifact-lifecycle.js'); let passed = 0; let failed = 0; +const pendingTests = []; function test(name, fn) { try { - fn(); - console.log(` ✓ ${name}`); - passed += 1; + const result = fn(); + if (result && typeof result.then === 'function') { + pendingTests.push(result.then(() => pass(name), error => fail(name, error))); + } else { + pass(name); + } } catch (error) { - console.log(` ✗ ${name}`); - console.log(` Error: ${error.message}`); - failed += 1; + 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) { return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/g, '\n'); } @@ -52,16 +70,23 @@ for (const workflowPath of workflowPaths) { 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.match(verify, /node scripts\/ci\/verify-release-gates\.js/); - assert.match(verify, /RELEASE_SHA(?:=|:)/); - assert.match(verify, /RELEASE_TAG:/); - assert.match(source, /actions:\s*read/); - assert.match(source, /checks:\s*read/); + 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`, () => { @@ -200,6 +225,86 @@ test('release gate verifier accepts only successful checks for the exact SHA', ( assert.strictEqual(failedCodeql.state, 'failed'); }); +test('release gate verifier validates GitHub response shapes before use', async () => { + const inputs = { + repository: 'affaan-m/ECC', + releaseSha: 'a'.repeat(40), + releaseTag: 'v1.2.3', + token: 'test-token', + }; + const malformedResponse = async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ object: { type: 'tag' } }), + }); + + await assert.rejects( + verifySignedAnnotatedTag(inputs, malformedResponse), + /GitHub API response validation failed/ + ); +}); + +test('release gate verifier evaluates checks from every GitHub result page', async () => { + const releaseSha = 'a'.repeat(40); + const inputs = { + repository: 'affaan-m/ECC', + releaseSha, + releaseTag: 'v1.2.3', + token: 'test-token', + }; + const pageTwo = + `https://api.github.com/repos/${inputs.repository}/commits/${releaseSha}/check-runs` + + '?per_page=100&page=2'; + const response = (payload, link = null) => ({ + ok: true, + status: 200, + headers: { get: name => (name.toLowerCase() === 'link' ? link : null) }, + json: async () => payload, + }); + const fetchImpl = async url => { + if (url.includes('/actions/runs?')) { + return response({ + workflow_runs: [ + { id: 1, name: 'CI', head_sha: releaseSha, status: 'completed', conclusion: 'success' }, + ], + }); + } + if (url === pageTwo) { + return response({ + check_runs: [ + { + id: 2, + name: 'CodeQL JavaScript', + head_sha: releaseSha, + status: 'completed', + conclusion: 'failure', + }, + ], + }); + } + return response( + { + check_runs: [ + { + id: 1, + name: 'CodeQL Actions', + head_sha: releaseSha, + status: 'completed', + conclusion: 'success', + }, + ], + }, + `<${pageTwo}>; rel="next"` + ); + }; + + await assert.rejects( + waitForExactShaGates(inputs, fetchImpl), + /CodeQL JavaScript concluded failure/ + ); +}); + 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'); @@ -327,6 +432,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); +Promise.all(pendingTests).then(() => { + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +}); From 32c10932fc08f0598af20676957ed4e8f4349bea Mon Sep 17 00:00:00 2001 From: Viggo Phillips <326137805+PhillipsT-Ai2@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:54:49 +0200 Subject: [PATCH 3/3] fix: accept nullable workflow metadata --- scripts/ci/verify-release-gates.js | 15 +++++++++------ tests/ci/release-packed-artifact-workflow.test.js | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/scripts/ci/verify-release-gates.js b/scripts/ci/verify-release-gates.js index d3c28c5f6..b4d7f854a 100644 --- a/scripts/ci/verify-release-gates.js +++ b/scripts/ci/verify-release-gates.js @@ -37,10 +37,13 @@ const tagSchema = { }, }, }; -const workflowRunsSchema = collectionSchema('workflow_runs'); +const workflowRunsSchema = collectionSchema('workflow_runs', true); const checkRunsSchema = collectionSchema('check_runs'); -function collectionSchema(property) { +function collectionSchema(property, nullableWorkflowFields = false) { + const nameAndStatusSchema = nullableWorkflowFields + ? { anyOf: [{ type: 'string' }, { type: 'null' }] } + : { type: 'string' }; return { type: 'object', required: [property], @@ -52,9 +55,9 @@ function collectionSchema(property) { required: ['id', 'name', 'head_sha', 'status', 'conclusion'], properties: { id: { type: 'integer' }, - name: { type: 'string' }, + name: nameAndStatusSchema, head_sha: { type: 'string' }, - status: { type: 'string' }, + status: nameAndStatusSchema, conclusion: { anyOf: [{ type: 'string' }, { type: 'null' }] }, }, }, @@ -129,11 +132,11 @@ function nextPageUrl(linkHeader, repository) { } async function githubApiPages(path, itemsKey, inputs, fetchImpl, schema) { - const items = []; + let items = []; let next = path; while (next) { const page = await githubApiPage(next, inputs, fetchImpl, schema); - items.push(...page.payload[itemsKey]); + items = [...items, ...page.payload[itemsKey]]; next = page.next; } return items; diff --git a/tests/ci/release-packed-artifact-workflow.test.js b/tests/ci/release-packed-artifact-workflow.test.js index 1a6533832..e8964b547 100644 --- a/tests/ci/release-packed-artifact-workflow.test.js +++ b/tests/ci/release-packed-artifact-workflow.test.js @@ -19,13 +19,16 @@ const lifecycleRunnerSource = load('tests/ci/packed-artifact-lifecycle.js'); let passed = 0; let failed = 0; -const pendingTests = []; +let pendingTests = []; function test(name, fn) { try { const result = fn(); if (result && typeof result.then === 'function') { - pendingTests.push(result.then(() => pass(name), error => fail(name, error))); + pendingTests = [ + ...pendingTests, + result.then(() => pass(name), error => fail(name, error)), + ]; } else { pass(name); } @@ -267,6 +270,13 @@ test('release gate verifier evaluates checks from every GitHub result page', asy return response({ workflow_runs: [ { id: 1, name: 'CI', head_sha: releaseSha, status: 'completed', conclusion: 'success' }, + { + id: 3, + name: null, + head_sha: 'b'.repeat(40), + status: null, + conclusion: null, + }, ], }); }