#!/usr/bin/env node // What CI says about a commit or a branch, read from the runs API through gh (the Mac's gh login; the repository is // igneum-network/igneum unless IGNEUM_REPO says otherwise). The merge tool and the pre-push hook read these lines, so a // merge lands on master only when the branch's own ci run is green on the exact commit being merged (standing rule, // 7 October 2026, 17:2x UK: era-vdf's tip 0e2d6b1c was merged with no ci run at all and master's igneum-pow suite went // red for 40 minutes, hidden by docs-only merges whose runs skip the compile job). // Node 22, standard library only; gh does the HTTP. // // node tools/ci/ci-state.mjs one line: " " // state: success | failure | cancelled | timed_out | pending | none | unknown // (the NEWEST ci run on that exact commit; a re-run replaces the first attempt) // node tools/ci/ci-state.mjs --master-code the verdict of master's newest completed ci run whose compile job (igneum-pow) // RAN: a docs-only push skips it and its green hides a red suite // node tools/ci/ci-state.mjs --branch-red prints "previous CI red on : ..." when the branch's newest completed ci // run is red and no newer run is green; prints nothing otherwise; exit 0 always // node tools/ci/ci-state.mjs --self-test a fake gh on PATH answers every case: success, failure, pending, none, newest // wins, the docs-only skip walked past, the branch line, a gh error = unknown // // Exit 0 for every answered query (callers read the first word); 2 on usage; the self-test exits 1 on a failure. import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const REPO = process.env.IGNEUM_REPO || 'igneum-network/igneum'; const FIELDS = 'databaseId,status,conclusion,headSha,url,createdAt,event'; const RED = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required']); function gh(args) { const env = { ...process.env, PATH: `${process.env.PATH || ''}:/opt/homebrew/bin:/usr/local/bin` }; const r = spawnSync('gh', args, { encoding: 'utf8', env, timeout: 60000 }); if (r.error) throw new Error(`gh: ${r.error.message}`); if (r.status !== 0) throw new Error(`gh ${args.slice(0, 2).join(' ')}: exit ${r.status}: ${(r.stderr || '').trim().slice(0, 200)}`); return JSON.parse(r.stdout || 'null'); } function fullSha(sha) { if (/^[0-9a-f]{40}$/.test(sha)) return sha; // gh's --commit filter matches the full sha only (an abbreviation answers nothing) const r = spawnSync('git', ['rev-parse', '--verify', `${sha}^{commit}`], { encoding: 'utf8' }); if (r.status !== 0) throw new Error(`${sha} is not a commit here`); return r.stdout.trim(); } export const newest = (runs) => [...(runs || [])].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; export function verdictOf(run) { if (!run) return 'none'; if (run.status !== 'completed') return 'pending'; return run.conclusion || 'pending'; } export function firstRed(jobs) { for (const j of jobs || []) { if (j.conclusion === 'success' || j.conclusion === 'skipped' || j.conclusion == null) continue; const s = (j.steps || []).find((x) => x.conclusion && x.conclusion !== 'success' && x.conclusion !== 'skipped'); return `${j.name.replace(/,.*$/, '')} at "${s ? s.name : '(no step)'}"`; } return ''; } const london = (iso) => new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso)) + ' UK'; const runsForSha = (sha) => gh(['run', 'list', '--repo', REPO, '--workflow', 'ci', '--commit', sha, '--limit', '10', '--json', FIELDS]) || []; const runsForBranch = (branch, limit = 12) => gh(['run', 'list', '--repo', REPO, '--workflow', 'ci', '--branch', branch, '--limit', String(limit), '--json', FIELDS]) || []; const jobsOf = (id) => (gh(['run', 'view', String(id), '--repo', REPO, '--json', 'jobs']) || {}).jobs || []; export function stateOfSha(sha) { const run = newest(runsForSha(fullSha(sha))); const state = verdictOf(run); if (!run) return `none - - no ci run on ${sha.slice(0, 8)} yet`; let detail = state === 'pending' ? `${run.status} since ${london(run.createdAt)}` : `${run.event} run at ${london(run.createdAt)}`; if (RED.has(state)) { const red = firstRed(jobsOf(run.databaseId)); if (red) detail += `: ${red}`; } return `${state} ${run.databaseId} ${run.url} ${detail}`; } export function masterCodeState() { const runs = [...runsForBranch('master', 12)].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); for (const run of runs) { if (run.status !== 'completed') continue; const jobs = jobsOf(run.databaseId); const pow = jobs.find((j) => /^igneum-pow/.test(j.name)); if (!pow || pow.conclusion === 'skipped') continue; // a docs-only push: its green says nothing about the suite const red = RED.has(run.conclusion) ? firstRed(jobs) : ''; return `${run.conclusion} ${run.databaseId} ${run.url} master @${run.headSha.slice(0, 8)} at ${london(run.createdAt)}, compile job ${pow.conclusion}${red ? `: ${red}` : ''}`; } return 'none - - no completed master ci run with the compile job among the last 12'; } export function branchRedLine(branch) { const runs = [...runsForBranch(branch, 12)].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); const done = runs.find((r) => r.status === 'completed'); if (!done || !RED.has(done.conclusion)) return ''; const red = firstRed(jobsOf(done.databaseId)); return `previous CI red on ${branch}: @${done.headSha.slice(0, 7)} ${done.conclusion} at ${london(done.createdAt)}${red ? `: ${red}` : ''} ${done.url} (no newer green run on this branch; this push gets its own run, read it)`; } async function selfTest() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-state-')); const fake = path.join(dir, 'gh'); // the fake gh answers `run list` from list-.json and `run view ` from view-.json; FAKE_GH_FAIL=1 fails fs.writeFileSync(fake, `#!/usr/bin/env bash [ "\${FAKE_GH_FAIL:-0}" = 1 ] && { echo "HTTP 502 from the fake" >&2; exit 1; } key=""; prev="" for a in "$@"; do case "$prev" in --commit|--branch) key="$a" ;; esac; prev="$a"; done case "$1 $2" in "run list") f="$FAKE_GH_DIR/list-$key.json"; [ -f "$f" ] && cat "$f" || echo '[]' ;; "run view") f="$FAKE_GH_DIR/view-$3.json"; [ -f "$f" ] && cat "$f" || echo '{"jobs":[]}' ;; *) echo "fake gh: unknown $*" >&2; exit 1 ;; esac `, { mode: 0o755 }); const sha = (c) => c.repeat(40); const run = (id, status, conclusion, created, headSha = sha('a')) => ({ databaseId: id, status, conclusion, headSha, url: `https://github.com/x/y/actions/runs/${id}`, createdAt: created, event: 'push' }); const w = (name, obj) => fs.writeFileSync(path.join(dir, name), JSON.stringify(obj)); w(`list-${sha('a')}.json`, [run(1, 'completed', 'success', '2026-10-07T15:00:00Z')]); w(`list-${sha('b')}.json`, [run(2, 'completed', 'failure', '2026-10-07T15:00:00Z', sha('b'))]); w('view-2.json', { jobs: [{ name: 'site build, link check', conclusion: 'success', steps: [] }, { name: 'igneum-pow tests, igneum-census build', conclusion: 'failure', steps: [{ name: 'toolchain', conclusion: 'success' }, { name: 'igneum-pow tests (release)', conclusion: 'failure' }] }] }); w(`list-${sha('c')}.json`, [run(3, 'in_progress', null, '2026-10-07T15:00:00Z', sha('c'))]); w(`list-${sha('d')}.json`, [run(4, 'completed', 'failure', '2026-10-07T14:00:00Z', sha('d')), run(5, 'completed', 'success', '2026-10-07T15:00:00Z', sha('d'))]); // the re-run wins // master: the newest run is a docs-only green (compile job skipped); the one before it ran the compile job and is red w('list-master.json', [run(10, 'completed', 'success', '2026-10-07T16:00:00Z', sha('1')), run(11, 'completed', 'failure', '2026-10-07T15:30:00Z', sha('2')), run(12, 'completed', 'success', '2026-10-07T15:00:00Z', sha('3'))]); w('view-10.json', { jobs: [{ name: 'igneum-pow tests, igneum-census build', conclusion: 'skipped', steps: [] }, { name: 'site build', conclusion: 'success', steps: [] }] }); w('view-11.json', { jobs: [{ name: 'igneum-pow tests, igneum-census build', conclusion: 'failure', steps: [{ name: 'igneum-pow tests (release)', conclusion: 'failure' }] }] }); // branches: x red newest; y green newest; z pending newest over a red w('list-x.json', [run(20, 'completed', 'failure', '2026-10-07T15:00:00Z', sha('e'))]); w('view-20.json', { jobs: [{ name: 'site build, link check, identity grep', conclusion: 'failure', steps: [{ name: 'the tree gate', conclusion: 'failure' }] }] }); w('list-y.json', [run(21, 'completed', 'success', '2026-10-07T15:00:00Z'), run(22, 'completed', 'failure', '2026-10-07T14:00:00Z')]); w('list-z.json', [run(23, 'queued', null, '2026-10-07T15:10:00Z'), run(24, 'completed', 'cancelled', '2026-10-07T15:00:00Z', sha('f'))]); const me = new URL(import.meta.url).pathname; const ask = (args, extraEnv = {}) => { const r = spawnSync(process.execPath, [me, ...args], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, FAKE_GH_DIR: dir, ...extraEnv } }); return { out: (r.stdout || '').trim(), code: r.status, err: (r.stderr || '').trim() }; }; const fails = []; const expect = (name, got, re) => { if (!re.test(got.out) || got.code !== 0) fails.push(`${name}: got exit ${got.code} "${got.out}" ${got.err}`); }; expect('success', ask([sha('a')]), /^success 1 https:\/\/github.com\/x\/y\/actions\/runs\/1 push run at /); expect('failure names the red step', ask([sha('b')]), /^failure 2 \S+ push run at \d\d:\d\d UK: igneum-pow tests at "igneum-pow tests \(release\)"$/); expect('pending', ask([sha('c')]), /^pending 3 \S+ in_progress since /); expect('newest wins', ask([sha('d')]), /^success 5 /); expect('none', ask([sha('9')]), /^none - - no ci run on 99999999 yet$/); expect('gh error is unknown', ask([sha('a')], { FAKE_GH_FAIL: '1' }), /^unknown - - gh run list: exit 1: HTTP 502 from the fake/); expect('master-code walks past the docs-only green', ask(['--master-code']), /^failure 11 \S+ master @22222222 at \d\d:\d\d UK, compile job failure: igneum-pow tests at "igneum-pow tests \(release\)"$/); expect('branch red line', ask(['--branch-red', 'x']), /^previous CI red on x: @eeeeeee failure at \d\d:\d\d UK: site build at "the tree gate" https:\/\/github.com\/x\/y\/actions\/runs\/20 \(no newer green/); const y = ask(['--branch-red', 'y']); if (y.out !== '' || y.code !== 0) fails.push(`branch green prints nothing: "${y.out}" exit ${y.code}`); expect('branch pending over a cancelled run still names the red', ask(['--branch-red', 'z']), /^previous CI red on z: @fffffff cancelled at /); const noArg = ask([]); if (noArg.code !== 2) fails.push(`usage: exit ${noArg.code}`); fs.rmSync(dir, { recursive: true, force: true }); if (fails.length) { for (const f of fails) console.error(`self-test failed: ${f}`); process.exit(1); } console.log('self-test passed: a commit answers success, failure (with the red step), pending, none; the newest run wins; a gh error is unknown; --master-code walks past a docs-only green to the run that compiled; --branch-red names the newest completed red and stays silent on green'); } const args = process.argv.slice(2); try { if (args[0] === '--self-test') await selfTest(); else if (args[0] === '--master-code') console.log(masterCodeState()); else if (args[0] === '--branch-red') { if (!args[1]) { console.error('usage: ci-state.mjs --branch-red '); process.exit(2); } console.log(branchRedLine(args[1])); } else if (args[0] && !args[0].startsWith('-')) console.log(stateOfSha(args[0])); else { console.error('usage: tools/ci/ci-state.mjs | --master-code | --branch-red | --self-test'); process.exit(2); } } catch (e) { console.log(`unknown - - ${e.message.replace(/\s+/g, ' ').slice(0, 300)}`); }