mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed contributor PR #3195 into backlog batch
Source-PR: https://github.com/affaan-m/ECC/pull/3195
Source-Head: 7c9c44736b
Local integration checkpoint; aggregate review and hosted acceptance pending.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 };
|
||||
+4
-3
@@ -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"
|
||||
|
||||
@@ -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', () => '<https://evil.invalid/repos/affaan-m/ECC/actions/workflows?page=2>; rel="next"'],
|
||||
['foreign repository', () => '<https://api.github.com/repos/other/ECC/actions/workflows?page=2>; rel="next"'],
|
||||
['foreign endpoint', () => '<https://api.github.com/repos/affaan-m/ECC/actions/runs?per_page=100&page=2>; 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 }] }, `<https://api.github.com/repos/${repository}/actions/workflows?per_page=100&page=${page + 1}>; 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;
|
||||
});
|
||||
|
||||
@@ -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, \'-\') }}'),
|
||||
|
||||
Reference in New Issue
Block a user