diff --git a/changelog.d/ship-queue-counts-hidden-failed-ci-runs.md b/changelog.d/ship-queue-counts-hidden-failed-ci-runs.md new file mode 100644 index 000000000..89789bd7d --- /dev/null +++ b/changelog.d/ship-queue-counts-hidden-failed-ci-runs.md @@ -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 ` (run , 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). diff --git a/crew/test/ship-queue-train-generic.test.ts b/crew/test/ship-queue-train-generic.test.ts index fdddcf38a..bab6e2180 100644 --- a/crew/test/ship-queue-train-generic.test.ts +++ b/crew/test/ship-queue-train-generic.test.ts @@ -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);", diff --git a/docs/ship-policy.md b/docs/ship-policy.md index dbb3ceeb2..436f59ead 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -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 ` (run , 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 diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 64ab964ad..d03b01653 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -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> } | { error: string } { +function readAllPages(ctx: QueueContext, endpoint: string, key: 'check_runs' | 'statuses' | 'workflow_runs'): { items: Array> } | { error: string } { const items: Array> = []; 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) => 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, judged: Array>, workflowRuns: Array>): 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(w.workflow_id ?? w.path ?? ''); + const order = (w: Record) => [Date.parse(String(w.created_at ?? '')) || 0, Number(w.id) || 0] as const; + const newer = (a: Record, b: Record) => order(a)[0] > order(b)[0] || (order(a)[0] === order(b)[0] && order(a)[1] > order(b)[1]); + const started = (w: Record) => latestSuites.has(String(w.check_suite_id ?? '')); + const replaces = (o: Record, w: Record) => 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'), diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 30eb1027e..b03e49e53 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -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 = { 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; 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)`); diff --git a/test/helpers/ship-queue-fixture.ts b/test/helpers/ship-queue-fixture.ts index 87068b348..3d5f666c3 100644 --- a/test/helpers/ship-queue-fixture.ts +++ b/test/helpers/ship-queue-fixture.ts @@ -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= the same way (none by default). */ -export function ghQueueShim(repo: FixtureRepo, data: { prs: Record; checks: Record; statuses?: Record }): { PATH: string } { +export function ghQueueShim(repo: FixtureRepo, data: { prs: Record; checks: Record; statuses?: Record; workflowRuns?: Record }): { 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 { 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); diff --git a/test/helpers/ship-queue-train-fixture.ts b/test/helpers/ship-queue-train-fixture.ts index 7691153cd..d893a5a78 100644 --- a/test/helpers/ship-queue-train-fixture.ts +++ b/test/helpers/ship-queue-train-fixture.ts @@ -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';", diff --git a/test/ship-queue-land-direct.test.ts b/test/ship-queue-land-direct.test.ts index 032750b31..967626be8 100644 --- a/test/ship-queue-land-direct.test.ts +++ b/test/ship-queue-land-direct.test.ts @@ -54,10 +54,12 @@ function setup(policy: Record = 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'); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 4dd86b86a..94088a127 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -42,6 +42,7 @@ function setup(policy: Record = 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)}",