Files
ECC/scripts/ci/verify-release-gates.js
T

352 lines
16 KiB
JavaScript

'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 };