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/<base> 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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-06 23:55:54 +00:00
1 parent ca367035c0
commit e4b1e34552
9 files changed
+334 -17

No files matched your search

+31 -6
View File
@@ -4,7 +4,7 @@
* IRON LAW ("no completion claims without fresh verification evidence").
*
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
* gstack-evidence record-ci --label <L> --base <base> [--remote-gate-stands-in] -- <cmd>
* gstack-evidence passed -- <cmd>
* gstack-evidence carried [--base <branch>] [--json]
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
@@ -72,7 +72,11 @@
* mapped it (`origin/<base>@<sha>:…`); `carried` lists those rows at the
* current tree for the ship receipt, only when that policy is origin/<the
* receipt's base> (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<number> {
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 <L> --base <base> -- <command>");
return 2;
}
@@ -746,6 +753,24 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
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<typeof ledgerPath>;
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}` : ""}`);
@@ -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=<lane> 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)
+40
View File
@@ -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 id="ship-lanes-remote-gate"></a>**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=<HEAD's tree> tests=<n>` 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/<base>@`: 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=<lane> receipt=<receipt> (inventory_failed: …; the
cleared pregate.json/remote_gate for this exact tree stands in)` and `LANE: <lane>
satisfied by remote_gate receipt=<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: <why>` 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 <workflow> --ref <branch>`)
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
+7 -3
View File
@@ -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 ?? '<workflow>'} --ref <branch>) 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);
+39
View File
@@ -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=<HEAD's tree> tests=<n>`, 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/<base> under origin/<base>'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() };
}
+12 -6
View File
@@ -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<string, ShipLaneMapping> {
@@ -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}`)] : []);
+4 -2
View File
@@ -563,11 +563,12 @@ async function finishStages(argv: readonly string[], cwd: string): Promise<Repor
if (!/EVIDENCE: FRESH/.test(e)) {
const m = mapped.get(l.label);
const res = m ? carryLane(cwd, base, l) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
if (m && !res.carried) {
if (m && !res.carried && !res.remoteGate) {
r.add(`EVIDENCE: DEFERRED label=${l.label} (${short([res.carryNone ?? 'not carried'], 1).slice(0, 160)}): CI at the pushed head carries it in accept`);
deferred.push(l.label);
continue;
}
for (const line of carryNoneLines(res)) r.add(line);
const ran = laneLine(res);
r.add(ran.line);
if (!ran.ok) return r.stop(`lane ${l.label} failed or ran zero tests: STOP publication; run Steps 5-15 from Step 5's triage, then Step 16 stage 1`);
@@ -635,7 +636,8 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
for (const l of list) {
const m = mapped.get(l.label);
const res = m ? carryLane(cwd, base, l, recordCi.seconds) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
if (m && !res.carried) { for (const line of carryNoneLines(res)) r.add(line); return r.stop(`lane ${l.label} was not carried from the green run (${short([res.carryNone ?? ''], 1).slice(0, 120)}): fix the cause, or run it here with gstack-ship-preflight --tests-only`); }
for (const line of carryNoneLines(res)) r.add(line);
if (m && !res.carried && !res.remoteGate) return r.stop(`lane ${l.label} was not carried from the green run (${short([res.carryNone ?? ''], 1).slice(0, 120)}): fix the cause, or run it here with gstack-ship-preflight --tests-only`);
const ran = laneLine(res);
r.add(ran.line);
if (res.exception) { r.add(`TEST_LANE_EXCEPTION: ${res.exception}`); exceptions.push(res.exception); }
+4
View File
@@ -435,6 +435,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
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
+196
View File
@@ -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<string, string>)[process.platform] ?? 'Linux';
const RUNNER = ({ darwin: 'macos-latest', win32: 'windows-latest' } as Record<string, string>)[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<string, string> = { '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<string, unknown>, 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<string, unknown>) => { o.receipt = String(o.receipt).replace(/tests=\d+/, 'tests=0'); }, /its receipt reports 0 tests$/],
['a clear that records no time', (o: Record<string, unknown>) => { 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<string, any>) => { 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<string, any>) => { 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<string, any>) => { 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/);
});
});