From 05114c8049ff5f95a072dc47ff6653eab5bbd697 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 6 Nov 2026 02:16:00 -0800 Subject: [PATCH] ship accept: a lanes-only head policy gates the final tree; owner-granted test-lane exception (GST-1595) (#4061) * ship accept: a lanes-only head policy gates the final tree; name bun-hidden JUnit suites; owner-granted test-lane exception (GST-1595) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Each excepted file is preflighted on its own and must run a counted test; the preflighted files count toward the lane's tests_ran; grants are kept per head and file set; lanesOnlyHeadPolicy compares .gstack/generated.json with the fork copy. Review-Finding: v1/outside-voice#1 reader=outside-voice Review-Finding: v1/outside-voice#2 reader=outside-voice Review-Finding: v1/outside-voice#3 reader=outside-voice Review-Finding: v1/adversarial-native#2 reader=adversarial-native Review-Finding: v1/adversarial-native#4 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: lanesOnlyHeadPolicy compares generated.json with the base tip again A --policy-from head run loads the regen registry from the branch (lib/pregate/context.ts), so a branch whose .gstack/generated.json differs from main's tip keeps the base policy; comparing with the fork copy would gate under a stale registry. Review-Finding: p2/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: troubleshooting entries for the accept head policy and test-lane exceptions (GST-1595) 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-approvals | 27 ++- bin/gstack-evidence | 42 +++- bin/gstack-ship-receipt | 6 +- ...-1595-accept-head-policy-lane-exception.md | 1 + docs/pregate.md | 41 +++- docs/ship-policy.md | 32 ++++ docs/troubleshooting.md | 37 +++- lib/ci-carry.ts | 72 +++++-- lib/headless-artifacts.ts | 7 +- lib/pregate/head-policy.ts | 34 ++++ lib/pregate/index.ts | 5 +- lib/ship-bin-common.ts | 10 +- lib/ship-preflight.ts | 4 +- lib/ship-receipt-prose.ts | 4 +- lib/ship-receipt.ts | 2 + lib/ship-release.ts | 17 +- lib/test-lane-exception.ts | 56 ++++++ scripts/lib/windows-curation.ts | 8 + test/ship-accept-head-policy.test.ts | 87 +++++++++ test/ship-accept-lane-exception.test.ts | 180 ++++++++++++++++++ 20 files changed, 624 insertions(+), 48 deletions(-) create mode 100644 changelog.d/capy-gst-1595-accept-head-policy-lane-exception.md create mode 100644 lib/test-lane-exception.ts create mode 100644 test/ship-accept-head-policy.test.ts create mode 100644 test/ship-accept-lane-exception.test.ts diff --git a/bin/gstack-approvals b/bin/gstack-approvals index bd8c6b4b3..b21103285 100755 --- a/bin/gstack-approvals +++ b/bin/gstack-approvals @@ -9,6 +9,7 @@ * gstack-approvals render [--no-heading] [--full] [--branch ] * gstack-approvals rule --finding --head --reason --words --by [--json] * gstack-approvals rulings [--branch ] + * gstack-approvals lane-exception --head --files --reason --words --by [--json] * gstack-approvals list [--json] [--branch ] * gstack-approvals sync [--branch ] * gstack-approvals path [--branch ] @@ -26,6 +27,7 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderMode, renderRecord, repoVisibility, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record'; import { GSTACK_ACTOR, openShipRun, RULED_NOTE_SCOPE, ruledNoteRow, ruledNotes, ruledNoteText } from '../lib/ruled-notes'; import { resultLine } from '../lib/result-codes'; +import { TEST_LANE_EXCEPTION_SCOPE, testLaneExceptionRow } from '../lib/test-lane-exception'; const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md) @@ -39,6 +41,11 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs counts that finding closed on a fix-check whose range_base..commit holds the head, and the receipt and PR body list it; refused inside this checkout's open /ship run rulings the "Ruled note: …" lines the Pre-Landing Review and the PR body carry + lane-exception --head --files + --reason --words --by + the owner's grant that a CI-carried lane counts although exactly those files are in no run's tree.json + (docs/ship-policy.md#test-lane-exceptions); gstack-evidence record-ci binds it only at that head, for that exact + set, after a local preflight of those files passes; refused inside this checkout's open /ship run render [--no-heading] [--full] the ## Approvals section for a PR body or report; a public repository, or one whose visibility gh cannot read, gets only "Approved: yes|no (record )", because a PR description keeps its edit history; --full prints the rows when gh cannot read the visibility and you know the repo is private @@ -53,7 +60,7 @@ Example: ${exitTableHelp()}`; -const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head']); +const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head', '--files']); function parse(args: string[]): { flags: Record; bools: Set; error?: string } { const flags: Record = {}; @@ -121,9 +128,22 @@ function main(argv: string[]): number { const json = bools.has('--json'); const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`; - if (cmd === 'add' || cmd === 'import' || cmd === 'rule') { + if (cmd === 'add' || cmd === 'import' || cmd === 'rule' || cmd === 'lane-exception') { let rows; - if (cmd === 'rule') { + if (cmd === 'lane-exception') { + const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message }); + const files = [...new Set((flags['--files'] ?? '').split(',').map(f => f.trim().replace(/^(\.\/)+/, '')).filter(Boolean))].sort(); + const reason = (flags['--reason'] ?? '').trim(); + if (!files.length || files.some(f => /\s/.test(f)) || !reason || /[\x00-\x1f\x7f]/.test(reason)) return invalid('lane-exception needs --files and --reason '); + const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 }); + if (!flags['--head'] || head.status !== 0) return invalid('lane-exception needs --head '); + if (GSTACK_ACTOR.test((flags['--by'] ?? '').trim())) return invalid('--by names gstack; a test-lane exception is the owner\'s, so --by names who granted it'); + const open = openShipRun(process.cwd()); + if (open) return invalid(`/ship run ${open} is open on this checkout: a test-lane exception is the owner's, never the lane's own runner's; record it from the owner's session`); + const built = buildRow({ ...testLaneExceptionRow(head.stdout.trim(), files, reason), words: flags['--words'] ?? '', by: flags['--by'] ?? '', date: flags['--date'] }, loc.branch); + if ('code' in built) return fail(cmd, built); + rows = [built]; + } else if (cmd === 'rule') { const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message }); const finding = (flags['--finding'] ?? '').trim(), reason = (flags['--reason'] ?? '').trim(); if (!finding || /\s/.test(finding) || !reason) return invalid('rule needs --finding and --reason '); @@ -138,6 +158,7 @@ function main(argv: string[]): number { rows = [built]; } else if (cmd === 'add') { if (RULED_NOTE_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a ruled_note scope is recorded with gstack-approvals rule' }); + if (TEST_LANE_EXCEPTION_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a test_lane_exception scope is recorded with gstack-approvals lane-exception' }); const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch); if ('code' in built) return fail(cmd, built); rows = [built]; diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 084f6de83..111766d59 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -100,6 +100,7 @@ import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ import { DEFAULT_MAX_AGE_HOURS, runChanged } from "../lib/run-evidence"; import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config"; import { buildRunCheck } from "../lib/pregate/run-check"; +import { readTestLaneExceptions, testLaneExceptionText, type BoundLaneException } from "../lib/test-lane-exception"; const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url)); const LOG_MAX_BYTES = 2 * 1024 * 1024; @@ -140,6 +141,8 @@ interface EvidenceRecord { not_owed?: string; /** The per_file run of the inventory files CI skipped entirely; not_owed: the ones that skipped every test here too (GST-1314). */ skipped_here?: { label: string; files: string[]; tests_ran: number | "unknown"; not_owed?: string[] }; + /** The owner's test-lane exception that let a lane carry with uncovered inventory files, and the preflight of exactly them here (GST-1595). */ + exception?: BoundLaneException; } /** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */ @@ -721,8 +724,9 @@ async function cmdRecordCi(argv: string[]): Promise { console.error("usage: gstack-evidence record-ci --label --base -- "); return 2; } - const none = (reason: string, detail: string) => { + const none = (reason: string, detail: string, diagnosis: string[] = []) => { console.error(`CARRY_NONE: ${reason}: ${detail}`); + for (const d of diagnosis) console.error(`CARRY_DIAGNOSIS: ${d}`); console.error(`gstack-evidence record-ci: label=${label} not carried; nothing recorded`); return 1; }; @@ -738,8 +742,29 @@ async function cmdRecordCi(argv: string[]): Promise { const tree = git(["rev-parse", "HEAD^{tree}"]); if (!head || !tree) return none("no_head", "HEAD is unreadable"); const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; }; - const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) }); - if (!v.ok) return none(v.reason, v.detail); + const grants = readTestLaneExceptions(cwd); + 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) 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); + if (v.excepted?.length && grant) { + const preLabel = `${label}.excepted`; + console.error(`gstack-evidence record-ci: test-lane exception ${grant.approval}: preflighting ${v.excepted.length} uncovered file(s) here at ${head.slice(0, 12)}, one at a time (label=${preLabel})`); + let ran = 0; + for (const file of v.excepted) { + const preCommand = perFileCommand(m.per_file!, [file]); + const exit = await cmdRun(["--label", preLabel, "--", preCommand]); + let rows: EvidenceRecord[] = []; + try { rows = readJsonl(ledgerPath().file); } catch { rows = []; } + const n = rows.findLast((r) => r.label === preLabel && r.source === undefined && r.cmd_sha256 === sha256(preCommand) && r.wtree === tree)?.tests_ran ?? "unknown"; + if (exit !== 0 || typeof n !== "number" || n <= 0) return none("exception_preflight_failed", `test-lane exception ${grant.approval}'s preflight of ${file} ${exit !== 0 ? `exited ${exit}` : `ran ${n} tests`} here (label=${preLabel}); the exception binds only when each of ${v.excepted.join(", ")} runs and passes at HEAD`); + ran += n; + } + exception = { ...grant, preflight: { label: preLabel, tests_ran: ran } }; + } + const testsRan = exception && typeof v.tests === "number" ? v.tests + v.excepted!.length : v.tests; let here: EvidenceRecord["skipped_here"]; if (v.remainder?.length) { const hereLabel = `${label}.skipped`; @@ -760,11 +785,13 @@ async function cmdRecordCi(argv: string[]): Promise { } here = { label: hereLabel, files: v.remainder, tests_ran: ran, ...(notOwed.length ? { not_owed: notOwed } : {}) }; if (notOwed.length) console.error(`gstack-evidence record-ci: not owed here: ${notOwed.length} remainder file(s) skipped every test on CI and here too (env- or tool-gated by design): ${notOwed.join(", ")}`); + } + if (here || exception) { for (const e of v.runs) { const changed = runChanged(check.deps, e.run_id, e.run_attempt); if (changed) return none("run_changed", changed); } - if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`); + if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${here?.label ?? exception!.preflight.label} ran`); } let paths: ReturnType; try { @@ -777,11 +804,12 @@ async function cmdRecordCi(argv: string[]): Promise { const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`); const rec: EvidenceRecord = { ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0, - commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}), + commit: head, tree, dirty: false, wtree: tree, tests_ran: testsRan, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}), ...(exception ? { exception } : {}), }; appendJsonl(paths.file, rec, { mode: 0o600 }); - console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}${here.not_owed ? ` not_owed_here=${here.not_owed.length}` : ""}` : ""}`); + console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${testsRan}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}${here.not_owed ? ` not_owed_here=${here.not_owed.length}` : ""}` : ""}`); if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`); + if (exception) console.error(`TEST_LANE_EXCEPTION: ${testLaneExceptionText(exception)}`); return 0; } @@ -804,7 +832,7 @@ function cmdCarried(argv: string[]): number { for (const r of records) latest.set(r.label, r); const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef) && gradeRecord(r, { maxAgeHours: DEFAULT_MAX_AGE_HOURS, allowPaths: [], wtreeNow: wtree }).verdict === "FRESH") - .flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; })); + .flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree!, ...(r.exception ? { exception: r.exception } : {}) }; })); if (argv.includes("--json")) console.log(JSON.stringify(carried)); else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`); return 0; diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index f3dde839e..930b8b48e 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -57,6 +57,7 @@ import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; import { readPause, receiptBaseRed } from '../lib/base-red'; import { readRuledNotes, ruledNoteText } from '../lib/ruled-notes'; +import { laneExceptionBodyLines } from '../lib/test-lane-exception'; import { readMechanicalLines } from '../lib/ship-mechanical'; import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; @@ -265,6 +266,9 @@ function cmdWrite(rest: string[], json: boolean): number { const ruledNotes = readRuledNotes(repoRoot); const unlisted = bodyFile ? ruledNotes.map(n => `Ruled note: ${ruledNoteText(n)}`).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined; if (unlisted) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ruled note ${JSON.stringify(unlisted)} (gstack-approvals rulings prints each line the body carries); nothing written`) + '\n'); return EXIT.fail; } + const carried = ciCarried(base); + const unlistedException = bodyFile ? laneExceptionBodyLines(carried).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined; + if (unlistedException) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedException)} (accept's TEST_LANE_EXCEPTION: line; the body carries it as one line); nothing written`) + '\n'); return EXIT.fail; } const mechanical = readMechanicalLines(repoRoot, git(['rev-parse', `${head}^{tree}`], repoRoot) ?? ''); const unlistedRetake = bodyFile ? mechanical.find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined; if (unlistedRetake) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedRetake)} (finish's MECHANICAL: lines; the body carries each as one line); nothing written`) + '\n'); return EXIT.fail; } @@ -317,7 +321,7 @@ function cmdWrite(rest: string[], json: boolean): number { version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base, gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined, tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined, - gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base), + gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: carried, spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined, session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), diff --git a/changelog.d/capy-gst-1595-accept-head-policy-lane-exception.md b/changelog.d/capy-gst-1595-accept-head-policy-lane-exception.md new file mode 100644 index 000000000..ec2b66f1c --- /dev/null +++ b/changelog.d/capy-gst-1595-accept-head-policy-lane-exception.md @@ -0,0 +1 @@ +- **`/ship` accept gates under a branch's own pregate policy when it only adds test lanes, says why a carried lane's test file is missing from CI's JUnit tree, and takes an owner-granted test-lane exception.** On gstack-evals #240 a PR that added a runtime file and its own lane line passed preflight under `--policy-from head`, then `gstack-ship-release accept` reran the pregate under the base policy and refused with `PREGATE_NO_LANE`. `accept`, and `finish` when it reruns the whole pregate, now pass `--policy-from head` when the branch's `.gstack/pregate.json` differs from the copy it forked from in nothing but added test lanes and `.gstack/generated.json` is unchanged, printing `POLICY: head …`; a removed or shrunk lane, or any other changed key, keeps the base policy with `POLICY: base … (the branch's copy …)`. The same PR then stopped on `inventory_uncovered`: bun registers an imported test file's tests under the importer, so its JUnit suite never appears. The refusal now adds `CARRY_DIAGNOSIS: bun registers under ; its JUnit suite won't appear`, and the owner can record `gstack-approvals lane-exception --head --files --reason … --words … --by …` (refused inside the lane's open /ship run, by gstack, or through `add`). `gstack-evidence record-ci` binds it only at that head for exactly those files, after a local preflight of them with the lane's `per_file` runner passes; `accept` prints `TEST_LANE_EXCEPTION:`, the receipt's `ci_carried` entry records the grant and the preflight, and `gstack-ship-receipt write --body` refuses a body without the `Test-lane exception: …` line. See docs/pregate.md#accept-head-policy and docs/ship-policy.md#test-lane-exceptions. (GST-1595) diff --git a/docs/pregate.md b/docs/pregate.md index 8621a0947..5cd0dda1d 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -39,7 +39,24 @@ read from the branch under `--policy-from head`. So an allowlist entry or a `[]` declaration a branch adds counts only once it is on the base, and a lane it adds counts on the base policy only once it lands; `PREGATE_NO_LANE`'s fix line says so when the branch edits the file -([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). An unresolvable base exits 3 with +([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). +/ship's reruns on the final tree (`gstack-ship-release accept`, and `finish` +when it reruns the whole pregate on the stamped tree) choose the policy themselves +(GST-1595). When the branch's `pregate.json` differs from the copy it forked from +(the base tip's when the fork has none) in nothing but added test lanes, and +`.gstack/generated.json` matches the base tip's copy (a head-policy run loads the +regen registry from the branch, so a copy that main changed after the fork keeps +the base policy), they pass `--policy-from head` and print +`POLICY: head (unreviewed) .gstack/pregate.json: adds test lane(s) over +origin/ and changes nothing else, so the gate is only stricter`. So a PR +that adds a runtime file and its own lane line passes `accept` without a +lanes-only PR first (gstack-evals #240). Any other difference (a removed or +shrunk lane, a `[]` declaration, `warn`, `timeout_s`, `ship_lanes_from`, any +other key, a changed `generated.json`, a file that does not parse) keeps the +base policy as before, with `POLICY: base origin/:.gstack/pregate.json (the +branch's copy , so it does not count here; land it on +first)`. A branch that leaves the file alone prints no `POLICY:` line. +An unresolvable base exits 3 with `POLICY_SOURCE_UNAVAILABLE`. Globs are repo-relative (no `..`, no absolute paths, at most 64 per list); commands are one line under 512 bytes. @@ -1202,6 +1219,28 @@ the same run. `gstack-ship-receipt write` records the carried runs as `ci_carried: [{ lane, run, attempt, tree }]`, and the `SHIP_RECEIPT:` line names them (`ci_carried=:/`). +**An inventory file CI's JUnit tree never lists (GST-1595).** bun registers +the tests of a test file another test file imports under the importer, so the +imported file's own JUnit suite never appears in `tree.json` and the carry +refuses with `inventory_uncovered`. For each such file another inventory file +imports by a relative path (`import`, `import()`, `require`), the refusal adds +`CARRY_DIAGNOSIS: bun registers under ; its JUnit suite +won't appear` (`lib/ci-carry.ts` `importedTestLines`), printed by `gstack-evidence +record-ci`, `gstack-ship-preflight` and `accept`; the remote gate's +`inventory_uncovered` reason carries the same words. The fix is usually to move +the shared code out of the `.test.` file. When that can't happen in this PR, +the owner can grant a [test-lane exception](ship-policy.md#test-lane-exceptions) +for exactly those files at the pushed head: `record-ci` then runs each of them +here, one file at a time, with the entry's `per_file` runner (label +`.excepted`) and carries the lane only when every file's run passes with a +counted test; the lane's `tests_ran` counts those files beside the ones CI +covered. A failed or zero-test run of any file refuses with +`exception_preflight_failed`, and a grant at another head or for another file +set binds nothing. The CI row and the receipt's `ci_carried` entry +keep the grant and the preflight (`exception`), and `accept` prints +`TEST_LANE_EXCEPTION: at : preflight