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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-06 10:16:00 +00:00
1 parent 3c7ff030b2
commit 05114c8049
20 files changed
+624 -48

No files matched your search

+40 -1
View File
@@ -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)).
<a id="accept-head-policy"></a>/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 <n> test lane(s) over
origin/<base> 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/<base>:.gstack/pregate.json (the
branch's copy <what differs>, so it does not count here; land it on <base>
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=<lane>:<run>/<attempt>`).
<a id="carry-imported-tests"></a>**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 <imported> under <importer>; 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
`<lane>.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: <files> at <head12>: preflight <label> ran <n> test(s)
here; granted by <who> at <time>: <reason> (approval <id>)`.
**`gstack-ci-carry`.** `find [--profile <name>]… [--command <exact lane command>
--allow-repo-commands]` prints the verdict for HEAD; `wait [--timeout <s>]`
polls the newest run at HEAD until it completes (run it in the background) and
+32
View File
@@ -1645,6 +1645,38 @@ Pre-Landing Review and the PR body, `gstack-ship-receipt write` records them as
`ruled_notes` (each with its `head`) and refuses a `--body` that does not carry each
line word for word, and the READY block prints each as `RULED_NOTE:`.
<a id="test-lane-exceptions"></a>
**Test-lane exceptions (GST-1595).** A lane `accept` carries from CI refuses with
`inventory_uncovered` when an inventory file is in no run's `tree.json`; when bun
hid it under an importing test file, the refusal says so
([pregate.md](pregate.md#carry-imported-tests)). The owner, never the lane, can
grant that the lane counts anyway at one pushed head, for exactly those files,
through the same approval record as a ruling:
```bash
gstack-approvals lane-exception --head <pushed head> --files <f1,f2> \
--reason '<why, one line>' --words '<the owner's exact words>' --by '<who granted>'
```
The row is an approved `gstack-approvals` row with scope `test_lane_exception <head>
<files>` and ask `test_lane_exception at <head>: <files>: <reason>`
(`lib/test-lane-exception.ts`). `lane-exception` refuses a head that is not a
commit, a file with whitespace, a multi-line reason, a `--by` naming gstack and
any grant while this checkout holds an open /ship run's marker, and `add` refuses
the scope, so the lane's own runner cannot write one; a row by gstack is ignored
wherever it came from. `gstack-evidence record-ci` binds a grant only when its
head is HEAD and its files are exactly the uncovered set, and only after a local
preflight of each of those files, one at a time, with the lane's `per_file` runner
passes with a counted test at that head (a lane with no `per_file` runner cannot
use one). Grants are kept per head and file set (the newest wins), so two lanes at
one head each bind their own. Nothing about it is
hidden: `accept` prints `TEST_LANE_EXCEPTION: …` and names the body line in its
`NEXT:`, the receipt's `ci_carried` entry records `exception` (head, files, by,
reason, time, approval id and the preflight's label and test count),
`gstack-ship-receipt write --body` refuses a body that does not carry
`Test-lane exception: …` word for word, and the READY block prints each as
`TEST_LANE_EXCEPTION:`. A new push needs a new grant at the new head.
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads the branch's review-log
rows for this invocation and its dispatch ledgers, and writes the counters the ship receipt carries
(`gstack-ship-receipt write … --review-loop @<file>`): `review_loop: { passes,
+31 -6
View File
@@ -1940,8 +1940,17 @@ The guide is `capy/skill/gstack/captain.md`.
`--by`), holds a value outside its enum (`--outcome approved|rejected|pending|standing`,
a `--date` that is not a real `YYYY-MM-DD` day) or exceeds a length cap (ask, scope and
pending 300 characters, words 2,000). Exit 2. Nothing was written.
`rule` and `lane-exception` refuse with this code too: a `--head` that is not
a commit in the repository, a reason that is missing or holds a newline or
control character, a `--by` naming gstack, any record while this checkout
holds an open /ship run, and for `lane-exception` a `--files` list that is
empty or holds whitespace. `add` refuses a `ruled_note` or
`test_lane_exception` scope ([ruled notes](ship-policy.md#ruled-notes),
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
**Fix.** Rerun with every required flag inside the caps the line names.
**Fix.** Rerun with every required flag inside the caps the line names. A
ruling or a test-lane exception is the owner's: record it from the owner's
session, outside the lane's /ship run.
<a id="approval-record-secret"></a>
### `gstack-approvals: ... (APPROVAL_RECORD_SECRET)`
@@ -3496,10 +3505,17 @@ scan tags included), or whose product patch is not the receipt's and the change
since it is more than plain docs. A docs-only change after the read carries the
record only when it changes no binary file, adds no numeric claim, touches no
results, prereg or eval path, and names no money, security, secret or auth topic; any other docs change
needs the reader again (GST-1195).
needs the reader again (GST-1195). With `--body`, write also refuses, writing
nothing, a body that does not carry each line the receipt requires as one line,
word for word: every `Ruled note: …` line `gstack-approvals rulings` prints,
finish's `MECHANICAL:` lines as `Mechanical retake: …`, and accept's
`TEST_LANE_EXCEPTION:` lines as `Test-lane exception: …`
([ruled notes](ship-policy.md#ruled-notes),
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
**Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the
block. For the `--head` refusal, check out that commit and write there. For a
block. For a missing body line, add the line the refusal quotes to the body and
write again. For the `--head` refusal, check out that commit and write there. For a
targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verify
--targeted-out .gstack/tmp/targeted.json`, and write again with
`--review-targeted @.gstack/tmp/targeted.json`.
@@ -4146,6 +4162,14 @@ two routes:
An allowlist entry or a `[]` declaration the branch adds to its own
`pregate.json` counts only once it is on the base.
/ship's own reruns on the final tree (`gstack-ship-release accept`, and
`finish` when it reruns the whole pregate) pass `--policy-from head`
themselves when the branch's copy differs from the one it forked from in
nothing but added test lanes and `.gstack/generated.json` matches the base
tip's, and print `POLICY: head (unreviewed) …`; any other difference keeps the
base policy and prints `POLICY: base … (the branch's copy <what differs> …)`
([accept's policy choice](pregate.md#accept-head-policy), GST-1595).
<a id="pregate-head-policy-loosens"></a>
### `PREGATE_HEAD_POLICY_LOOSENS`
@@ -6536,9 +6560,10 @@ body that names a counter the receipt records (`passes=3` against
against `gate.passed: 41`) with a different value. The receipt is written from
the run; the prose was typed, so a reader of the body would learn a number the
run did not produce. The `PROSE:` line names each field with both values. The
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`
and `Documentation exception: …` lines the receipt's exceptions require, and
the inside of a closed code fence are never read as claims.
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`,
`Documentation exception: …`, `Ruled note: …`, `Mechanical retake: …` and
`Test-lane exception: …` lines the receipt requires, and the inside of a
closed code fence are never read as claims.
**Fix.** Correct the body to the receipt's number. When the receipt is the
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten