mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
352 lines
16 KiB
JavaScript
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 };
|