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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-17 01:22:58 +00:00
1 parent 9671522f80
commit aa8a1195b4
6 files changed
+179 -36

No files matched your search

+14 -9
View File
@@ -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 <n> [--pregate <pregate.json>]
* gstack-ship-queue ready --pr <n> [--pregate <pregate.json>] [--gate-exit <rc> --gate-log <file>]
* gstack-ship-queue stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>
* [--expect-order <pr,...>] [--allow-repo-commands] [--gate-exit <rc> --gate-log <file>]
* gstack-ship-queue verify --pr <n> (--gated <sha> | --record <gate-ahead.json>) [--receipt <sha256>]
* [--expect-head <sha>] [--ref <ref>] [--no-fetch]
* [--expect-head <sha>] [--ref <ref>] [--no-fetch] [--gate-exit <rc> --gate-log <file>]
* gstack-ship-queue gate-ahead --after <pr> --pr <n> [--predecessor-ref <ref>] [--allow-repo-commands]
* gstack-ship-queue gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
* gstack-ship-queue gate-ahead --list --pr <n>
@@ -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 <n> [--pregate <file>] READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state
ready --pr <n> [--pregate <file>] [--gate-exit <rc> --gate-log <file>]
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state
stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha> [--expect-order <pr,...>] [--allow-repo-commands]
[--gate-exit <rc> --gate-log <file>]
restamp, commit and prove: QUEUE_STAMP: <pr> <X> head= gated= tree= receipt=
verify --pr <n> --gated <sha> | --record <file> [--receipt <sha256>] [--expect-head <sha>] [--ref <ref>] [--no-fetch]
[--gate-exit <rc> --gate-log <file>] (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> --pr <n> [--predecessor-ref <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 <number>`);
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 <X> --expect-gated <sha> --expect-base <sha>');
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}`);
+5 -3
View File
@@ -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 <file>` 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 <gated> --receipt <sha256> --expect-head <head>
gstack-ship-queue verify --pr 42 --gated <gated> --receipt <sha256> --expect-head <head> [--gate-exit 0 --gate-log <file>]
gstack-ship-queue verify --pr 42 --record <gate-ahead record> --receipt <sha256> --expect-head <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=<sha>
gated=<sha> tree=same-modulo-stamps receipt=<same sha256>`. With a gate-ahead
gated=<sha> tree=same-modulo-stamps receipt=<same sha256>`. Under `full_e2e`,
`--gated` also needs the lane's passing gate log from its run on that head
(`--gate-exit 0 --gate-log <file>`), 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 <branch head> <predecessor head>`
and compares it with the recorded merge tree, rebuilds the gated tree from
+1 -1
View File
@@ -111,7 +111,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
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).',
};
+57 -17
View File
@@ -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<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}`]);
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 <file>)` };
}
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 <file> 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 <file>, 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);
+93 -4
View File
@@ -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<string, unknown>) => { const r = makeFixtureRepo(policy ? { policy } : {}); repos.push(r); return r; };
const fixture = (policy?: Record<string, unknown>, 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<string, string> = {}, 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<string, unknown>) {
const repo = fixture(policy);
function twoLanes(policy?: Record<string, unknown>, 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);
});
+9 -2
View File
@@ -76,14 +76,21 @@ export function gbrainPairRepo(): { repo: FixtureRepo; pair: GbrainPair; gated:
}
/** A `gh` on PATH answering `gh pr view <n> --json …` and `gh api …/commits/<sha>/check-runs…` from fixed data, so ready runs offline. */
export function ghQueueShim(repo: FixtureRepo, data: { prs: Record<string, unknown>; checks: Record<string, unknown> }): { 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<string, unknown>; checks: Record<string, unknown>; statuses?: 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));
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);