From e4b1e3455230a958cc53667e920994a9747d440c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 6 Nov 2026 15:55:54 -0800 Subject: [PATCH] fix(ship): a cleared remote gate for HEAD's exact tree satisfies a lane whose inventory refuses that tree (GST-1688) (#4098) * fix(ship): a cleared remote gate for HEAD's exact tree satisfies a lane whose inventory refuses that tree (GST-1688) When a ship_lanes_from inventory refuses HEAD's tree (inventory_failed) and .gstack/tmp/pregate.json verifies for that exact tree with the remote gate's own report for it, record-ci --remote-gate-stands-in records the lane as source=remote_gate and accept prints a CI_CARRY: remote_gate line naming the receipt. A gate for any other tree still stops, with a CARRY_DIAGNOSIS line. A run whose tree.json lists no files now says to dispatch a fresh full run. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep ship-accept-remote-gate-lanes off the Windows scoped shard (bin shebang spawns; run 42302497169) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review-Finding: p2/specialist-batched#1 reader=red-team Review-Finding: p2/specialist-batched#2 reader=testing Review-Finding: p2/specialist-batched#3 reader=testing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: remote-gate stand-in requires origin/ provenance and a clear within 24h (review fix-check) Review-Finding: p4/outside-voice#2 reader=outside-voice Review-Finding: p4/adversarial-native#4 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-evidence | 37 +++- .../capy-gst-1688-remote-gate-stands-in.md | 1 + docs/pregate.md | 40 ++++ lib/ci-carry.ts | 10 +- lib/remote-gate-stand-in.ts | 39 ++++ lib/ship-bin-common.ts | 18 +- lib/ship-release.ts | 6 +- scripts/lib/windows-curation.ts | 4 + test/ship-accept-remote-gate-lanes.test.ts | 196 ++++++++++++++++++ 9 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 changelog.d/capy-gst-1688-remote-gate-stands-in.md create mode 100644 lib/remote-gate-stand-in.ts create mode 100644 test/ship-accept-remote-gate-lanes.test.ts diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 111766d59..d8ac06fd1 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -4,7 +4,7 @@ * IRON LAW ("no completion claims without fresh verification evidence"). * * gstack-evidence run [--reuse [--max-age ]] --label -- - * gstack-evidence record-ci --label --base -- + * gstack-evidence record-ci --label --base [--remote-gate-stands-in] -- * gstack-evidence passed -- * gstack-evidence carried [--base ] [--json] * gstack-evidence check [--label [--expect-cmd ]]... | --all @@ -72,7 +72,11 @@ * mapped it (`origin/@:…`); `carried` lists those rows at the * current tree for the ship receipt, only when that policy is origin/ (GST-1190: a row a run against another base wrote never - * counts). + * counts). With --remote-gate-stands-in (gstack-ship-release's lanes), a lane + * whose inventory command refuses HEAD's tree (inventory_failed) is recorded + * as source "remote_gate" when the cleared pregate.json/remote_gate verifies + * for that exact tree (lib/remote-gate-stand-in.ts, GST-1688); `carried` + * never lists it, since no CI run carried it. * * MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced. * A synced record citing an unsynced log would grade FRESH on a machine where @@ -125,8 +129,10 @@ interface EvidenceRecord { identity?: string; /** The original ts of the row a `run --reuse` copied. */ reused_from?: string; - /** "ci" for a lane CI carried (record-ci); absent for a local run. */ - source?: "ci"; + /** "ci" for a lane CI carried (record-ci); "remote_gate" for a lane whose inventory refused HEAD's tree and the cleared remote gate for that tree stood in (record-ci --remote-gate-stands-in, GST-1688); absent for a local run. */ + source?: "ci" | "remote_gate"; + /** The pregate.json/remote_gate receipt that stood in for the lane (source "remote_gate"). */ + remote_gate?: string; runs?: string[]; profiles?: string[]; /** The CI jobs a jobs-mapped lane carried from (record-ci, GST-1189); tests_ran is then "n/a". */ @@ -720,7 +726,8 @@ async function cmdRecordCi(argv: string[]): Promise { const label = raw === undefined ? undefined : ledgerLabel(raw); const base = get("--base"); const command = sep < 0 ? "" : argv.slice(sep + 1).join(" "); - if (!label || !base || !command || opts.length !== 4) { + const standIn = opts.includes("--remote-gate-stands-in"); + if (!label || !base || !command || opts.length !== (standIn ? 5 : 4)) { console.error("usage: gstack-evidence record-ci --label --base -- "); return 2; } @@ -746,6 +753,24 @@ async function cmdRecordCi(argv: string[]): Promise { const atHead = grants.filter((g) => g.head === head); const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd), ...(atHead.length ? { excepted: atHead } : {}) }); const elsewhere = grants.filter((g) => g.head !== head).map((g) => `${g.approval} at ${g.head.slice(0, 12)}`); + if (!v.ok && v.reason === "inventory_failed" && standIn) { + const { remoteGateStandIn } = await import("../lib/remote-gate-stand-in"); + const gate = remoteGateStandIn(git(["rev-parse", "--show-toplevel"]) ?? cwd, tree, base); + if (!gate.ok) return none(v.reason, v.detail, [`no cleared pregate.json/remote_gate stands in: ${gate.why}`]); + let gatePaths: ReturnType; + try { + gatePaths = ledgerPath(); + mkdirpSync(gatePaths.dir); + } catch (e: any) { + console.error(`gstack-evidence record-ci: ledger setup failed (${e?.message ?? e}); nothing recorded`); + return 1; + } + const row: EvidenceRecord = { ts: gate.clearedAt, label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0, commit: head, tree, dirty: false, wtree: tree, tests_ran: gate.tests, source: "remote_gate", remote_gate: gate.receipt, policy: loaded.label }; + appendJsonl(gatePaths.file, row, { mode: 0o600 }); + console.error(`CARRY_STANDIN: ${v.reason}: ${v.detail}`); + console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${gate.tests} log=- source=remote_gate receipt=${gate.receipt}`); + return 0; + } if (!v.ok) return none(v.reason, `${v.detail}${v.reason === "inventory_uncovered" && !atHead.length && elsewhere.length ? `; test-lane exception ${elsewhere.join(", ")} binds another head, not HEAD ${head.slice(0, 12)}` : ""}`, v.diagnosis); let exception: BoundLaneException | undefined; const grant = atHead.find((g) => g.approval === v.exceptedBy); @@ -959,7 +984,7 @@ function cmdCheck(argv: string[]): number { continue; } const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown"; - const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : ""; + const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : latest.source === "remote_gate" ? " source=remote_gate" : ""; const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan}${latest.tests_unit === "files" ? " unit=files" : ""} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`; const { verdict, reason } = gradeRecord(latest, { maxAgeHours, expectCmd, allowPaths, wtreeNow }); console.log(`EVIDENCE: ${verdict} ${detail}${reason ? ` reason=${reason}` : ""}`); diff --git a/changelog.d/capy-gst-1688-remote-gate-stands-in.md b/changelog.d/capy-gst-1688-remote-gate-stands-in.md new file mode 100644 index 000000000..c4deabf41 --- /dev/null +++ b/changelog.d/capy-gst-1688-remote-gate-stands-in.md @@ -0,0 +1 @@ +- **A cleared remote gate now satisfies a /ship lane whose inventory refuses its own tree.** gbrain's lane inventory refuses, on purpose, any tree that changes its CI-reuse files, so `gstack-ship-release accept` stopped with `CI_CARRY: none label=unit (inventory_failed: …)` even after `gstack-pregate --remote` had cleared the identical tree, and every such PR needed `--no-lanes-approved` plus a manual approval (GBRA-345). Now, when the carry refuses as `inventory_failed` and `.gstack/tmp/pregate.json` verifies for HEAD's exact tree with the remote gate's own report for that tree (tests above 0, not a stamp-only substitution), that receipt satisfies the lane: accept prints `CI_CARRY: remote_gate label= receipt=…` naming the substitution and records the lane as `source=remote_gate` in the evidence ledger. A gate cleared for any other tree, or a receipt naming another tree, still stops, with a `CARRY_DIAGNOSIS:` line saying why. A CI run whose `tree.json` lists no files (a rerun of only the failed jobs) now says to dispatch a fresh full run instead of a bare `no_tests`. (GST-1688) diff --git a/docs/pregate.md b/docs/pregate.md index 1214531c4..d94299f81 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -1134,6 +1134,46 @@ different environment than the key. An alias that repeats a key or another entry's alias is invalid config. The ledger row binds the command the lane actually ran. +**A lane whose inventory refuses its own tree.** A repo's inventory command +may refuse a tree on purpose: gbrain's `runs_may_attest` exits 1 for a tree +that changes CI-reuse files (its workflows, `scripts/ci-reuse.ts`), so no CI +run can attest to it (GBRA-345, gbrain #8727). When the carry refuses as +`inventory_failed`, /ship's lanes (finish, accept and Step 5, through +`gstack-evidence record-ci --remote-gate-stands-in`) look for a cleared remote +gate for the same tree, and it stands in for the lane only when every one of +these holds (`lib/remote-gate-stand-in.ts`, GST-1688): + +- `.gstack/tmp/pregate.json` was recorded for HEAD's exact tree and + `gstack-pregate verify` accepts it now; +- its `pregate.json/remote_gate` obligation holds the gate's own report, + `remote-gate tree= tests=` with n above 0 (from + `gstack-pregate --remote` or `clear --receipt`), run on that tree rather than + accepted from a stamp-only predecessor, cleared within the last 24 hours; +- that record's `base` and `policy` both name `origin/@`: the pregate + run was against the same base branch, under that branch's own + `.gstack/pregate.json`, the policy whose `ship_lanes_from` maps the lane. + +Then the ledger records the lane as `source=remote_gate` with that receipt and +the gate's test count, timed at the clear, and accept prints +`CI_CARRY: remote_gate label= receipt= (inventory_failed: …; the +cleared pregate.json/remote_gate for this exact tree stands in)` and `LANE: +satisfied by remote_gate receipt=`. The receipt's `ci_carried` never +lists it, since no CI run carried it. Otherwise the lane stops as before, and a +`CARRY_DIAGNOSIS: no cleared pregate.json/remote_gate stands in: ` line +says which rule failed: a gate for an earlier tree, a receipt that names +another tree, a record `verify` refuses, or none at all. Other refusals +(`inventory_uncovered`, `no_tests`, …) never fall back to the gate. + +One limit: the record cannot say whether the gate ran the whole suite or only +the files pregate selected and handed it (`PREGATE_FILES_FILE`; gbrain's gate +runs its whole corpus either way). The receipt carries a test count, not a file +list, so a gate that ran only the selection can stand in for a full-suite lane. + +A run whose `tree.json` lists no files, which is what a rerun of only the +failed jobs uploads, refuses as `no_tests` and says to dispatch a fresh full +run of the profile's workflow (`gh workflow run --ref `) +and carry from that. + **What `tests_ran` means on a carried lane.** No carried lane reports the whole run's count. A lane mapped by `inventory` reports how many of its inventory files the runs' `tree.json` covers, labeled as a file count (`tests_ran=14 diff --git a/lib/ci-carry.ts b/lib/ci-carry.ts index 783ba148e..aed0a9233 100644 --- a/lib/ci-carry.ts +++ b/lib/ci-carry.ts @@ -92,7 +92,7 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string; const other = runs.find(e => e.tree !== o.tree); if (other) return no('tree_mismatch', `run ${other.run_id} tested tree ${other.tree.slice(0, 12)}, not ${o.tree.slice(0, 12)}`); const empty = jobs.length ? undefined : runs.find(e => !e.files.length || e.tests === 0); - if (empty) return no('no_tests', `run ${empty.run_id}'s tree.json ${empty.files.length ? 'records 0 tests' : 'lists no test files'}`); + if (empty) return no('no_tests', `run ${empty.run_id}'s tree.json ${empty.files.length ? 'records 0 tests' : `lists no test files (a rerun of only the failed jobs records none): dispatch a fresh full run of ${src.profiles[empty.profile]?.workflow ?? empty.profile} (gh workflow run ${src.profiles[empty.profile]?.workflow ?? ''} --ref ) and carry from that`}`); if (o.inventory) { const listed = o.inventory(); if ('error' in listed) return no('inventory_failed', listed.error); @@ -199,10 +199,14 @@ export function importedTestLines(cwd: string, missing: string[], candidates: st /** * What `gstack-evidence record-ci` reported, read from its own last record line onward (the per_file remainder's output comes * first, so a test printing look-alike lines cannot change the reading): the carried runs, count, unit and remainder, any - * not-owed reason, or the last CARRY_NONE reason. + * not-owed reason, or the last CARRY_NONE reason. A lane the cleared remote gate stood in for (--remote-gate-stands-in, GST-1688) + * reads as its receipt, its count and why the CI carry refused. */ -export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; exception?: string; none?: string; diagnosis?: string[] } { +export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; exception?: string; none?: string; diagnosis?: string[]; remoteGate?: string; standIn?: string } { const lines = stderr.split('\n'); + const last = lines.findLast(x => x.startsWith('gstack-evidence: recorded label=')); + const gateRow = last && / tests_ran=(\d+) log=- source=remote_gate receipt=(.+)$/.exec(last); + if (gateRow) return { tests: gateRow[1], remoteGate: gateRow[2], standIn: lines.findLast(x => x.startsWith('CARRY_STANDIN: '))?.slice('CARRY_STANDIN: '.length) }; const at = lines.findLastIndex(x => x.startsWith('gstack-evidence: recorded label=') && x.includes(' log=- source=ci runs=')); const noneAt = lines.findLastIndex(x => x.startsWith('CARRY_NONE: ')); const none = noneAt < 0 ? undefined : lines[noneAt]!.slice('CARRY_NONE: '.length); diff --git a/lib/remote-gate-stand-in.ts b/lib/remote-gate-stand-in.ts new file mode 100644 index 000000000..e833eb31c --- /dev/null +++ b/lib/remote-gate-stand-in.ts @@ -0,0 +1,39 @@ +/** + * GST-1688: a cleared pregate.json/remote_gate stands in for a /ship lane whose ship_lanes_from inventory refuses HEAD's own + * tree (gbrain's runs_may_attest refuses, on purpose, a tree that changes CI-reuse files). It stands in only when the + * repository's pregate.json was recorded for HEAD's exact tree and verifies (gstack-pregate verify), and its remote_gate + * receipt is the gate's own report for that same tree: `remote-gate tree= tests=`, n > 0, run on that tree + * rather than accepted from a stamp-only predecessor, cleared within the evidence max age, by a pregate run against + * origin/ under origin/'s own policy (the one whose ship_lanes_from maps the lane, so the gate command is that + * policy's). Anything else returns why, and the lane stops as before. + */ +import * as path from 'node:path'; +import { REMOTE_GATE_LANE } from './pregate/checks/lanes'; +import { PREGATE_OUT_REL, readPregateFile, verifyPregate } from './pregate/index'; +import { DEFAULT_MAX_AGE_HOURS } from './run-evidence'; + +export type RemoteGateStandIn = { ok: true; receipt: string; tests: number; clearedAt: string } | { ok: false; why: string }; + +export function remoteGateStandIn(repoRoot: string, tree: string, base: string, now = Date.now()): RemoteGateStandIn { + const short = tree.slice(0, 12); + const baseRef = `origin/${base.replace(/^origin\//, '')}`; + const { file } = readPregateFile(path.join(repoRoot, PREGATE_OUT_REL)); + if (!file) return { ok: false, why: `no readable ${PREGATE_OUT_REL}` }; + if (file.tree !== tree) return { ok: false, why: `${PREGATE_OUT_REL} was recorded for tree ${file.tree.slice(0, 12)}, not HEAD's tree ${short}` }; + if (!file.base?.startsWith(`${baseRef}@`)) return { ok: false, why: `${PREGATE_OUT_REL} was recorded against ${file.base ?? 'no base'}, not ${baseRef}` }; + if (!file.policy?.startsWith(`${baseRef}@`)) return { ok: false, why: `${PREGATE_OUT_REL} ran under policy ${file.policy ?? 'unrecorded'}, not ${baseRef}'s .gstack/pregate.json` }; + const verified = verifyPregate(repoRoot, file); + if (!verified.ok) return { ok: false, why: `gstack-pregate verify refuses it (${verified.code}: ${verified.message})` }; + const gate = file.requires_remote?.find(r => r.lane === REMOTE_GATE_LANE); + if (!gate?.receipt) return { ok: false, why: `${PREGATE_OUT_REL} holds no cleared ${REMOTE_GATE_LANE}` }; + if (gate.substitution) return { ok: false, why: `its receipt was accepted from gated tree ${gate.substitution.gated_tree.slice(0, 12)}, not run on ${short}` }; + const report = /^remote-gate tree=([0-9a-f]{12}) tests=(\d+)(?: |$)/.exec(gate.receipt); + if (!report) return { ok: false, why: `its receipt (${gate.receipt}) is not the remote gate's own report` }; + if (report[1] !== short) return { ok: false, why: `its receipt names tree ${report[1]}, not HEAD's tree ${short}` }; + if (Number(report[2]) <= 0) return { ok: false, why: 'its receipt reports 0 tests' }; + const clearedAt = /(\S+)$/.exec(gate.cleared_by ?? '')?.[1]; + if (!clearedAt || Number.isNaN(Date.parse(clearedAt))) return { ok: false, why: `its clear records no time (cleared_by: ${gate.cleared_by ?? 'none'})` }; + const age = now - Date.parse(clearedAt); + if (!(age >= 0 && age <= DEFAULT_MAX_AGE_HOURS * 3600_000)) return { ok: false, why: `its clear at ${clearedAt} is not within the last ${DEFAULT_MAX_AGE_HOURS}h; rerun the remote gate` }; + return { ok: true, receipt: gate.receipt, tests: Number(report[2]), clearedAt: new Date(clearedAt).toISOString() }; +} diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 0a690ac7b..0a6787a07 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -182,7 +182,7 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar return cpus <= 4 ? 1 : Infinity; } -export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; diagnosis?: string[]; notOwed?: string; here?: string; exception?: string } +export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; diagnosis?: string[]; notOwed?: string; here?: string; exception?: string; remoteGate?: string; standIn?: string } /** The base branch's `ship_lanes_from` entry for each lane whose command is a key or a declared alias, byte for byte (the ledger binds its sha256). */ export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map { @@ -196,11 +196,13 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co /** * A mapped lane carried from CI at HEAD's clean tree: gstack-evidence record-ci finds and verifies the run (lib/ci-carry.ts) - * and records the CI-sourced ledger row; or why it was not (`carryNone`), so the caller runs it. + * and records the CI-sourced ledger row; or, when the lane's inventory refuses this tree, the cleared remote gate for this exact + * tree stands in (`remoteGate`, lib/remote-gate-stand-in.ts, GST-1688); or why neither held (`carryNone`), so the caller runs it. */ export function carryLane(cwd: string, base: string, l: { label: string; command: string }, timeoutS = DEFAULT_POLICY.ci_record_timeout_s): LaneResult { - const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], timeoutS * 1000); + const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--remote-gate-stands-in', '--', l.command], timeoutS * 1000); const got = recordCiResult(rec.stderr); + if (rec.status === 0 && got.remoteGate) return { label: l.label, exit: 0, testsRan: got.tests!, log: '-', remoteGate: got.remoteGate, standIn: got.standIn ?? 'not carried' }; if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}), ...(got.exception ? { exception: got.exception } : {}) }; return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}`, ...(got.diagnosis ? { diagnosis: got.diagnosis } : {}) }; } @@ -228,7 +230,7 @@ export async function runLanes(cwd: string, list: { label: string; command: stri const l = list[i]!; const m = mapped.get(l.label); const carry = m ? carryLane(cwd, opts.base!, l) : null; - if (carry?.carried) { out[i] = carry; continue; } + if (carry?.carried || carry?.remoteGate) { out[i] = carry; continue; } const r = await binAsync(cwd, 'gstack-evidence', ['run', ...(opts.reuse === false ? [] : ['--reuse']), '--label', l.label, '--', l.command]); const rec = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr); const reused = /gstack-evidence: reused label=\S+ tree=\S+ from=(\S+)/.exec(r.stderr)?.[1]; @@ -243,10 +245,14 @@ export async function runLanes(cwd: string, list: { label: string; command: stri export function laneLine(l: LaneResult): { line: string; ok: boolean } { if (l.notOwed) return { line: `LANE: ${l.label} not owed: ${l.notOwed} run=${l.carried}`, ok: true }; if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}${l.here ? ` ${l.here}` : ''}`, ok: true }; + if (l.remoteGate) return { line: `LANE: ${l.label} satisfied by remote_gate receipt=${l.remoteGate}`, ok: true }; const zero = l.exit === 0 && l.testsRan === '0'; const state = l.exit !== 0 ? 'FAIL' : zero ? 'ZERO-RUN' : l.testsRan === 'unknown' ? 'pass (count unavailable)' : 'pass'; return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}${l.reused ? ` reused=${l.reused}` : ''}`, ok: l.exit === 0 && !zero }; } -/** The CI_CARRY line for a mapped lane that ran here instead, then a CARRY_DIAGNOSIS line per uncovered file another test file imports. */ -export const carryNoneLines = (l: LaneResult): string[] => (l.carryNone ? [`CI_CARRY: none label=${l.label} (${l.carryNone.length > 220 ? `${l.carryNone.slice(0, 220)}…` : l.carryNone})`, ...(l.diagnosis ?? []).map(d => `CARRY_DIAGNOSIS: ${d}`)] : []); +/** + * The CI_CARRY line for a mapped lane that ran here instead, then a CARRY_DIAGNOSIS line per uncovered file another test file imports + * (or why no remote gate stood in); for a lane the cleared remote gate stood in for, the line that names the substitution. + */ +export const carryNoneLines = (l: LaneResult): string[] => l.remoteGate ? [`CI_CARRY: remote_gate label=${l.label} receipt=${l.remoteGate} (${(l.standIn ?? '').length > 160 ? `${l.standIn!.slice(0, 160)}…` : l.standIn}; the cleared pregate.json/remote_gate for this exact tree stands in)`] : (l.carryNone ? [`CI_CARRY: none label=${l.label} (${l.carryNone.length > 220 ? `${l.carryNone.slice(0, 220)}…` : l.carryNone})`, ...(l.diagnosis ?? []).map(d => `CARRY_DIAGNOSIS: ${d}`)] : []); diff --git a/lib/ship-release.ts b/lib/ship-release.ts index a2041e377..bef678d7b 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -563,11 +563,12 @@ async function finishStages(argv: readonly string[], cwd: string): Promise file: 'test/ship-accept-same-tree-clears.test.ts', reason: 'drives bin/gstack-ship-release, gstack-evidence and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-accept-record-timeout.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)', }, + { + file: 'test/ship-accept-remote-gate-lanes.test.ts', + reason: 'drives bin/gstack-ship-release, gstack-evidence and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-accept-same-tree-clears.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42302497169)', + }, ]; // Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for diff --git a/test/ship-accept-remote-gate-lanes.test.ts b/test/ship-accept-remote-gate-lanes.test.ts new file mode 100644 index 000000000..cd1c91ee5 --- /dev/null +++ b/test/ship-accept-remote-gate-lanes.test.ts @@ -0,0 +1,196 @@ +/** + * GST-1688 (GBRA-345, gbrain #8727): gbrain maps every /ship lane through ship_lanes_from, and its inventory command + * refuses on purpose for a tree that changes CI-reuse files (runs_may_attest, exit 1). accept then stopped with + * CI_CARRY: none label=unit (inventory_failed: …) although a cleared pregate.json/remote_gate stood for the identical + * tree (PREGATE_REMOTE: tree=… tests=6482 failed=0, PREGATE_VERIFY: ok). That receipt now satisfies the mapped lane and + * accept names the substitution; a receipt for any other tree still stops. + */ +import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { recordCiResult } from '../lib/ci-carry'; +import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs'; +import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; + +setDefaultTimeout(180_000); +const fixtures: ShipFx[] = []; +afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); }); + +const BIN = path.dirname(RELEASE); +const UNIT = "printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2"; +const HOST_OS = ({ darwin: 'macOS', win32: 'Windows' } as Record)[process.platform] ?? 'Linux'; +const RUNNER = ({ darwin: 'macos-latest', win32: 'windows-latest' } as Record)[process.platform] ?? 'ubuntu-latest'; +const TESTS = ['test/a.test.ts']; +const REFUSE = "echo 'runs_may_attest: refused: this tree changes CI-reuse files (.github/workflows/free.yml)' >&2; exit 1"; +const BASE = { + '.gstack/pregate.json': JSON.stringify({ + run_profiles: { 'ci/free': { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: HOST_OS } }, + ship_lanes_from: { [UNIT]: { profiles: ['ci/free'], inventory: REFUSE } }, + remote_gate: 'sh scripts/remote-gate.sh', + dependencies: { 'src/a.ts': ['test/a.test.ts'], 'src/b.ts': ['test/a.test.ts'], 'test/**': [], 'scripts/**': [], '.gstack/ship-policy.json': [] }, + }, null, 2) + '\n', + 'scripts/remote-gate.sh': 'printf "PREGATE_REMOTE: tree=%s tests=6482 failed=0\\n" "$PREGATE_TREE"\n', + '.github/workflows/free.yml': `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ${RUNNER}\n steps:\n - run: bun test\n`, + 'test/a.test.ts': "import { expect, test } from 'bun:test';\ntest('ok', () => expect(1).toBe(1));\n", +}; + +/** A pushed branch whose free.yml run is green at HEAD (CI ran its tests; the lane's inventory refuses this tree). */ +function pushed(head: Record = { 'src/b.ts': 'export const b = 2;\n' }): ShipFx { + const f = shipFixture(BASE, head); + fixtures.push(f); + world(f); + expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0); + return f; +} +function world(f: ShipFx, files: string[] = TESTS): void { + const sha = f.git('rev-parse', 'HEAD').stdout.trim(); + const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim(); + const at = (m: number) => new Date(Date.now() - m * 60_000).toISOString(); + const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: sha, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } }; + const w: FakeWorld = { runs: [run] as FakeWorld['runs'], jobs: { 7: [{ name: 'suite', conclusion: 'success', completed_at: at(5) }] }, trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha, tree, files, tests: files.length ? 1 : 0, os: HOST_OS } } }; + fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w)); + f.gh(fakeGhScript(path.join(f.root, 'world.json'))); +} +/** The remote gate for the checked-out tree, as gbrain ran it: gstack-pregate --remote, then verify. */ +function gate(f: ShipFx): string { + const r = f.run(path.join(BIN, 'gstack-pregate'), ['--base', 'main', '--stage', 'tests', '--remote', '--no-ci-carry', '--allow-repo-commands', '--spend-cap', '5']); + expect(r.status, r.stdout + r.stderr).toBe(0); + const v = f.run(path.join(BIN, 'gstack-pregate'), ['verify']); + expect(v.stdout, v.stdout + v.stderr).toStartWith('PREGATE_VERIFY: ok '); + return JSON.parse(fs.readFileSync(path.join(f.work, '.gstack/tmp/pregate.json'), 'utf8')).requires_remote.find((o: { lane: string }) => o.lane === 'pregate.json/remote_gate').receipt; +} +const accept = (f: ShipFx) => f.run(RELEASE, ['accept', '--base', 'main', '--lane', `unit=${UNIT}`, '--wait', '0']); + +describe('accept: a cleared remote gate for the identical tree satisfies a lane whose inventory refuses that tree (GST-1688)', () => { + // Value: protects=gbrain #8727's shape ships without --no-lanes-approved: the inventory refuses its own tree on purpose, and the + // cleared remote gate for that exact tree carries the lane, with a CI_CARRY line naming the substitution; fails_when=accept still + // stops on inventory_failed, or carries without naming the receipt; seam=gstack-evidence record-ci under gstack-ship-release accept + test('inventory_failed plus a verified remote_gate receipt for the same tree: the lane is satisfied and accept says by what', () => { + const f = pushed(); + const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim(); + const receipt = gate(f); + expect(receipt).toBe(`remote-gate tree=${tree.slice(0, 12)} tests=6482`); + const out = accept(f); + expect([out.status, lineOf(out.stdout, 'CI_CARRY'), lineOf(out.stdout, 'LANE'), lineOf(out.stdout, 'EVIDENCE')], out.stdout + out.stderr).toEqual([0, + expect.stringMatching(new RegExp(`^CI_CARRY: remote_gate label=unit receipt=remote-gate tree=${tree.slice(0, 12)} tests=6482 \\(inventory_failed: .*runs_may_attest: refused.*; the cleared pregate\\.json/remote_gate for this exact tree stands in\\)$`)), + `LANE: unit satisfied by remote_gate receipt=remote-gate tree=${tree.slice(0, 12)} tests=6482`, + expect.stringMatching(/^EVIDENCE: FRESH label=unit /)]); + expect(out.stdout).not.toContain('STOP'); + const carried = f.run(path.join(BIN, 'gstack-evidence'), ['carried', '--base', 'main', '--json']); + expect(JSON.parse(carried.stdout)).toEqual([]); + }); + + // Value: protects=tree identity: a remote gate cleared for an earlier tree never stands in for HEAD's; fails_when=the substitution + // reads the receipt without binding it to HEAD's tree; seam=gstack-evidence record-ci + test('negative control: a remote_gate cleared for a different tree still stops on inventory_failed', () => { + const f = pushed(); + gate(f); + f.write({ 'src/b.ts': 'export const b = 3;\n' }); f.commit('a change after the remote gate'); + world(f); + expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0); + const out = accept(f); + expect(out.status).not.toBe(0); + expect(lineOf(out.stdout, 'CI_CARRY'), out.stdout).toMatch(/^CI_CARRY: none label=unit \(inventory_failed: .*runs_may_attest: refused/); + expect(out.stdout).toMatch(/STOP: lane unit was not carried from the green run/); + expect(lineOf(out.stdout, 'CARRY_DIAGNOSIS')).toMatch(/^CARRY_DIAGNOSIS: no cleared pregate\.json\/remote_gate stands in: \.gstack\/tmp\/pregate\.json was recorded for tree [0-9a-f]{12}, not HEAD's tree [0-9a-f]{12}$/); + }); + + // Value: protects=tree identity inside the record: a receipt naming another tree in a pregate.json that verifies for HEAD's tree + // (hand-edited) stands in for nothing; fails_when=the substitution trusts verify alone; seam=gstack-evidence record-ci + test('negative control: a remote_gate receipt that names another tree stops, even when pregate.json is HEAD\'s', () => { + const f = pushed(); + gate(f); + const file = path.join(f.work, '.gstack/tmp/pregate.json'); + const rec = JSON.parse(fs.readFileSync(file, 'utf8')); + for (const o of rec.requires_remote) if (o.lane === 'pregate.json/remote_gate') o.receipt = 'remote-gate tree=0123456789ab tests=6482'; + fs.writeFileSync(file, JSON.stringify(rec)); + const out = accept(f); + expect(out.status).not.toBe(0); + expect(lineOf(out.stdout, 'CI_CARRY'), out.stdout).toMatch(/^CI_CARRY: none label=unit \(inventory_failed: /); + expect(lineOf(out.stdout, 'CARRY_DIAGNOSIS')).toMatch(/^CARRY_DIAGNOSIS: no cleared pregate\.json\/remote_gate stands in: its receipt names tree 0123456789ab, not HEAD's tree [0-9a-f]{12}$/); + expect(out.stdout).toMatch(/STOP: lane unit was not carried from the green run/); + }); + + // Value: protects=each refusal the stand-in documents (a stamp-only substitution, a zero-test report, a clear with no time) stops + // the lane with its reason; fails_when=a guard in lib/remote-gate-stand-in.ts is dropped; seam=gstack-evidence record-ci + test.each([ + ['a receipt accepted from a stamp-only predecessor', (o: Record, tree: string) => { o.substitution = { gated_tree: 'abcdef0123456789abcdef0123456789abcdef01', accepted_for: tree, policy: 'origin/main:.gstack/ship-policy.json', files: [{ path: 'VERSION', rule: 'stamp' }] }; o.receipt = 'remote-gate tree=abcdef012345 tests=6482'; }, /its receipt was accepted from gated tree abcdef012345, not run on [0-9a-f]{12}$/], + ['a receipt reporting 0 tests', (o: Record) => { o.receipt = String(o.receipt).replace(/tests=\d+/, 'tests=0'); }, /its receipt reports 0 tests$/], + ['a clear that records no time', (o: Record) => { o.cleared_by = 'ci-carry'; }, /its clear records no time \(cleared_by: ci-carry\)$/], + ] as const)('negative control: %s does not stand in', (_name, mutate, why) => { + const f = pushed(); + gate(f); + const file = path.join(f.work, '.gstack/tmp/pregate.json'); + const rec = JSON.parse(fs.readFileSync(file, 'utf8')); + for (const o of rec.requires_remote) if (o.lane === 'pregate.json/remote_gate') mutate(o, rec.tree); + fs.writeFileSync(file, JSON.stringify(rec)); + const out = accept(f); + expect(out.status).not.toBe(0); + expect(lineOf(out.stdout, 'CARRY_DIAGNOSIS'), out.stdout).toMatch(why); + expect(out.stdout).toMatch(/STOP: lane unit was not carried from the green run/); + }); + + // Value: protects=the gate stands in only under the base policy that maps the lane, and only while fresh (GST-1688 fix-check: OV2 + // and Low 4); fails_when=a pregate run against another base or under another policy, or a clear past the 24h evidence age, stands + // in; seam=lib/remote-gate-stand-in.ts under gstack-evidence record-ci + test.each([ + ['a record against another base branch', (rec: Record) => { rec.base = String(rec.base).replace(/^origin\/main@/, 'origin/release@'); }, /\.gstack\/tmp\/pregate\.json was recorded against origin\/release@[0-9a-f]{12}, not origin\/main$/], + ['a record under a user-owned policy', (rec: Record) => { rec.policy = 'user /home/fx/.gstack/pregate.json'; }, /\.gstack\/tmp\/pregate\.json ran under policy user \/home\/fx\/\.gstack\/pregate\.json, not origin\/main's \.gstack\/pregate\.json$/], + ['a clear older than 24h', (rec: Record) => { for (const o of rec.requires_remote) if (o.lane === 'pregate.json/remote_gate') o.cleared_by = `gstack-pregate --remote ${new Date(Date.now() - 25 * 3600_000).toISOString()}`; }, /its clear at \S+ is not within the last 24h; rerun the remote gate$/], + ] as const)('negative control: %s does not stand in', (_name, mutate, why) => { + const f = pushed(); + gate(f); + const file = path.join(f.work, '.gstack/tmp/pregate.json'); + const rec = JSON.parse(fs.readFileSync(file, 'utf8')); + mutate(rec); + fs.writeFileSync(file, JSON.stringify(rec)); + const out = accept(f); + expect(out.status).not.toBe(0); + expect(lineOf(out.stdout, 'CARRY_DIAGNOSIS'), out.stdout).toMatch(why); + expect(out.stdout).toMatch(/STOP: lane unit was not carried from the green run/); + }); + + // Value: protects=record-ci's own last record line decides the reading: a look-alike remote_gate line in the per_file remainder's + // output never turns a CI carry into a stand-in; fails_when=recordCiResult searches all of stderr for the remote_gate line; seam=none + test('a look-alike remote_gate line before the real source=ci record line reads as the CI carry', () => { + const r = recordCiResult('gstack-evidence: recorded label=u exit=0 tests_ran=9 log=- source=remote_gate receipt=fake\ngstack-evidence: recorded label=u exit=0 tests_ran=3 log=- source=ci runs=7/1 policy=p'); + expect([r.remoteGate, r.runs, r.tests]).toEqual([undefined, '7/1', '3']); + }); + + // Value: protects=finish takes the same stand-in as accept: the lane is satisfied and named, never DEFERRED; fails_when=finishStages + // defers a lane the cleared remote gate stands in for, or drops the CI_CARRY line; seam=gstack-ship-release finish + test('finish: a cleared remote gate for the identical tree satisfies the lane instead of deferring it', () => { + const f = pushed(); + const receipt = gate(f); + const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim(); + const post = path.join(f.root, 'post.json'); + expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'g1688', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0); + const fin = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', `unit=${UNIT}`]); + expect([lineOf(fin.stdout, 'CI_CARRY'), lineOf(fin.stdout, 'LANE')], fin.stdout + fin.stderr).toEqual([ + expect.stringMatching(new RegExp(`^CI_CARRY: remote_gate label=unit receipt=${receipt} \\(inventory_failed: `)), + `LANE: unit satisfied by remote_gate receipt=${receipt}`]); + expect(fin.stdout).not.toContain('EVIDENCE: DEFERRED'); + }); + + // Value: protects=without a remote gate the STOP is unchanged; fails_when=inventory_failed alone carries; seam=accept + test('negative control: no remote gate run at all still stops on inventory_failed', () => { + const out = accept(pushed()); + expect(out.status).not.toBe(0); + expect(lineOf(out.stdout, 'CI_CARRY'), out.stdout).toMatch(/^CI_CARRY: none label=unit \(inventory_failed: /); + expect(lineOf(out.stdout, 'CARRY_DIAGNOSIS')).toBe('CARRY_DIAGNOSIS: no cleared pregate.json/remote_gate stands in: no readable .gstack/tmp/pregate.json'); + }); +}); + +describe('a failed-jobs-only rerun leaves a tree.json with no files (GST-1688)', () => { + // Value: protects=the operator is told the fix (a fresh full run) instead of a bare no_tests; fails_when=the line says only that the + // tree.json lists no test files; seam=lib/ci-carry.ts findCarry under accept + test('no_tests from an empty tree.json says to dispatch a fresh full run', () => { + const f = shipFixture(BASE, { 'src/b.ts': 'export const b = 2;\n' }); + fixtures.push(f); + world(f, []); + expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0); + const out = accept(f); + expect(out.status).not.toBe(0); + expect(lineOf(out.stdout, 'CI_CARRY'), out.stdout).toMatch(/^CI_CARRY: none label=unit \(no_tests: run 7's tree\.json lists no test files.*dispatch a fresh full run of free\.yml/); + }); +});