At 09:46 UK the founder's gh had his other account active (his own work) and tools/ci/gh-account-check.sh refused every Igneum push from the Mac (the v5 lane's 56a50160 held local). The check now reads Igneum's directory: empty, it refuses naming the one step (the founder or main stores the Igneum token there with `GH_CONFIG_DIR=~/.config/gh-igneum gh auth login --with-token`; no lane does); another login, refused and named; the stored entry, passes; while tools/ci/github-suspended stands it skips with a line, because no gh call can succeed and the hook already refuses GitHub pushes, so the lanes land on the mirror meanwhile. Known-failed first: an empty directory, another login, the founder's directory never read (the fake gh records the directory it was given), the marker's skip, no gh. IGNEUM_GH_CONFIG_DIR overrides the path for the self-tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
154 lines
12 KiB
JavaScript
Executable file
154 lines
12 KiB
JavaScript
Executable file
#!/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 <sha> one line: "<state> <run id> <url> <detail>"
|
|
// 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 <branch> prints "previous CI red on <branch>: ..." 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) {
|
|
// Igneum's own gh configuration directory, never the founder's (tools/ci/gh-env.sh; 8 October 2026)
|
|
const ghDir = process.env.IGNEUM_GH_CONFIG_DIR || path.join(os.homedir(), '.config', 'gh-igneum');
|
|
const env = { ...process.env, GH_CONFIG_DIR: ghDir, 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-<commit or branch>.json and `run view <id>` from view-<id>.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 <branch>'); 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 <sha> | --master-code | --branch-red <branch> | --self-test'); process.exit(2); }
|
|
} catch (e) {
|
|
console.log(`unknown - - ${e.message.replace(/\s+/g, ' ').slice(0, 300)}`);
|
|
}
|