From aa8a1195b4e1319984ebeb651f97d0ac73c9729b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 17 Oct 2026 01:16:02 +0000 Subject: [PATCH] fix(ship-queue): full_e2e needs bound gate evidence in ready and verify, CI reads every check-run page and commit status, next: quotes its paths (review #3235 P1-1, P1-5, P2-1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-queue | 23 ++++--- docs/ship-policy.md | 8 ++- lib/ship-policy.ts | 2 +- lib/ship-queue.ts | 74 +++++++++++++++++------ test/gstack-ship-queue.test.ts | 97 ++++++++++++++++++++++++++++-- test/helpers/ship-queue-fixture.ts | 11 +++- 6 files changed, 179 insertions(+), 36 deletions(-) diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index bff0a9165..6135827bb 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -3,11 +3,11 @@ * gstack-ship-queue — the shared merge queue, one subcommand per protocol * step (field list 2, W1; docs/ship-policy.md#run-a-shared-merge-queue). * - * gstack-ship-queue ready --pr [--pregate ] + * gstack-ship-queue ready --pr [--pregate ] [--gate-exit --gate-log ] * gstack-ship-queue stamp --pr --version --expect-gated --expect-base * [--expect-order ] [--allow-repo-commands] [--gate-exit --gate-log ] * gstack-ship-queue verify --pr (--gated | --record ) [--receipt ] - * [--expect-head ] [--ref ] [--no-fetch] + * [--expect-head ] [--ref ] [--no-fetch] [--gate-exit --gate-log ] * gstack-ship-queue gate-ahead --after --pr [--predecessor-ref ] [--allow-repo-commands] * gstack-ship-queue gate-ahead --record --pr --attempt --exit --log * gstack-ship-queue gate-ahead --list --pr @@ -28,11 +28,13 @@ import { attemptFile, listAttempts, prepareGateAhead, recordGateAhead, type Queu const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (docs/ship-policy.md#run-a-shared-merge-queue) - ready --pr [--pregate ] READY # STAMP_VERSION=, CI at that head, PREGATE, gate state + ready --pr [--pregate ] [--gate-exit --gate-log ] + READY # STAMP_VERSION=, CI at that head, PREGATE, gate state stamp --pr --version --expect-gated --expect-base [--expect-order ] [--allow-repo-commands] [--gate-exit --gate-log ] restamp, commit and prove: QUEUE_STAMP: head= gated= tree= receipt= verify --pr --gated | --record [--receipt ] [--expect-head ] [--ref ] [--no-fetch] + [--gate-exit --gate-log ] (full_e2e: --gated needs the lane's passing gate run on that head) the coordinator's check from the PR's pushed head: VERIFY: ... and MERGE_CONDITION gate-ahead --after --pr [--predecessor-ref ] [--allow-repo-commands] prepare an attempt: merged, stamped worktree, gated sha and the gate command @@ -59,6 +61,8 @@ function finish(cmd: string, out: StepOutput): number { return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(out.failure.code) ? EXIT.fail : EXIT.refused; } +const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + function usage(message: string): number { process.stderr.write(`gstack-ship-queue: ${message}\n${HELP}\n`); return EXIT.usage; @@ -113,7 +117,7 @@ function gateAhead(ctx: QueueContext, args: string[]): number { `record: ${r.file}`, `GATE_COMMAND: ${rec.gate_command ?? 'none (the policy sets no gate_command; run the repo gate you would run on a PR head)'}`, ...(superseded.length ? [`note: this attempt is now current; earlier unrecorded attempts (${superseded.join(', ')}) keep their worktrees until recorded`] : []), - `next: (cd ${rec.worktree} && ${cmd}) > ${log} 2>&1; gstack-ship-queue gate-ahead --record --pr ${pr} --attempt ${rec.attempt} --exit $? --log ${log}`, + `next: (cd ${shq(rec.worktree)} && ${cmd}) > ${shq(log)} 2>&1; gstack-ship-queue gate-ahead --record --pr ${pr} --attempt ${rec.attempt} --exit $? --log ${shq(log)}`, ], }); } @@ -129,17 +133,18 @@ function main(argv: string[]): number { const pr = flag(args, '--pr'); if (cmd === 'gate-ahead') return gateAhead(ctx, args); if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr `); - if (cmd === 'ready') return finish('ready', ready(ctx, { pr, pregate: flag(args, '--pregate') })); + const gateExit = flag(args, '--gate-exit'); + if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code'); + const gate = { gateExit: gateExit === undefined ? undefined : Number(gateExit), gateLog: flag(args, '--gate-log') }; + if (cmd === 'ready') return finish('ready', ready(ctx, { pr, pregate: flag(args, '--pregate'), ...gate })); if (cmd === 'stamp') { const version = flag(args, '--version'); const expectGated = flag(args, '--expect-gated'); const expectBase = flag(args, '--expect-base'); if (!version || !expectGated || !expectBase) return usage('stamp needs --version --expect-gated --expect-base '); - const gateExit = flag(args, '--gate-exit'); - if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code'); return finish('stamp', stamp(ctx, { pr, version, expectGated, expectBase, expectOrder: flag(args, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean), - allowRepoCommands: args.includes('--allow-repo-commands'), gateExit: gateExit === undefined ? undefined : Number(gateExit), gateLog: flag(args, '--gate-log'), + allowRepoCommands: args.includes('--allow-repo-commands'), ...gate, failAfter: process.env.GSTACK_SHIP_QUEUE_FAIL_AFTER, })); } @@ -147,7 +152,7 @@ function main(argv: string[]): number { const record = flag(args, '--record'); return finish('verify', verify(ctx, { pr, gated: flag(args, '--gated'), record: record ? path.resolve(record) : undefined, receipt: flag(args, '--receipt'), - expectHead: flag(args, '--expect-head'), ref: flag(args, '--ref'), noFetch: args.includes('--no-fetch'), + expectHead: flag(args, '--expect-head'), ref: flag(args, '--ref'), noFetch: args.includes('--no-fetch'), ...gate, })); } return usage(`unknown subcommand ${cmd}`); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 97a95b48b..ea16ad6e2 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -56,7 +56,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). | | `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. | | `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. | -| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`). Needs `min_gstack_version`. | +| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log ` from its run on the gated head. Needs `min_gstack_version`. | | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | Containment, checked by `validate` and again at write time: paths and globs are @@ -230,13 +230,15 @@ stamp commit, wait for CI on it, run `ready` again **5. Coordinator: verify, then merge.** From the coordinator's own clone: ```bash -gstack-ship-queue verify --pr 42 --gated --receipt --expect-head +gstack-ship-queue verify --pr 42 --gated --receipt --expect-head [--gate-exit 0 --gate-log ] gstack-ship-queue verify --pr 42 --record --receipt --expect-head ``` It fetches `refs/pull/42/head` and reruns the tree receipt there, so `QUEUE_STAMP` is never a pasted claim: `VERIFY: 42 1.2.7.0 head= -gated= tree=same-modulo-stamps receipt=`. With a gate-ahead +gated= tree=same-modulo-stamps receipt=`. Under `full_e2e`, +`--gated` also needs the lane's passing gate log from its run on that head +(`--gate-exit 0 --gate-log `), else `QUEUE_GATE_MISSING`. With a gate-ahead record, whose gated commit never left the lane's machine, it first recomputes the merge with `git merge-tree --write-tree ` and compares it with the recorded merge tree, rebuilds the gated tree from diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index e72ecfefa..8da297f23 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -111,7 +111,7 @@ export const POLICY_EXPLANATIONS: Record = { restamp_command: 'The repo’s own restamp, run first in gstack-restamp’s staging worktree (a clean checkout of HEAD) with the target version as its argument and in GSTACK_RESTAMP_VERSION; gstack’s version writer is skipped, output confinement still applies. Needs a clean caller tree and --allow-repo-commands.', migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.', gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.', - full_e2e: 'true: every merge needs a passing full-gate record (gstack-ship-queue gate-ahead --record) bound to a tree equal to the merging one modulo stamps; ready and verify refuse without one (QUEUE_GATE_MISSING).', + full_e2e: 'true: every merge needs a passing full-gate result (gstack-ship-queue gate-ahead --record, or the lane\'s --gate-exit 0 --gate-log on the gated head) bound to a tree equal to the merging one modulo stamps; ready, stamp and verify refuse without one (QUEUE_GATE_MISSING).', min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).', }; diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index f5d0afc15..574a17517 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -59,18 +59,51 @@ function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, cur return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r; } +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 } { + const items: Array> = []; + for (let page = 1; page <= CI_MAX_PAGES; page++) { + const r = ctx.gh(['api', `${endpoint}?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]; + const total = body?.total_count; + if (!Array.isArray(list) || typeof total !== 'number') return { error: `${key} page ${page} unreadable (gh: ${(r.err ?? r.out).trim().slice(0, 120) || 'no total_count'})` }; + items.push(...list); + if (items.length >= total || list.length < CI_PAGE) { + return items.length === total ? { items } : { error: `${key}: read ${items.length} of total_count=${total}` }; + } + } + return { error: `${key}: more than ${CI_PAGE * CI_MAX_PAGES} entries, not all read` }; +} + +/** CI at one exact SHA: every check run and every commit-status context; unreadable or incomplete is unknown, never green. */ function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean } { - const r = ctx.gh(['api', `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`]); - let runs: Array<{ status?: string; conclusion?: string | null }> | null = null; - try { runs = r.ok ? JSON.parse(r.out).check_runs : null; } catch {} - if (!Array.isArray(runs)) return { line: `unknown at ${sha.slice(0, 12)} (gh: ${(r.err ?? r.out).trim().slice(0, 120) || 'no check runs readable'})`, green: false }; - const count = (pred: (c: { status?: string; conclusion?: string | null }) => boolean) => runs!.filter(pred).length; - const pending = count(c => c.status !== 'completed'); - const passed = count(c => c.status === 'completed' && c.conclusion === 'success'); - const skipped = count(c => c.status === 'completed' && (c.conclusion === 'skipped' || c.conclusion === 'neutral')); - const failed = runs.length - pending - passed - skipped; - const green = runs.length > 0 && pending === 0 && failed === 0; - return { line: `${green ? 'green' : pending ? 'pending' : runs.length ? 'red' : 'none'} at ${sha.slice(0, 12)} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green }; + 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 }; + const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses'); + if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false }; + const verdicts = [ + ...runs.items.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'), + ]; + const count = (v: string) => verdicts.filter(x => x === v).length; + const [passed, failed, pending, skipped] = [count('passed'), count('failed'), count('pending'), count('skipped')]; + const green = verdicts.length > 0 && pending === 0 && failed === 0; + return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green }; +} + +/** Under full_e2e: a passing gate bound to this head (the stamp journal, a gate-ahead attempt on it) or the lane's own run on it. */ +function gateEvidence(ctx: QueueContext, pr: string, head: string, attempt: AttemptRecord | null, gateExit?: number, gateLog?: string): { ok: boolean; line: string } { + const j = readJournal(path.join(ctx.repoRoot, stampJournalRel(pr))); + if (j?.outcome?.head === head && j.gate) return { ok: j.gate.exit === 0, line: `gate-evidence: exit ${j.gate.exit} (${j.gate.source}, stamped onto ${head.slice(0, 12)})` }; + if (attempt?.result && attempt.branch_head === head) return { ok: attempt.result.exit === 0, line: `gate-evidence: exit ${attempt.result.exit} (gate-ahead attempt ${attempt.attempt})` }; + if (gateExit !== undefined && gateLog && fs.existsSync(gateLog)) return { ok: gateExit === 0, line: `gate-evidence: exit ${gateExit} log_sha256=${sha256(fs.readFileSync(gateLog))} (lane run on ${head.slice(0, 12)})` }; + return { ok: false, line: `gate-evidence: missing for ${head.slice(0, 12)} (gate-ahead --record, or --gate-exit 0 --gate-log )` }; } function gateLine(policy: LoadedPolicy, attempt: AttemptRecord | null): string { @@ -80,7 +113,7 @@ function gateLine(policy: LoadedPolicy, attempt: AttemptRecord | null): string { } /** READY: one block, identifiers first, about the exact pushed PR head. */ -export function ready(ctx: QueueContext, o: { pr: string; pregate?: string }): StepOutput { +export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gateExit?: number; gateLog?: string }): StepOutput { const run = PREGATE_OUT_REL; const policy = loadBasePolicy(ctx); if ('message' in policy) return fail(policy, [], run); @@ -121,11 +154,11 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string }): S gateLine(policy, attempt), receipt.found && receipt.receipt && !receipt.errors.length ? receiptSummaryLine(receipt.receipt) : `SHIP_RECEIPT: ${receipt.found ? 'invalid' : 'none'} in the PR body`, ]; + const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null; + if (gate) lines.push(gate.line); if (!ci.green) return fail({ code: 'QUEUE_NOT_READY', message: `CI is not green at ${head.slice(0, 12)}: ${ci.line}` }, lines, run); if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run); - if (policy.policy.full_e2e && version === 'unstamped' && !(attempt?.result?.exit === 0)) { - lines.push('note: full_e2e is set; the stamp needs a passing gate record (gate-ahead --record) or --gate-exit 0 --gate-log on the gated head'); - } + if (gate && !gate.ok) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no passing gate is bound to ${head.slice(0, 12)}: ${gate.line}` }, lines, run); return { ok: true, lines, run }; } @@ -268,7 +301,7 @@ export function stamp(ctx: QueueContext, o: StampOptions): StepOutput { }; } -export interface VerifyOptions { pr: string; gated?: string; record?: string; receipt?: string; expectHead?: string; ref?: string; noFetch?: boolean } +export interface VerifyOptions { pr: string; gated?: string; record?: string; receipt?: string; expectHead?: string; ref?: string; noFetch?: boolean; gateExit?: number; gateLog?: string } /** Rebuild a gate-ahead record's gated tree from the PR's own commits: merge-tree, then the recorded stamp patch. */ function reproduceGateAhead(ctx: QueueContext, policy: LoadedPolicy, rec: AttemptRecord, head: string): QueueFailure | { gatedCommit: string; lines: string[] } { @@ -341,7 +374,14 @@ export function verify(ctx: QueueContext, o: VerifyOptions): StepOutput { if (!sha) return fail({ code: 'QUEUE_PROOF_MISSING', message: `gated head ${o.gated} is not reachable from ${remote}; a gate-ahead claim needs --record` }, [], run); gatedCommit = sha; gatedLabel = sha; - lines.push(`gate: ${policy.policy.full_e2e ? 'lane-reported (a plain stamp carries no gate record to check)' : 'not required by the policy'}`); + if (o.gateExit === undefined) { + if (policy.policy.full_e2e) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no gate result is bound to the gated head ${sha.slice(0, 12)}; pass the lane's --gate-exit 0 --gate-log from its gate run there, or verify a gate-ahead --record` }, [], run); + lines.push('gate: not required by the policy'); + } else { + if (!o.gateLog || !fs.existsSync(o.gateLog) || !fs.statSync(o.gateLog).size) return fail({ code: 'QUEUE_GATE_MISSING', message: `--gate-exit needs --gate-log , the non-empty log of the gate run on ${sha.slice(0, 12)}` }, [], run); + if (o.gateExit !== 0) return fail({ code: 'QUEUE_GATE_MISSING', message: `the gate on ${sha.slice(0, 12)} exited ${o.gateExit}` }, [], run); + lines.push(`gate: exit 0 log_sha256=${sha256(fs.readFileSync(o.gateLog))} (lane run on gated ${sha.slice(0, 12)})`); + } } const rcpt = treeReceipt(ctx, policy, gatedCommit, head); if ('message' in rcpt) return fail(rcpt, lines, run); diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 92a59a864..2422c2ae7 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -10,7 +10,7 @@ * (pushed-head, CI-at-head and pregate refusals) with an offline gh. */ import { afterEach, describe, expect, test } from 'bun:test'; -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; @@ -22,7 +22,8 @@ import { BASE_POLICY, BIN, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToM import { coordinatorClone, ghQueueShim } from './helpers/ship-queue-fixture'; const repos: FixtureRepo[] = []; -const fixture = (policy?: Record) => { const r = makeFixtureRepo(policy ? { policy } : {}); repos.push(r); return r; }; +const fixture = (policy?: Record, prefix?: string) => { const r = makeFixtureRepo({ ...(policy ? { policy } : {}), ...(prefix ? { prefix } : {}) }); repos.push(r); return r; }; +const FULL_E2E = { ...BASE_POLICY, full_e2e: true, gate_command: 'bun run full-gate', min_gstack_version: SHIP_QUEUE_KEYS_VERSION }; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); const queue = (repo: FixtureRepo, args: string[], extraEnv: Record = {}, cwd = repo.work) => runBin(repo, 'gstack-ship-queue', [...args, '--base', 'main'], extraEnv, cwd); @@ -122,8 +123,8 @@ describe('stamp journal, resume and refusals', () => { }); /** #1 stamped and published; #2 branched with its own [Unreleased] entry. */ -function twoLanes(policy?: Record) { - const repo = fixture(policy); +function twoLanes(policy?: Record, prefix?: string) { + const repo = fixture(policy, prefix); makePr(repo, 'pr-a', { expects: '1.2.4.0' }); expect(restamp(repo, ['--next', '--pr', '1']).status).toBe(EXIT.ok); repo.git('commit', '-qam', 'stamp a'); @@ -299,3 +300,91 @@ describe('receipt digest', () => { expect(receiptDigest(receipt(files, { new_version: '1.2.5.0' }))).not.toBe(d); }); }); + +describe('full_e2e gate evidence (review #3235 P1-1)', () => { + test('verify --gated refuses without a passing gate bound to the gated head; the lane run passes, a failing one refuses', () => { + const repo = fixture(FULL_E2E); + const head = makePr(repo, 'feature'); + publish(repo, '1'); + const coord = coordinatorClone(repo); + const bare = queue(repo, ['verify', '--pr', '1', '--gated', head, '--expect-head', head], {}, coord); + expect(bare.status).toBe(EXIT.refused); + expect(bare.err).toMatch(/full_e2e is set and no gate result is bound to the gated head .*\(QUEUE_GATE_MISSING\)/); + expect(bare.out).not.toContain('MERGE_CONDITION'); + const log = path.join(repo.root, 'gate.log'); + fs.writeFileSync(log, 'FAIL 1\n'); + const red = queue(repo, ['verify', '--pr', '1', '--gated', head, '--gate-exit', '1', '--gate-log', log], {}, coord); + expect(red.status).toBe(EXIT.refused); + expect(red.err).toMatch(/the gate on [0-9a-f]{12} exited 1; .*\(QUEUE_GATE_MISSING\)/); + expect(queue(repo, ['verify', '--pr', '1', '--gated', head, '--gate-exit', '0'], {}, coord).err).toContain('(QUEUE_GATE_MISSING)'); + fs.writeFileSync(log, 'all passed\n'); + const ok = queue(repo, ['verify', '--pr', '1', '--gated', head, '--expect-head', head, '--gate-exit', '0', '--gate-log', log], {}, coord); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toMatch(/^gate: exit 0 log_sha256=[0-9a-f]{64} \(lane run on gated [0-9a-f]{12}\)$/m); + expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${head}`); + }, 180_000); + + test('ready refuses with QUEUE_GATE_MISSING and a gate-evidence: missing line; the lane run on that head clears it', () => { + const repo = fixture(FULL_E2E); + const head = makePr(repo, 'feature'); + 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: '' } }, checks: { [head]: { check_runs: [{ status: 'completed', conclusion: 'success' }] } } }); + const missing = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh); + expect(missing.status).toBe(EXIT.refused); + expect(missing.out).toContain(`gate-evidence: missing for ${head.slice(0, 12)}`); + expect(missing.err).toContain('(QUEUE_GATE_MISSING)'); + const log = path.join(repo.root, 'gate.log'); + fs.writeFileSync(log, 'all passed\n'); + const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregate, '--gate-exit', '0', '--gate-log', log], gh); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toMatch(new RegExp(`^gate-evidence: exit 0 log_sha256=[0-9a-f]{64} \\(lane run on ${head.slice(0, 12)}\\)$`, 'm')); + }, 180_000); +}); + +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) { + 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: '' } }, checks: { [head]: checks }, ...(statuses ? { statuses: { [head]: statuses } } : {}) }); + return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) }; + } + + test('a failed check run on page two is red', () => { + const { r, at } = readyWith({ pages: [{ total_count: 101, check_runs: hundred }, { total_count: 101, check_runs: [{ status: 'completed', conclusion: 'failure' }] }] }); + expect(r.status).toBe(EXIT.refused); + expect(line(r.out, 'ci: ')).toBe(`ci: red at ${at} (passed=100 failed=1 pending=0 skipped=0)`); + }, 120_000); + + test('a failed or pending commit-status context is not green even when every check run passed', () => { + const red = readyWith({ check_runs: [pass] }, { state: 'failure', statuses: [{ context: 'required-deploy', state: 'failure' }] }); + expect(red.r.status).toBe(EXIT.refused); + expect(line(red.r.out, 'ci: ')).toBe(`ci: red at ${red.at} (passed=1 failed=1 pending=0 skipped=0)`); + const pending = readyWith({ check_runs: [pass] }, { state: 'pending', statuses: [{ context: 'legacy', state: 'pending' }] }); + expect(line(pending.r.out, 'ci: ')).toBe(`ci: pending at ${pending.at} (passed=1 failed=0 pending=1 skipped=0)`); + }, 120_000); + + test('incomplete pagination is unknown, never green', () => { + const { r, at } = readyWith({ total_count: 101, check_runs: hundred }); + expect(r.status).toBe(EXIT.refused); + expect(line(r.out, 'ci: ')).toBe(`ci: unknown at ${at} (check_runs: read 100 of total_count=101)`); + }, 120_000); +}); + +describe('printed gate-ahead command (review #3235 P2-1)', () => { + test("next: runs as printed from a checkout whose path holds a space and an apostrophe", () => { + const { repo } = twoLanes({ ...BASE_POLICY, gate_command: 'node -e "process.exit(0)"', min_gstack_version: SHIP_QUEUE_KEYS_VERSION }, "gstack sq o'fx-"); + const p = prepare(repo); + expect(p.status).toBe(EXIT.ok); + const next = line(p.out, 'next: ').slice(6); + const r = spawnSync('bash', ['-c', next], { cwd: repo.work, encoding: 'utf8', timeout: 120_000, env: { ...process.env, ...repo.env, PATH: `${path.dirname(BIN('gstack-ship-queue'))}:${process.env.PATH ?? ''}` } }); + expect(r.stderr).toBe(''); + expect(line(r.stdout, 'GATE_RECORD:')).toMatch(new RegExp(`^GATE_RECORD: pr=2 attempt=${p.attempt} exit=0 `)); + }, 180_000); +}); diff --git a/test/helpers/ship-queue-fixture.ts b/test/helpers/ship-queue-fixture.ts index a1d9f55dd..0051fd548 100644 --- a/test/helpers/ship-queue-fixture.ts +++ b/test/helpers/ship-queue-fixture.ts @@ -76,14 +76,21 @@ export function gbrainPairRepo(): { repo: FixtureRepo; pair: GbrainPair; gated: } /** A `gh` on PATH answering `gh pr view --json …` and `gh api …/commits//check-runs…` from fixed data, so ready runs offline. */ -export function ghQueueShim(repo: FixtureRepo, data: { prs: Record; checks: Record }): { PATH: string } { +/** + * 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. + */ +export function ghQueueShim(repo: FixtureRepo, data: { prs: Record; checks: Record; statuses?: 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)); const js = [ "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); }", - "if (a[0] === 'api') { const m = /commits\\/([0-9a-f]+)\\/check-runs/.exec(a[1] || ''); const c = m && d.checks[m[1]]; if (!c) { console.error('HTTP 404'); process.exit(1); } console.log(JSON.stringify(c)); 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); }", "console.error('gh shim: unsupported ' + a.join(' ')); process.exit(1);", ].join('\n'); fs.writeFileSync(path.join(dir, 'shim.js'), js);