mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
Stop the merge queue from calling a head green while a check on it failed (GST-2167) (#4235)
* Stop the merge queue from calling a head green while a check on it failed (GST-2167) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Don't count a workflow run cancelled before any job as a failed check, and keep a jobless failure counted until its replacement starts (GST-2167) A concurrency group cancels a pending run when a newer one at another SHA arrives, so the main backstop at526f604f91and5fdfa14904would have read red forever. A replacing run that is still queued with no jobs no longer hides the failure before it. Adds a test that an unreadable workflow-runs list is unknown. Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/specialist-batched#1 reader=testing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Judge each check suite by its latest attempt that ran jobs, so a rerun clears a dropped matrix failure and an empty failed rerun stays red (GST-2167) ciAtHead now reads check runs with the default filter and with filter=all. A suite the default list names is judged by that list alone; a suite only filter=all names is judged by its earlier attempts, and its workflow run counts as a failed check with no jobs when it failed and nothing in the suite already counts the failure. Review-Finding: v1/outside-voice#1 reader=outside-voice Review-Finding: v1/outside-voice#2 reader=outside-voice Review-Finding: v1/adversarial-native#1 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Name the changelog fragment after the branch and split a run-on sentence in it (GST-2167) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Describe how the merge queue now counts CI in the ship policy doc (GST-2167) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
01e398051c
commit
a5bcbc4f89
9 files changed
+184
-18
No files matched your search
@@ -0,0 +1 @@
|
||||
- **The merge queue no longer calls a head green while a check on it failed.** `gstack-ship-queue ready`, `land`, train land, wave and the post-merge watch all read CI through one helper, and it missed two kinds of failed run. GitHub lists only the newest attempt of each workflow run by default, so when a rerun attempt created no jobs, the failed run before it vanished from the list; gstack-evals#303 went READY with `passed=1 failed=0` while its `checks` job was red. The helper now also reads every attempt. A check suite whose latest attempt ran jobs is judged by that attempt alone, so a rerun that dropped a failed matrix job clears it, and a suite whose latest attempt had no jobs is judged by its earlier attempts and its workflow run's result, so a passing first attempt no longer hides a rerun that failed without starting. A workflow run that ended without creating any job (a startup failure, or a run that failed before scheduling one) never appears as a check run at all; it now counts as a failed check named `<workflow> (run <id>, no jobs)` unless a later run of the same workflow at that head replaced it; a replacing run counts only once it has jobs or has finished, and a run cancelled before any job (a concurrency group dropping a pending run for a newer SHA, as the main backstop does) never ran, so it does not count. A green head, a pending or skipped check and a rerun that passed read the same as before (GST-2167).
|
||||
@@ -52,6 +52,7 @@ function wrap(fx: TrainFixture): void {
|
||||
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;out(p)}",
|
||||
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];d.log.push({cmd:'pr merge',args:a});if(rev('refs/heads/'+p.headRefName)!==want){save();console.error('Head branch was modified');process.exit(1)}",
|
||||
" const before=rev('main');const tree=g('merge-tree','--write-tree',before,want).split('\\n')[0];g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',before,'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
|
||||
"if(a[0]==='api'&&/actions\\/runs\\?head_sha=/.test(a[1])){console.log(JSON.stringify({total_count:0,workflow_runs:[]}));process.exit(0)}",
|
||||
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
|
||||
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]){const list=paged[2]==='check-runs'?[{name:'test',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
|
||||
"const r=cp.spawnSync('node',[" + JSON.stringify(shim) + ",D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);",
|
||||
|
||||
+8
-5
@@ -265,13 +265,16 @@ so it builds the scratch merge described below (`SCREEN_MERGE:`, pushed as
|
||||
(or a remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`. A CHANGELOG-only conflict is screenable, and a
|
||||
lane whose skill's own paid cases ran focused may still READY with `--no-screen`.
|
||||
|
||||
`ci:` counts the latest run per check (one app's check name), as GitHub's
|
||||
rollup does: a check that was re-run or superseded on the same commit (a
|
||||
cancelled run beside a later pass of the same name from a second workflow run)
|
||||
is judged by its newest run (latest `completed_at`, then id; a run still in
|
||||
`ci:` reads every attempt of each check suite and judges a suite by its latest
|
||||
attempt that has jobs (earlier attempts count only when the latest has none).
|
||||
It keeps the newest run per check (one app's check name): a check that was
|
||||
re-run or superseded on the same commit is judged by its newest run (latest `completed_at`, then id; a run still in
|
||||
progress is newest); two apps reporting the same name stay separate; a run with
|
||||
no name or no readable `completed_at` counts on its own; commit-status contexts
|
||||
are counted one each.
|
||||
are counted one each. A workflow run that failed without starting any job
|
||||
counts as a failed check `<workflow> (run <id>, no jobs)` unless a later run of
|
||||
the same workflow replaced it; a run cancelled before any job is not counted.
|
||||
An unreadable list is unknown, never green.
|
||||
|
||||
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
|
||||
is not the PR's pushed head, CI at that exact head is not green, or the pregate
|
||||
|
||||
+49
-6
@@ -107,10 +107,10 @@ const CI_PAGE = 100;
|
||||
const CI_MAX_PAGES = 10;
|
||||
|
||||
/** Every page of one GitHub list for a commit; anything short of exactly total_count is an error, never a partial answer. */
|
||||
function readAllPages(ctx: QueueContext, endpoint: string, key: 'check_runs' | 'statuses'): { items: Array<Record<string, unknown>> } | { error: string } {
|
||||
function readAllPages(ctx: QueueContext, endpoint: string, key: 'check_runs' | 'statuses' | 'workflow_runs'): { items: Array<Record<string, unknown>> } | { error: string } {
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
for (let page = 1; page <= CI_MAX_PAGES; page++) {
|
||||
const r = ctx.gh(['api', `${endpoint}?per_page=${CI_PAGE}&page=${page}`]);
|
||||
const r = ctx.gh(['api', `${endpoint}${endpoint.includes('?') ? '&' : '?'}per_page=${CI_PAGE}&page=${page}`]);
|
||||
let body: { total_count?: unknown; [k: string]: unknown } | null = null;
|
||||
try { body = r.ok ? JSON.parse(r.out) : null; } catch {}
|
||||
const list = body?.[key];
|
||||
@@ -149,14 +149,57 @@ export function latestRunPerName<T extends { name?: unknown; id?: unknown; statu
|
||||
return [...latest.values(), ...standalone];
|
||||
}
|
||||
|
||||
/** CI at one exact SHA: the latest check run per name (as GitHub's rollup counts) and every commit-status context; unreadable or incomplete is unknown, never green. */
|
||||
const suiteOf = (r: Record<string, unknown>) => String((r.check_suite as { id?: unknown } | null | undefined)?.id ?? '');
|
||||
|
||||
/**
|
||||
* Workflow runs at a head that ended without a passing conclusion and without one check run (a startup_failure, or a
|
||||
* run that failed before scheduling a job), so the check-runs list never names them. Each counts as its own failed
|
||||
* check unless a later run of the same workflow at this head replaced it; that run's own checks then count (Plain
|
||||
* title on #4225 failed with no jobs and passed two minutes later; gstack-evals#303's second sealed run had no later one).
|
||||
* A later run replaces it only once that run has check runs or has finished; while it is still queued with no jobs the
|
||||
* failure still counts. A run cancelled before any job never ran: a concurrency group cancels a pending run when a newer
|
||||
* one at another SHA arrives (the main backstop at 526f604f911e), so it is not counted. "Has jobs" means its suite is in
|
||||
* latestSuites (its latest attempt ran jobs), so a passing first attempt never hides a rerun that failed with no jobs; a
|
||||
* suite whose earlier-attempt runs already count a failure (judged) adds no second entry.
|
||||
*/
|
||||
export function joblessRuns(latestSuites: Set<string>, judged: Array<Record<string, unknown>>, workflowRuns: Array<Record<string, unknown>>): Array<{ name: string; status: string; conclusion: string | null }> {
|
||||
const failedSuites = new Set(judged.filter(r => r.status === 'completed' && !['success', 'skipped', 'neutral'].includes(String(r.conclusion))).map(suiteOf).filter(Boolean));
|
||||
const workflow = (w: Record<string, unknown>) => String(w.workflow_id ?? w.path ?? '');
|
||||
const order = (w: Record<string, unknown>) => [Date.parse(String(w.created_at ?? '')) || 0, Number(w.id) || 0] as const;
|
||||
const newer = (a: Record<string, unknown>, b: Record<string, unknown>) => order(a)[0] > order(b)[0] || (order(a)[0] === order(b)[0] && order(a)[1] > order(b)[1]);
|
||||
const started = (w: Record<string, unknown>) => latestSuites.has(String(w.check_suite_id ?? ''));
|
||||
const replaces = (o: Record<string, unknown>, w: Record<string, unknown>) => o !== w && workflow(o) === workflow(w) && newer(o, w) && (started(o) || (o.status === 'completed' && o.conclusion !== 'cancelled'));
|
||||
return workflowRuns
|
||||
.filter(w => w.status === 'completed' && !['success', 'skipped', 'neutral', 'cancelled'].includes(String(w.conclusion)) && !started(w) && !failedSuites.has(String(w.check_suite_id ?? '')))
|
||||
.filter(w => !workflow(w) || !workflowRuns.some(o => replaces(o, w)))
|
||||
.map(w => ({ name: `${String(w.name ?? w.path ?? 'workflow')} (run ${String(w.id ?? '?')}, no jobs)`, status: 'completed', conclusion: w.conclusion === undefined || w.conclusion === null ? null : String(w.conclusion) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* CI at one exact SHA: the latest check run per name, every workflow run that ended with no jobs (joblessRuns), and
|
||||
* every commit-status context; unreadable or incomplete is unknown, never green. Check runs are read twice: GitHub's
|
||||
* default (filter=latest) lists only each check suite's latest attempt, so a rerun attempt that created no jobs hides the
|
||||
* failed run before it (gstack-evals#303), and GitHub's own rollup drops it the same way. A suite the latest list names is
|
||||
* judged by that list alone, so a rerun whose matrix dropped a failed job retires it; a suite only filter=all names (its
|
||||
* latest attempt had no jobs) is judged by its earlier attempts. latestRunPerName then picks the newest run per name.
|
||||
*/
|
||||
export function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean; checks: Array<{ name: string; status: string; conclusion: string | null }>; contexts: Array<{ name: string; state: string }> } {
|
||||
const at = sha.slice(0, 12);
|
||||
const runs = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs');
|
||||
if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false, checks: [], contexts: [] };
|
||||
const latest = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs');
|
||||
if ('error' in latest) return { line: `unknown at ${at} (${latest.error})`, green: false, none: false, checks: [], contexts: [] };
|
||||
const all = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs?filter=all`, 'check_runs');
|
||||
if ('error' in all) return { line: `unknown at ${at} (${all.error})`, green: false, none: false, checks: [], contexts: [] };
|
||||
const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses');
|
||||
if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false, checks: [], contexts: [] };
|
||||
const checks = latestRunPerName(runs.items).map(c => ({ name: String(c.name ?? ''), status: String(c.status ?? ''), conclusion: c.conclusion === undefined || c.conclusion === null ? null : String(c.conclusion) }));
|
||||
const workflowRuns = readAllPages(ctx, `repos/{owner}/{repo}/actions/runs?head_sha=${sha}`, 'workflow_runs');
|
||||
if ('error' in workflowRuns) return { line: `unknown at ${at} (${workflowRuns.error})`, green: false, none: false, checks: [], contexts: [] };
|
||||
const latestSuites = new Set(latest.items.map(suiteOf).filter(Boolean));
|
||||
const latestIds = new Set(latest.items.map(r => String(r.id ?? '')).filter(Boolean));
|
||||
const judged = latestRunPerName([...latest.items, ...all.items.filter(r => suiteOf(r) ? !latestSuites.has(suiteOf(r)) : !!r.id && !latestIds.has(String(r.id)))]);
|
||||
const checks = [
|
||||
...judged.map(c => ({ name: String(c.name ?? ''), status: String(c.status ?? ''), conclusion: c.conclusion === undefined || c.conclusion === null ? null : String(c.conclusion) })),
|
||||
...joblessRuns(latestSuites, judged, workflowRuns.items),
|
||||
];
|
||||
const verdicts = [
|
||||
...checks.map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'),
|
||||
...statuses.items.map(s => s.state === 'success' ? 'passed' : s.state === 'pending' ? 'pending' : 'failed'),
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { SHIP_QUEUE_KEYS_VERSION } from '../lib/ship-policy';
|
||||
import { receiptDigest } from '../lib/ship-queue';
|
||||
import { ciAtHead, receiptDigest } from '../lib/ship-queue';
|
||||
import { applyStampPatch } from '../crew/lib/ship-queue-gate';
|
||||
import type { TreeReceipt } from '../lib/tree-receipt';
|
||||
import { BASE_POLICY, BIN, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
@@ -547,13 +547,13 @@ describe('full_e2e gate evidence (review #3235 P1-1)', () => {
|
||||
describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
|
||||
const pass = { status: 'completed', conclusion: 'success' };
|
||||
const hundred = Array.from({ length: 100 }, () => pass);
|
||||
function readyWith(checks: unknown, statuses?: unknown) {
|
||||
function readyWith(checks: unknown, statuses?: unknown, workflowRuns?: unknown) {
|
||||
const repo = fixture();
|
||||
const head = makePr(repo, 'pr-a');
|
||||
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head) } }, checks: { [head]: checks }, ...(statuses ? { statuses: { [head]: statuses } } : {}) });
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head) } }, checks: { [head]: checks }, ...(statuses ? { statuses: { [head]: statuses } } : {}), ...(workflowRuns ? { workflowRuns: { [head]: workflowRuns } } : {}) });
|
||||
return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) };
|
||||
}
|
||||
|
||||
@@ -599,6 +599,102 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
|
||||
expect(line(nullConclusion.r.out, 'ci: ')).toBe(`ci: red at ${nullConclusion.at} (passed=1 failed=1 pending=0 skipped=0)`);
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=a check whose latest workflow-run attempt has no jobs (GitHub's default filter=latest then lists none of its runs) still counts its
|
||||
// failed, cancelled, timed_out or action_required run, while a green head, a pending or skipped check and a rerun that later passed keep their results;
|
||||
// fails_when=ready reads only the latest attempt's runs and calls gstack-evals#303 (failed checks, attempt 2 empty, beside a passing sealed) green;
|
||||
// seam=ciAtHead (ready, land, train land, wave and post-merge share it) through the gh shim's filter=latest/all split; no_claim=a workflow run that never created a job
|
||||
test('a failed check run from an earlier attempt is red when the latest attempt listed no jobs (gstack-evals#303)', () => {
|
||||
const app = { id: 15368 };
|
||||
const sealed = { id: 129322588977, name: 'sealed', status: 'completed', conclusion: 'success', completed_at: '2026-11-09T17:22:47Z', app, check_suite: { id: 115553095053 } };
|
||||
const checks = { id: 129322614246, name: 'checks', status: 'completed', conclusion: 'failure', completed_at: '2026-11-09T17:49:57Z', app, check_suite: { id: 115553115622 } };
|
||||
const pr303 = readyWith({ check_runs: [{ ...checks, earlier_attempt: true }, sealed] });
|
||||
expect(line(pr303.r.out, 'ci: ')).toBe(`ci: red at ${pr303.at} (passed=1 failed=1 pending=0 skipped=0)`);
|
||||
expect(pr303.r.status).toBe(EXIT.refused);
|
||||
for (const conclusion of ['cancelled', 'timed_out', 'action_required']) {
|
||||
const r = readyWith({ check_runs: [{ ...checks, conclusion, earlier_attempt: true }, sealed] });
|
||||
expect(line(r.r.out, 'ci: ')).toBe(`ci: red at ${r.at} (passed=1 failed=1 pending=0 skipped=0)`);
|
||||
}
|
||||
const green = readyWith({ check_runs: [{ ...checks, conclusion: 'success' }, sealed] });
|
||||
expect(green.r.status).toBe(EXIT.ok);
|
||||
expect(line(green.r.out, 'ci: ')).toBe(`ci: green at ${green.at} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
const pending = readyWith({ check_runs: [{ ...checks, status: 'in_progress', conclusion: null, completed_at: null }, sealed] });
|
||||
expect(line(pending.r.out, 'ci: ')).toBe(`ci: pending at ${pending.at} (passed=1 failed=0 pending=1 skipped=0)`);
|
||||
const skipped = readyWith({ check_runs: [{ ...checks, conclusion: 'skipped' }, sealed] });
|
||||
expect(line(skipped.r.out, 'ci: ')).toBe(`ci: green at ${skipped.at} (passed=1 failed=0 pending=0 skipped=1)`);
|
||||
const rerunPassed = readyWith({ check_runs: [{ ...checks, earlier_attempt: true }, { ...checks, id: 129330000000, conclusion: 'success', completed_at: '2026-11-09T18:20:00Z' }, sealed] });
|
||||
expect(rerunPassed.r.status).toBe(EXIT.ok);
|
||||
expect(line(rerunPassed.r.out, 'ci: ')).toBe(`ci: green at ${rerunPassed.at} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=a workflow run that failed before creating any job (no check run names it) is red unless a later run of the same workflow at the
|
||||
// head replaced it; fails_when=ready drops gstack-evals#303's jobless sealed failure, or holds #4225's head on a jobless Plain title failure that a later
|
||||
// run passed; seam=ciAtHead's actions/runs?head_sha read through the gh shim; no_claim=third-party apps' check suites that never report
|
||||
test('a workflow run that ended with no jobs is red until a later run of the same workflow replaces it (gstack-evals#303, #4225)', () => {
|
||||
const app = { id: 15368 };
|
||||
const sealedRun = { id: 129322588977, name: 'sealed', status: 'completed', conclusion: 'success', completed_at: '2026-11-09T17:22:47Z', app, check_suite: { id: 115553095053 } };
|
||||
const checksRun = { id: 129322614246, name: 'checks', status: 'completed', conclusion: 'failure', completed_at: '2026-11-09T17:49:57Z', app, check_suite: { id: 115553115622 }, earlier_attempt: true };
|
||||
const wf = (id: number, name: string, workflow_id: number, suite: number, conclusion: string, created_at: string) => ({ id, name, workflow_id, check_suite_id: suite, status: 'completed', conclusion, created_at });
|
||||
const pr303 = readyWith({ check_runs: [checksRun, sealedRun] }, undefined, { workflow_runs: [
|
||||
wf(42671854799, 'sealed', 2, 115574560782, 'failure', '2026-11-09T18:11:40Z'),
|
||||
wf(42663983836, 'ci', 1, 115553115622, 'failure', '2026-11-09T17:22:30Z'),
|
||||
wf(42663976278, 'sealed', 2, 115553095053, 'success', '2026-11-09T17:22:28Z'),
|
||||
] });
|
||||
expect(line(pr303.r.out, 'ci: ')).toBe(`ci: red at ${pr303.at} (passed=1 failed=2 pending=0 skipped=0)`);
|
||||
expect(pr303.r.status).toBe(EXIT.refused);
|
||||
const title = { id: 129351707680, name: 'Plain title', status: 'completed', conclusion: 'success', completed_at: '2026-11-09T18:28:20Z', app, check_suite: { id: 115581108609 } };
|
||||
const free = { id: 129351700000, name: 'free-tests', status: 'completed', conclusion: 'success', completed_at: '2026-11-09T18:20:00Z', app, check_suite: { id: 115580000000 } };
|
||||
const startup = wf(42664833631, 'Plain title', 410784741, 115555408717, 'startup_failure', '2026-11-09T17:27:42Z');
|
||||
const jobless = wf(42673942770, 'Plain title', 410784741, 115580220335, 'failure', '2026-11-09T18:25:42Z');
|
||||
const replaced = readyWith({ check_runs: [title, free] }, undefined, { workflow_runs: [wf(42674265699, 'Plain title', 410784741, 115581108609, 'success', '2026-11-09T18:27:59Z'), jobless, startup] });
|
||||
expect(line(replaced.r.out, 'ci: ')).toBe(`ci: green at ${replaced.at} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
expect(replaced.r.status).toBe(EXIT.ok);
|
||||
const last = readyWith({ check_runs: [{ ...title, completed_at: '2026-11-09T17:30:00Z' }, free] }, undefined, { workflow_runs: [wf(42664900000, 'Plain title', 410784741, 115581108609, 'success', '2026-11-09T17:29:00Z'), jobless, startup] });
|
||||
expect(line(last.r.out, 'ci: ')).toBe(`ci: red at ${last.at} (passed=2 failed=1 pending=0 skipped=0)`);
|
||||
expect(last.r.status).toBe(EXIT.refused);
|
||||
const queued = readyWith({ check_runs: [{ ...title, completed_at: '2026-11-09T17:30:00Z' }, free] }, undefined, { workflow_runs: [{ ...wf(42674265699, 'Plain title', 410784741, 115590000000, 'success', '2026-11-09T18:27:59Z'), status: 'queued', conclusion: null }, jobless] });
|
||||
expect(line(queued.r.out, 'ci: ')).toBe(`ci: red at ${queued.at} (passed=2 failed=1 pending=0 skipped=0)`);
|
||||
expect(queued.r.status).toBe(EXIT.refused);
|
||||
const backstop = wf(41764056397, 'Main backstop gate', 3, 115500000000, 'cancelled', '2026-11-04T02:57:38Z');
|
||||
const superseded = readyWith({ check_runs: [title, free] }, undefined, { workflow_runs: [backstop] });
|
||||
expect(line(superseded.r.out, 'ci: ')).toBe(`ci: green at ${superseded.at} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
expect(superseded.r.status).toBe(EXIT.ok);
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=each check suite is judged by its latest attempt alone when that attempt has jobs, and by its earlier attempts plus its
|
||||
// workflow run's conclusion when it has none; fails_when=a rerun whose matrix dropped a failed slice stays red, or a passing first attempt hides a
|
||||
// failed jobless rerun; seam=ciAtHead's filter=latest and filter=all reads through the gh shim; no_claim=GitHub's suite-per-attempt grouping
|
||||
test('each check suite is judged by its latest attempt that ran jobs (stale matrix, empty failed rerun, replaced jobless run)', () => {
|
||||
const app = { id: 15368 };
|
||||
const at = (id: number, name: string, conclusion: string, completed_at: string, suite: number, earlier = false) => ({ id, name, status: 'completed', conclusion, completed_at, app, check_suite: { id: suite }, ...(earlier ? { earlier_attempt: true } : {}) });
|
||||
const wf = (id: number, name: string, workflow_id: number, suite: number, conclusion: string, created_at: string) => ({ id, name, workflow_id, check_suite_id: suite, status: 'completed', conclusion, created_at });
|
||||
const stale = readyWith({ check_runs: [
|
||||
at(1, 'eval-slices (1)', 'success', '2026-11-09T17:00:00Z', 500, true), at(2, 'eval-slices (3)', 'failure', '2026-11-09T17:01:00Z', 500, true),
|
||||
at(3, 'eval-slices (1)', 'success', '2026-11-09T18:00:00Z', 500), at(4, 'eval-slices (2)', 'success', '2026-11-09T18:01:00Z', 500),
|
||||
] }, undefined, { workflow_runs: [wf(42690000000, 'E2E Evals', 7, 500, 'success', '2026-11-09T16:59:00Z')] });
|
||||
expect(line(stale.r.out, 'ci: ')).toBe(`ci: green at ${stale.at} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
expect(stale.r.status).toBe(EXIT.ok);
|
||||
const free = at(10, 'free-tests', 'success', '2026-11-09T18:20:00Z', 700);
|
||||
const emptyRerun = readyWith({ check_runs: [at(11, 'checks', 'success', '2026-11-09T17:30:00Z', 600, true), free] }, undefined, { workflow_runs: [wf(42699999999, 'ci', 8, 600, 'failure', '2026-11-09T17:20:00Z')] });
|
||||
expect(line(emptyRerun.r.out, 'ci: ')).toBe(`ci: red at ${emptyRerun.at} (passed=2 failed=1 pending=0 skipped=0)`);
|
||||
const lists: Record<string, unknown> = { latest: { check_runs: [free] }, all: { check_runs: [at(11, 'checks', 'success', '2026-11-09T17:30:00Z', 600), free] }, statuses: { statuses: [] }, workflow_runs: { workflow_runs: [wf(42699999999, 'ci', 8, 600, 'failure', '2026-11-09T17:20:00Z')] } };
|
||||
const gh = (args: string[]) => { const url = args[1] ?? ''; const key = url.includes('actions/runs') ? 'workflow_runs' : url.includes('/status') ? 'statuses' : url.includes('filter=all') ? 'all' : 'latest'; const body = lists[key] as Record<string, unknown[]>; return { ok: true, out: JSON.stringify({ total_count: Object.values(body)[0]!.length, ...body }) }; };
|
||||
expect(ciAtHead({ repoRoot: '', base: 'main', gh }, 'a'.repeat(40)).checks.filter(c => c.conclusion === 'failure').map(c => c.name)).toEqual(['ci (run 42699999999, no jobs)']);
|
||||
expect(emptyRerun.r.status).toBe(EXIT.refused);
|
||||
const title = at(12, 'Plain title', 'success', '2026-11-09T18:28:20Z', 800);
|
||||
const plain = readyWith({ check_runs: [title, free] }, undefined, { workflow_runs: [wf(42674265699, 'Plain title', 410784741, 800, 'success', '2026-11-09T18:27:59Z'), wf(42673942770, 'Plain title', 410784741, 900, 'failure', '2026-11-09T18:25:42Z')] });
|
||||
expect(line(plain.r.out, 'ci: ')).toBe(`ci: green at ${plain.at} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
expect(plain.r.status).toBe(EXIT.ok);
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=the workflow-runs read fails closed like the check-runs read; fails_when=ready treats an unreadable or short actions/runs list
|
||||
// as zero jobless runs and calls the head green; seam=ciAtHead's readAllPages over actions/runs?head_sha; no_claim=GitHub's own pagination
|
||||
test('an unreadable or short workflow-runs list is unknown, never green', () => {
|
||||
const pass = { id: 1, name: 'free-tests', status: 'completed', conclusion: 'success', completed_at: '2026-11-09T18:00:00Z', app: { id: 1 } };
|
||||
const short = readyWith({ check_runs: [pass] }, undefined, { total_count: 5, workflow_runs: [] });
|
||||
expect(line(short.r.out, 'ci: ')).toMatch(new RegExp(`^ci: unknown at ${short.at}`));
|
||||
expect(short.r.status).toBe(EXIT.refused);
|
||||
}, 240_000);
|
||||
|
||||
test('a commit-status context is never collapsed with a check run of the same name', () => {
|
||||
const r = readyWith({ check_runs: [{ id: 1, name: 'deploy', status: 'completed', conclusion: 'success', completed_at: '2026-10-26T01:00:00Z' }] }, { state: 'failure', statuses: [{ context: 'deploy', state: 'failure' }] });
|
||||
expect(line(r.r.out, 'ci: ')).toBe(`ci: red at ${r.at} (passed=1 failed=1 pending=0 skipped=0)`);
|
||||
|
||||
@@ -80,9 +80,12 @@ export function gbrainPairRepo(): { repo: FixtureRepo; pair: GbrainPair; gated:
|
||||
* gh for the queue: pr view, and paged check-runs and combined commit status
|
||||
* per SHA. A checks or statuses entry is one page (total_count defaults to its
|
||||
* length; later pages are empty) or { pages: [...] }. A SHA with no statuses
|
||||
* entry has no commit-status contexts, as on GitHub.
|
||||
* entry has no commit-status contexts, as on GitHub. A check run marked
|
||||
* earlier_attempt belongs to a superseded workflow-run attempt: like GitHub's
|
||||
* default filter=latest, the shim hides it unless the call asks for filter=all.
|
||||
* workflowRuns answers actions/runs?head_sha=<sha> the same way (none by default).
|
||||
*/
|
||||
export function ghQueueShim(repo: FixtureRepo, data: { prs: Record<string, unknown>; checks: Record<string, unknown>; statuses?: Record<string, unknown> }): { PATH: string } {
|
||||
export function ghQueueShim(repo: FixtureRepo, data: { prs: Record<string, unknown>; checks: Record<string, unknown>; statuses?: Record<string, unknown>; workflowRuns?: Record<string, unknown> }): { PATH: string } {
|
||||
const dir = path.join(repo.root, 'gh-queue-shim');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'data.json'), JSON.stringify(data));
|
||||
@@ -90,7 +93,9 @@ export function ghQueueShim(repo: FixtureRepo, data: { prs: Record<string, unkno
|
||||
"const d = require(process.argv[2]); const a = process.argv.slice(3);",
|
||||
"if (a[0] === 'pr' && a[1] === 'view') { const p = d.prs[a[2]]; if (!p) { console.error('no pull request found'); process.exit(1); } console.log(JSON.stringify(p)); process.exit(0); }",
|
||||
"const paged = (c, key, page) => { if (c.pages) return c.pages[page - 1] || { total_count: c.pages.reduce((n, p) => n + p[key].length, 0), [key]: [] }; const total = c.total_count ?? c[key].length; return page === 1 ? { total_count: total, ...c } : { total_count: total, [key]: [] }; };",
|
||||
"if (a[0] === 'api') { const m = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || ''); if (!m) { console.error('gh shim: unsupported ' + a.join(' ')); process.exit(1); } const runs = m[2] === 'check-runs'; const c = runs ? d.checks[m[1]] : ((d.statuses || {})[m[1]] || { state: 'pending', statuses: [] }); if (!c) { console.error('HTTP 404'); process.exit(1); } console.log(JSON.stringify(paged(c, runs ? 'check_runs' : 'statuses', Number(m[3])))); process.exit(0); }",
|
||||
"const latestOnly = (c, url) => /[?&]filter=all(&|$)/.test(url) || !c || !Array.isArray(c.check_runs) || !c.check_runs.some(r => r.earlier_attempt) ? c : { check_runs: c.check_runs.filter(r => !r.earlier_attempt) };",
|
||||
"if (a[0] === 'api') { const w = /actions\\/runs\\?head_sha=([0-9a-f]+).*page=(\\d+)/.exec(a[1] || ''); if (w) { console.log(JSON.stringify(paged((d.workflowRuns || {})[w[1]] || { workflow_runs: [] }, 'workflow_runs', Number(w[2])))); process.exit(0); } }",
|
||||
"if (a[0] === 'api') { const m = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || ''); if (!m) { console.error('gh shim: unsupported ' + a.join(' ')); process.exit(1); } const runs = m[2] === 'check-runs'; const c = runs ? latestOnly(d.checks[m[1]], a[1]) : ((d.statuses || {})[m[1]] || { state: 'pending', statuses: [] }); if (!c) { console.error('HTTP 404'); process.exit(1); } console.log(JSON.stringify(paged(c, runs ? 'check_runs' : 'statuses', Number(m[3])))); process.exit(0); }",
|
||||
"console.error('gh shim: unsupported ' + a.join(' ')); process.exit(1);",
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(dir, 'shim.js'), js);
|
||||
|
||||
@@ -166,6 +166,7 @@ export function wrapLand(fx: TrainFixture): void {
|
||||
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
|
||||
"if(a[0]==='api'&&/actions\\/runs\\?head_sha=/.test(a[1])){console.log(JSON.stringify({total_count:0,workflow_runs:[]}));process.exit(0)}",
|
||||
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
|
||||
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
|
||||
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
|
||||
@@ -240,6 +241,7 @@ function shimSource(): string {
|
||||
" if (arts) { const names = Object.keys(d.artifacts[arts[1]] || {}).filter(n => !arts[2] || n === decodeURIComponent(arts[2])); out({ total_count: names.length, artifacts: names.map(name => ({ name, expired: !!(d.expired || {})[arts[1]] })) }); }",
|
||||
" const named = /commits\\/([0-9a-f]+)\\/check-runs\\?check_name=([^&]+)/.exec(a[1] || '');",
|
||||
" if (named) { const list = (d.checks[named[1]] || []).filter(c => c.name === decodeURIComponent(named[2])); out({ total_count: list.length, check_runs: list }); }",
|
||||
" if (/actions\\/runs\\?head_sha=/.test(a[1] || '')) out({ total_count: 0, workflow_runs: [] });",
|
||||
" const paged = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || '');",
|
||||
" if (paged && (d.ciFail || []).includes(paged[1])) fail('HTTP 502: gateway timeout');",
|
||||
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : ((d.statuses || {})[paged[1]] || []); const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
|
||||
|
||||
@@ -54,10 +54,12 @@ function setup(policy: Record<string, unknown> = POLICY): Fx {
|
||||
" if(d.mainOnView){g('update-ref','refs/heads/main',d.mainOnView);delete d.mainOnView;save()}",
|
||||
" let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};",
|
||||
" console.log(JSON.stringify({mergeable:'MERGEABLE',mergeStateStatus:'CLEAN',...p,headRefOid:p.headRefOid??head,mergeCommit:p.mergeSha?{oid:p.mergeSha}:null}));process.exit(0)}",
|
||||
"if(a[0]==='api'&&/actions\\/runs\\?head_sha=/.test(a[1])){console.log(JSON.stringify({total_count:0,workflow_runs:[]}));process.exit(0)}",
|
||||
"if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);",
|
||||
" if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}",
|
||||
" const set=d.checks[m[1]]??Object.fromEntries(d.defaultChecks.map(n=>[n,'success']));",
|
||||
" const runs=Object.entries(set).map(([name,c],i)=>({id:i+1,name,status:c==='pending'?'in_progress':'completed',conclusion:c==='pending'?null:c,completed_at:'2026-11-03T00:00:00Z',app:{id:1}}));",
|
||||
" const all=/[?&]filter=all(&|$)/.test(a[1]);",
|
||||
" const runs=Object.entries(set).filter(([,v])=>all||!v.earlierAttempt).map(([name,v],i)=>{const c=typeof v==='string'?v:v.conclusion;return {id:i+1,name,status:c==='pending'?'in_progress':'completed',conclusion:c==='pending'?null:c,completed_at:'2026-11-03T00:00:00Z',app:{id:1}}});",
|
||||
" console.log(JSON.stringify({total_count:runs.length,check_runs:runs}));process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];const head=g('rev-parse','refs/heads/'+p.headRefName);",
|
||||
" if(head!==want){console.error('Head branch was modified');process.exit(1)}",
|
||||
@@ -612,6 +614,18 @@ describe('land --pin: refusals (nothing merged)', () => {
|
||||
expect(missing.out + missing.err).toContain('cannot be dispatched, so edit the PR title or body to rerun it');
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=land never merges a pin whose failed check run sits in an earlier workflow-run attempt that GitHub's default filter=latest hides
|
||||
// (gstack-evals#303: the rerun attempt listed no jobs); fails_when=land reads only the latest attempt and merges; seam=ciAtHead through the shim's
|
||||
// filter=latest/all split; no_claim=a workflow run that never created a job
|
||||
test('QUEUE_LAND_CHECKS: a failed check from an earlier attempt whose rerun listed no jobs is red (gstack-evals#303)', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '27');
|
||||
fx.write(d => { d.checks[pin] = { 'free-tests': 'success', 'Check VERSION is not stale vs queue': 'success', checks: { conclusion: 'failure', earlierAttempt: true } }; });
|
||||
const r = land(fx, '27', pin);
|
||||
refused(fx, r, 'QUEUE_LAND_CHECKS');
|
||||
expect(r.err).toMatch(/red at [0-9a-f]{12} \(passed=2 failed=1 pending=0 skipped=0\)/);
|
||||
}, 90_000);
|
||||
|
||||
test('QUEUE_LAND_NOT_CLEAN: GitHub does not report the PR mergeable and clean', () => {
|
||||
const fx = setup();
|
||||
const pin = openPr(fx, '18');
|
||||
|
||||
@@ -42,6 +42,7 @@ function setup(policy: Record<string, unknown> = POLICY): Fx {
|
||||
" let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};",
|
||||
" console.log(JSON.stringify({...p,headRefOid:head}));process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='list'){console.log(JSON.stringify(Object.entries(d.prs).filter(([,p])=>p.state==='OPEN').map(([n,p])=>({number:Number(n),headRefOid:g('rev-parse','refs/heads/'+p.headRefName),isCrossRepository:!!p.isCrossRepository}))));process.exit(0)}",
|
||||
"if(a[0]==='api'&&/actions\\/runs\\?head_sha=/.test(a[1])){console.log(JSON.stringify({total_count:0,workflow_runs:[]}));process.exit(0)}",
|
||||
"if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);",
|
||||
" if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}",
|
||||
" const red=d.red.includes(m[1]);console.log(JSON.stringify({total_count:1,check_runs:[{id:1,name:'free-tests',status:'completed',conclusion:red?'failure':'success',completed_at:'2026-10-29T00:00:00Z',app:{id:1}}]}));process.exit(0)}",
|
||||
|
||||
Reference in new issue
Block a user