mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
165 lines
7.9 KiB
JavaScript
165 lines
7.9 KiB
JavaScript
// Exact-SHA GitHub Actions gate used by release workflows. Prereleases require
|
|
// Validate; stable releases additionally require the dedicated full
|
|
// performance workflow.
|
|
import { execFileSync } from 'node:child_process';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, localEvidence,
|
|
loadGithubProofContext, selectCiProofVerdict,
|
|
} from './ci-proof.mjs';
|
|
|
|
/**
|
|
* #573: ожидания потребителя. Гейты релиза стоят на checkout кандидата, и
|
|
* тогда evidence proof (product tree, overlay эталонов, content-ключи,
|
|
* reviewed run) не принимается на веру, а сверяется с тем, что посчитано
|
|
* здесь. Checkout не на кандидате — считать нечего, и об этом говорится вслух.
|
|
*/
|
|
export function candidateExpectations({ sha, root = process.cwd(), log = console.log } = {}) {
|
|
let head = null;
|
|
try { head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { head = null; }
|
|
if (!sha || head !== sha) {
|
|
log(`::notice::checkout ${head ? head.slice(0, 8) : 'absent'} is not the candidate ${String(sha).slice(0, 8)} — proof evidence is verified against GitHub only (#573)`);
|
|
return null;
|
|
}
|
|
return localEvidence(root);
|
|
}
|
|
|
|
/**
|
|
* The verdict is the LATEST run that was not cancelled (#511). A cancelled run
|
|
* proves nothing either way — concurrency or a hand superseded it — and the
|
|
* old "every run must be green" rule turned one red or cancelled duplicate on
|
|
* a SHA into a permanent block (v1.73.0: a cancelled dispatch twin plus a red
|
|
* comparison next to a green push run left the tag without assets). A later
|
|
* re-run or a dispatch with another baseline may therefore refresh the verdict
|
|
* on the same SHA; the owner accepted that trade-off deliberately.
|
|
*/
|
|
export function latestRelevantRun(runs) {
|
|
const relevant = (Array.isArray(runs) ? runs : []).filter((run) => run && run.conclusion !== 'cancelled');
|
|
const stamp = (run) => Date.parse(run.run_started_at || run.created_at || 0) || 0;
|
|
return relevant.sort((a, b) => stamp(b) - stamp(a) || Number(b.id || 0) - Number(a.id || 0))[0] || null;
|
|
}
|
|
|
|
export function classifyValidateRuns(runs) {
|
|
const latest = latestRelevantRun(runs);
|
|
if (!latest) return 'wait';
|
|
if (latest.status !== 'completed') return 'wait';
|
|
return latest.conclusion === 'success' ? 'success' : 'fail';
|
|
}
|
|
|
|
const newestFirst = (runs) => [...(Array.isArray(runs) ? runs : [])].sort((a, b) => {
|
|
const stamp = (run) => Date.parse(run?.run_started_at || run?.startedAt || run?.created_at || run?.createdAt || 0) || 0;
|
|
return stamp(b) - stamp(a) || Number(b?.id || b?.databaseId || 0) - Number(a?.id || a?.databaseId || 0);
|
|
});
|
|
|
|
/** #541: proof-aware verdict shared with review and merge. */
|
|
export async function classifyValidateProofs({
|
|
runs, repo, sha, tree, token, fetchImpl = fetch, expected = null,
|
|
// #573: reviewed run спрашивается у GitHub только вместе с ожиданиями (release)
|
|
loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl, withReviewedRun: Boolean(expected) }),
|
|
}) {
|
|
const evaluations = [];
|
|
for (const run of newestFirst(runs)) {
|
|
if (run?.status !== 'completed' || run?.conclusion === 'cancelled') {
|
|
evaluations.push(evaluateCiProof({ run, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release }));
|
|
} else {
|
|
try {
|
|
const context = await loadContext(run);
|
|
evaluations.push(evaluateCiProof({
|
|
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release, expected,
|
|
}));
|
|
} catch (error) {
|
|
evaluations.push({
|
|
status: 'missing', url: run.html_url || run.url || null,
|
|
note: `proof could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
|
|
});
|
|
}
|
|
}
|
|
const current = evaluations.at(-1);
|
|
// #619: a complete proof is immutable evidence for this exact SHA/tree.
|
|
// A later duplicate may fail for workflow topology rather than product
|
|
// content, so only a green proof ends the search; failures remain the
|
|
// fallback verdict when no run proves the candidate green.
|
|
if (current.status === 'green') break;
|
|
}
|
|
return selectCiProofVerdict(evaluations);
|
|
}
|
|
|
|
export const workflowRunsUrl = ({ repo, workflow, sha }) => (
|
|
`https://api.github.com/repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}`
|
|
+ `/runs?head_sha=${encodeURIComponent(sha)}&per_page=100`
|
|
);
|
|
|
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
|
|
export async function waitForGreenWorkflow({
|
|
repo, sha, token, workflow = 'validate.yml', label = 'Validate', timeoutMs = 60 * 60 * 1000,
|
|
root = process.cwd(),
|
|
}) {
|
|
if (!repo || !sha || !token || !workflow) throw new Error('repo, sha, token and workflow are required');
|
|
const deadline = Date.now() + timeoutMs;
|
|
const url = workflowRunsUrl({ repo, workflow, sha });
|
|
const proofRequired = workflow === 'validate.yml';
|
|
const tree = proofRequired ? await githubCandidateTree({ repo, sha, token }) : null;
|
|
const expected = proofRequired ? candidateExpectations({ sha, root }) : null;
|
|
if (expected) {
|
|
console.log(`candidate evidence: product tree ${expected.product.tree.slice(0, 12)}, baselines `
|
|
+ `${expected.baselines.tree?.slice(0, 12) || 'none'}, reviewed run ${expected.baselines.reviewedRun || 'none'} (#573)`);
|
|
}
|
|
while (true) {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${token}`,
|
|
'User-Agent': 'houseplan-release-gate',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
},
|
|
});
|
|
if (!response.ok) throw new Error(`GitHub Actions API ${response.status}: ${await response.text()}`);
|
|
const body = await response.json();
|
|
const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
|
|
const latest = latestRelevantRun(runs);
|
|
const verdict = proofRequired
|
|
? await classifyValidateProofs({ runs, repo, sha, tree, token, expected })
|
|
: { status: classifyValidateRuns(runs) === 'success' ? 'green'
|
|
: classifyValidateRuns(runs) === 'fail' ? 'failed' : 'pending', url: latest?.html_url, note: '' };
|
|
if (verdict.status === 'failed') {
|
|
throw new Error(`${label} is not green for ${sha}: ${verdict.note}${verdict.url ? ` (${verdict.url})` : ''}`);
|
|
}
|
|
if (verdict.status === 'green') {
|
|
console.log(`${label} proof is green for ${sha}: ${verdict.url || latest?.html_url || latest?.id} (${runs.length} run(s) on the SHA)`);
|
|
return;
|
|
}
|
|
if (Date.now() >= deadline) throw new Error(`No complete ${label} proof for ${sha} within the deadline: ${verdict.status} (${verdict.note})`);
|
|
const running = runs.filter((run) => run?.status !== 'completed').length;
|
|
console.log(runs.length
|
|
? `waiting: ${label} proof is ${verdict.status}; ${running} run(s) still going (${verdict.note})`
|
|
: `waiting: no ${label} run for ${sha} yet`);
|
|
await sleep(30_000);
|
|
}
|
|
}
|
|
|
|
export const waitForGreenValidate = (options) => waitForGreenWorkflow({
|
|
...options, workflow: 'validate.yml', label: 'Validate',
|
|
});
|
|
|
|
const invokedDirectly = process.argv[1]
|
|
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
if (invokedDirectly) {
|
|
const sha = process.argv[2];
|
|
const valueArg = (name) => process.argv
|
|
.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
const workflow = valueArg('workflow') || 'validate.yml';
|
|
const label = valueArg('label') || (workflow === 'validate.yml' ? 'Validate' : workflow);
|
|
waitForGreenWorkflow({
|
|
repo: process.env.REPO || process.env.GITHUB_REPOSITORY,
|
|
sha,
|
|
token: process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
|
|
workflow,
|
|
label,
|
|
}).catch((err) => {
|
|
console.error(`::error::${err instanceof Error ? err.message : String(err)}`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|