Files
TREK/scripts/ci/ci-ok-verdict.mjs
Maurice c5ba70f67e ci(release): let a later green ci-ok supersede a red run on the same commit
Both release workflows refused a commit if any ci-ok check run on it was
not green. Every workflow run is its own check suite, so a red run stays
on the commit after a later one passed: a flaky job on the dev push, or a
push run cancelled by a manual run on the same ref (ci-ok runs with
always() and fails on the cancelled jobs). Running Tests & SonarQube
again, as the error told the maintainer to do, added a green suite but
never cleared the red one, and the message did not say which run was
red.

The gate now lives in scripts/ci/ci-ok-verdict.mjs. It fetches every
attempt (filter=all), keeps the newest attempt per check suite, refuses
while one is still going and requires the run that finished last to be
green. It prints the link of every run it counted, and an older red run
it overrode shows up as a notice. A response it cannot read, one cut off
by pagination or a run without a suite, conclusion or completion time
fails the release instead of passing it. Tests run with test:ci-scripts.
2026-10-08 13:04:43 +02:00

114 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// Decides whether a commit has a green ci-ok, for the release workflows.
//
// docker.yml and docker-dev.yml refuse to build a commit unless ci-ok passed
// on it. They fetch every ci-ok check run on the commit, earlier attempts
// included (GET /repos/{repo}/commits/{sha}/check-runs?check_name=ci-ok&filter=all),
// and hand the response to this script.
//
// A commit can carry several ci-ok runs: the dev push, the main push and any
// manual dispatch each start their own workflow run, and each workflow run is
// its own check suite. A red suite stays on the commit after a later one passed
// (a flaky job, a push run cancelled by a dispatch on the same ref), so
// requiring every run to be green would block the release until someone found
// and re-ran that exact old run. Instead:
//
// 1. Per check suite only the newest check run counts. Re-running a workflow
// run adds a check run to the same suite, and that one replaces the old.
// 2. A run still going fails the gate: wait for it and dispatch again.
// 3. Of the rest, the one that completed last decides. It has to be green.
//
// Usage:
// node scripts/ci/ci-ok-verdict.mjs <check-runs.json> <sha>
//
// Prints one line per counted run and exits 0 only when the gate passes. It
// fails closed: a file it cannot read or parse, a response cut off by
// pagination, or a run without the fields it needs is an error, never a pass.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
function requireField(run, value, name) {
if (value === undefined || value === null || value === '') {
throw new Error(`check run ${run?.id ?? '?'} has no ${name}`);
}
return value;
}
// Parses the API response into the runs that count, newest per check suite.
export function latestPerSuite(response) {
if (!response || !Array.isArray(response.check_runs)) {
throw new Error('response has no check_runs array');
}
if (typeof response.total_count === 'number' && response.total_count > response.check_runs.length) {
throw new Error(`response lists ${response.check_runs.length} of ${response.total_count} check runs`);
}
const bySuite = new Map();
for (const run of response.check_runs) {
const id = requireField(run, run?.id, 'id');
const suite = requireField(run, run.check_suite?.id, 'check_suite.id');
const status = requireField(run, run.status, 'status');
if (status === 'completed') {
requireField(run, run.conclusion, 'conclusion');
if (Number.isNaN(Date.parse(requireField(run, run.completed_at, 'completed_at')))) {
throw new Error(`check run ${id} has an unreadable completed_at`);
}
}
const kept = bySuite.get(suite);
if (!kept || id > kept.id) bySuite.set(suite, run);
}
return [...bySuite.values()];
}
// Returns { ok, lines } for the API response of one commit.
export function verdict(response, sha) {
const runs = latestPerSuite(response);
const describe = (run) =>
`${run.status === 'completed' ? run.conclusion : run.status} ${run.html_url ?? `check run ${run.id}`}`;
if (runs.length === 0) {
return { ok: false, lines: [`::error::ci-ok never ran on ${sha}. Run Tests & SonarQube on it and dispatch again.`] };
}
const lines = runs.map((run) => `ci-ok on ${sha}: ${describe(run)}`);
const pending = runs.filter((run) => run.status !== 'completed');
if (pending.length > 0) {
for (const run of pending) lines.push(`::error::ci-ok on ${sha} is still running: ${describe(run)}. Wait for it and dispatch again.`);
return { ok: false, lines };
}
const newest = runs.reduce((a, b) => {
const diff = Date.parse(b.completed_at) - Date.parse(a.completed_at);
return diff > 0 || (diff === 0 && b.id > a.id) ? b : a;
});
if (newest.conclusion !== 'success') {
lines.push(`::error::The newest ci-ok on ${sha} is not green: ${describe(newest)}. Re-run that run, or run Tests & SonarQube on it again, then dispatch again.`);
return { ok: false, lines };
}
for (const run of runs) {
if (run !== newest && run.conclusion !== 'success') {
lines.push(`::notice::An older ci-ok on ${sha} is ${run.conclusion} (${run.html_url ?? run.id}); the newer green run supersedes it.`);
}
}
return { ok: true, lines };
}
function main() {
const args = process.argv.slice(2);
if (args.length !== 2 || !args[0] || !args[1]) throw new Error('usage: ci-ok-verdict.mjs <check-runs.json> <sha>');
// An unreadable or malformed file throws and fails the job.
const response = JSON.parse(readFileSync(args[0], 'utf8'));
const result = verdict(response, args[1]);
console.log(result.lines.join('\n'));
if (!result.ok) process.exit(1);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
main();
} catch (err) {
console.error(`::error::${err instanceof Error ? err.message : err}`);
process.exit(1);
}
}