* test(ship): failing-first Step 12 keep-current refusal and unattended early bump write (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 12 keeps VERSION on --keep-current, adds bump never, and waits for the owner's answer before an unattended bump (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: regenerate ship SKILL goldens (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (answer gate covers ALREADY_BUMPED --version writes; typed session rows; never drift and --answer rejection cases) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (the answer gate names every flag that decides the write; Version Gate and validate understand bump never) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: under bump never a release PR that moves VERSION takes the Version Gate's ordinary rules Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fragment for GST-380 Step 12 keep-current and bump never 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>
100 KiB
The repo ship policy: .gstack/ship-policy.json
/ship reads one machine-readable file per repository that says how releases
are stamped and merged: the bump level, the queue mode, the repo's own release
tool, the paths where version mentions are rewritten, the CHANGELOG, a paired
mirror, the history policy and the preregistered evals. Without the file
nothing changes: every key has a default that reproduces today's behavior.
The file is read from the committed copy on the base branch
(origin/<base>), never from the PR's working tree, so a PR cannot smuggle a
command into its own gate. File presence never authorizes execution:
gstack-restamp runs release_tool only under /ship or with
--allow-repo-commands; standalone it lists the commands and exits 3 with
repo commands not executed (pass --allow-repo-commands). A PR that
introduces or changes the policy runs under the reviewed bootstrap path
(--policy-from head --allow-repo-commands) and its receipt says
policy: head (unreviewed).
Tools: gstack-ship-policy init|validate|show-effective|check-history,
gstack-restamp, gstack-tree-receipt, gstack-ship-receipt,
gstack-ship-queue (shared merge queue, merge trains),
gstack-evidence bundle|ancestor. They share the exit table (0 ok, 1
fail, 2 usage, 3 refused or needs a flag) and end every error line with a
code from troubleshooting.md.
Schema v1
gstack-ship-policy init writes every key at its default; init --explain
prints this table. JSON carries no comments, so examples live in
.gstack/ship-policy.example.json. Unknown keys are errors, and there is no
extension mechanism: a key from a newer gstack, or a misspelled one, fails
closed (POLICY_INVALID) instead of being ignored. Every key has an
introducing version (init --explain and validate print it). A key newer
than 1.91.98.0 set to a non-default value requires min_gstack_version at
least at its introducing version, and a gstack older than min_gstack_version
refuses the file (POLICY_GSTACK_TOO_OLD). validate ends with
MIN_READER: gstack <version>, the floor every lane reading the policy needs.
| Key | Values | Default | Meaning |
|---|---|---|---|
bump |
patch | ask | never |
ask |
patch skips the MINOR/MAJOR prompt in /ship Step 12 and gstack-restamp --next bumps PATCH; ask keeps the prompt, and in a session no person answers any version Step 12 would write (--bump on a fresh branch, --version <v> on a bumped one) stops with BUMP_ANSWER_REQUIRED until --answer "<the owner's words>"; never is for a repo whose unit PRs never change VERSION: Step 12 keeps it with no question, refuses --bump, and gstack-restamp --next needs an explicit --bump or --version (the release process bumps). CI's Version Gate passes a head that keeps VERSION; a release PR that moves it takes the ordinary bump and collision rules. never needs min_gstack_version at its introducing release and cannot be combined with queue_mode: stamp-at-merge, train or changelog_fragments, which stamp a version at every merge. |
queue_mode |
claim-at-open | stamp-at-merge |
claim-at-open |
See stamp-at-merge. claim-at-open is today's queue (gstack-next-version claims a slot when the PR opens). |
release_tool |
command or null |
null |
The repo's own release command (bun run release:restamp). Runs in an isolated staging worktree under the release-tool contract. |
release_outputs |
globs | [] |
Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Anything else is RESTAMP_OUTPUT_OUTSIDE_ALLOWED. |
stamp_paths |
globs | [] |
Where exact (was X), since: X and vX mentions of the branch's tentative version (quoted or bare: since: 'X' counts) are rewritten on a restamp. |
changelog |
path or null |
CHANGELOG.md |
Re-headed on a restamp: the PR's ## [Unreleased] (or old-version) section moves to the top as ## [<version>] - <date>. |
mirror |
relative path or null |
null |
A paired checkout to restamp identically (tier 2: validated and confined today, not yet run). |
history |
merge-only | squash-ok |
squash-ok |
merge-only makes /ship refuse a squash or rebase plan; gstack-ship-policy check-history reports a rewritten branch (HISTORY_POLICY_VIOLATION). |
preregistration_shas |
commit ids | [] |
Each must be an ancestor of HEAD before push: gstack-evidence ancestor --from-policy; the ship receipt records the result. |
stamp_sensitive_gates |
evidence labels | [] |
Gates that read VERSION or generated bytes (gstack's own tests lane does, through the agents-digest freshness test). The tree receipt never marks them reusable. |
pr_count |
{ default_pr_count, split_requires_owner } |
{1, true} |
The PR-count row in plan reviews. |
maintainers |
GitHub logins | [] |
Maintainer set for /review contributor mode; empty means repository push permission. |
stamp_files |
globs | [] |
The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own since: rows). It applies before every other stamp rule, so a doc vX mention outside it is a difference. |
stamp_patterns |
literal templates | [] |
At most 16 templates of at most 128 characters, each with exactly one {version} slot ("version": "{version}", since: '{version}', <!-- x-stamp: {version} -->); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. |
restamp_command |
command or null |
null |
The repo's own restamp, run first in the staging worktree; see restamp_command. |
migrations |
{ dir, id } or null |
null |
The migration directory and a regex with exactly one capture group for the number in a file name ("^v(\\d+)-"); gstack-pregate regate reports MIGRATION_COLLISION. |
gate_command |
command or null |
null |
The repo's full pre-merge gate. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. Needs min_gstack_version. |
full_e2e |
true | false |
false |
true: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; gstack-ship-queue ready, stamp and verify refuse without one (QUEUE_GATE_MISSING): a gate-ahead record, or the lane's --gate-exit 0 --gate-log <file> from its run on the gated head. Needs min_gstack_version. |
review_scope |
frozen | diff-plus-blocking | all |
frozen |
The finding scope of /ship's review loop; see review_scope. frozen fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. diff-plus-blocking is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. all is the earlier loop: every finding is fixed on every pass. Needs min_gstack_version when not frozen. |
min_gstack_version |
version or null |
null |
The oldest gstack that may read the file; see the compatibility rule above. |
docs |
{ caps: [{ path, max_bytes }] } or null |
null |
Documentation files with a byte cap. /ship Step 14.5 runs gstack-ship-receipt docs-caps --base <base> (the caps come from origin/<base>'s policy, never the branch's copy; --policy <file> is for a repository bootstrapping its policy) before the verification gate and prints one DOCS_CAP: <path> <bytes>/<max> ok|over line per cap; any over (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (DOCS_AUDIT_BLOCKED). At most 64 caps, each path contained. See the ship receipt. Needs min_gstack_version. |
train |
{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd } or null |
null |
Turns on merge trains, read from the base revision only. Every field is required and an unknown field is POLICY_INVALID: max_size 1 to 6, fill_hours above 0 and at most 24, screen_selection gstack-paid | none, trial_reservation_usd above 0 and at most 100. null keeps the per-PR queue exactly as it is. Needs min_gstack_version. |
changelog_fragments |
{ dir, required_checks? } or null |
null |
PRs carry unversioned CHANGELOG fragments in dir (one flat, repo-relative directory, no ..) and the merge step (land) assigns the version. required_checks (up to 50 distinct names) are the checks train land requires present and green on a stamp commit. null keeps [Unreleased] sections and per-PR stamps. Needs min_gstack_version. |
Containment, checked by validate and again at write time: paths and globs are
repo-relative with no .. segment, under 256 characters, at most 64 per list,
and a glob's regex expansion under 512 characters; release_tool is under 512
characters with no newline; a matched file that resolves through a symlink to
outside the repository is refused (POLICY_CONTAINMENT). A mirror may start
with .. (a sibling checkout) but has no interior ..; mirror writes are
confined to that directory.
gstack's own files
gstack's policy (.gstack/ship-policy.json, tracked beside generated.json
and pregate.json under the otherwise ignored .gstack/) runs the repo on
stamp-at-merge: the version is allocated once, when the PR is next in the queue,
every bump is PATCH, and /review reads the maintainer set from the file. It
names the one release tool gstack-version-bump --regen-digest used to
hard-code:
{
"bump": "patch",
"queue_mode": "stamp-at-merge",
"release_tool": "bun scripts/gen-agents-digest.ts",
"release_outputs": ["agents-digest/gstack-AGENTS.md"],
"stamp_paths": ["lib/ship-policy.ts"],
"stamp_patterns": ["_KEY_VERSION = '{version}'"],
"changelog": "CHANGELOG.md",
"history": "squash-ok",
"stamp_sensitive_gates": ["tests"],
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"min_gstack_version": "1.91.340.0"
}
gstack itself runs on changelog fragments: a PR adds
changelog.d/<branch-slug>.md and never a version, and trains merge through
train land instead
of slot calls. required_checks names the pull_request checks that run on
every PR (branch protection enforces only free-tests); a workflow with a path
filter (Windows Setup E2E, make-pdf gate, Native ARM Setup Smoke, Dependency
Review) is left out, because a stamp commit of a PR outside its paths never
gets that check and train land would hold on it. min_gstack_version is
1.91.340.0, the first release that reads required_checks and counts a
stamped slot as merged only when GitHub reports it MERGED, so an older gstack
refuses the file by name (POLICY_GSTACK_TOO_OLD).
.gstack/ship-policy.example.json shows every key set the way a serial-merge
repo with a release script, docs mentions and a mirror would set them.
Queue-side procedure for gstack
Who does what under stamp-at-merge, for the PR author and for the thread
running the merge queue:
- Open. The branch carries a tentative stamp of the base's version plus
PATCH (
gstack-version-bump write --version <base+patch> --regen-digest) and its CHANGELOG section headed with that version directly under# Changelog;## [Unreleased]is also accepted. Nothing is claimed andgstack-next-versionis not consulted; the tentative version is only the guess that makes the common case free. CI runs on this head as usual. - Register. The queue records the PR and its intended order.
- Stamp when next. With every check green on the current head and
origin/mainat the SHA the queue assumed, the PR owner (never the queue) runs on the branch:gstack-restamp --next --base main --allow-repo-commands --pr <n> --expect-base <main-sha> --expect-order <pr,...>. When the base has not moved past the tentative stamp it printsRESTAMP: X -> X (noop)andFILES: (none), and the green head is the merge candidate with no further CI. When the base moved, it stamps base + PATCH, re-heads the CHANGELOG above the base's top entry, reruns the release tool, printsSTAMP_VERSION: <X>and the stamp diff, and refuses withQUEUE_STALE(nothing written) when the base or order moved after--expect-basewas read. The owner commits and pushes the stamp commit, lets CI run on it, and reportsREADY #<n> <sha> STAMP_VERSION=<X>. - Merge. The queue verifies on that exact SHA:
VERSIONequalsSTAMP_VERSION, the CHANGELOG's top heading is[<X>]and the heading below it is the base's current version, every check is green on that SHA, and, when a stamp commit was pushed, it changed onlyVERSION,package.json,CHANGELOG.mdand the release outputs (gstack-tree-receipt --gated <green-head> --current <sha>prints the per-file verdict). It then squash-merges with the merge API's expected-headshaset to that SHA, so a late push is rejected by the server. "VERSION above main" is replaced by "VERSION equals the restamp's STAMP_VERSION on the SHA being merged, and that version is base + PATCH at merge time". - Not next. A PR waiting behind another may gate ahead locally with
gstack-restamp --after <predecessor-pr>; it pushes nothing. When the predecessor lands, the waiting PR mergesorigin/mainin (never a rebase), keeping its own CHANGELOG section above the newly merged one, and step 3 runs.
Until this policy is on origin/main, gstack-restamp --next reads the base's
bump: ask and falls back to a MICRO bump; pass --bump patch on the first
PR stamped after the flip if its base predates it.
Run a shared merge queue
gstack-ship-queue strings gstack-restamp, gstack-tree-receipt and the
ship receipt into the coordinator protocol, so nobody proves a stamp diff by
hand. Lanes own their PRs and never merge; the coordinator owns the order and
every merge. Each step ends in a line the other side acts on, then
GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>. Nothing
here pushes, except ready under changelog_fragments (its READY receipt goes
to refs/notes/gstack-trains) and land.
1. Lane: report ready (unstamped). After the gate and pregate pass on the pushed head:
gstack-pregate run --allow-repo-commands
git push
gstack-ship-queue ready --pr 42
READY #42 <head> STAMP_VERSION=unstamped
pr: https://github.com/<owner>/<repo>/pull/42
head: <head>
version: unstamped
base: origin/main@<sha>
ci: green at <head12> (passed=31 failed=0 pending=0 skipped=2)
PREGATE: pass tree=<tree12> head=<head12> checks=6
gate: command="<gate_command>" full_e2e=no gate-ahead=none
SHIP_RECEIPT: pr=42 head=<head12> …
READY_RECEIPT: <state root>/ship-queue/ready/<repo key>/42-<head>.json (notes pushed to origin refs/notes/gstack-trains)
Under changelog_fragments, a READY_RECEIPT: line follows, written only when
every check above passed; land refuses a pin without one.
CI's Version Gate (scripts/compare-pr-version.ts) accepts that head as
unstamped when VERSION and package.json's version match its merge base, the
CHANGELOG change is confined to [Unreleased], and any changed release output
is byte for byte what release_tool generates at the head's merge base
VERSION (the head's own, so a later stamp on main changes nothing), so a
generator change ships with its regenerated output and the slot's restamp
regenerates it again. A release output that differs from that (a hand edit, one generated at a
bumped version, or one the tool never writes), a VERSION bump or a
package.json version change classifies the head as stamped, and it must then
carry base + bump. The tool runs as head code in a throwaway worktree with a
bare environment and no tokens; one that fails at the head is an error, never
a pass.
A PR that conflicts with the base on CHANGELOG.md alone. GitHub shows every
READY PR as conflicting each time main stamps; the train merges the CHANGELOG
by section, so the lane never merges main for it. The conflict does mean GitHub
has no merge ref for the PR, so no pull_request CI runs on the head and
ready prints ci: none at <head12> with a ci-hint: line. The lane then
runs ready … --dispatch-ci free-tests.yml,windows-free-tests.yml [--timeout <s>] (under a train policy): it dispatches each named workflow on the branch
(gh workflow run <wf> --ref <branch>, skipped when a run at this head already
concluded), waits for the runs (CI_DISPATCH: <wf> --ref <branch> run <id> <url> <conclusion>), clears every requires-remote pregate lane whose workflow is one
of them with the run URL (PREGATE_CLEARED: <lane> <url>; a lane of another
workflow stays uncleared and is named in the refusal), and then reads the head
as usual, which passes because the dispatched check runs attach to the commit;
the ci-hint: line then says those non-pull_request runs count. A red run
clears no lane and refuses (QUEUE_NOT_READY); a run still going at --timeout
(default 2700 s) is QUEUE_TRAIN_PENDING, and rerunning the command keeps
waiting; a fork head is never dispatched here (QUEUE_TRAIN_UNTRUSTED). By hand the same four steps are gh workflow run <wf> --ref <branch>, wait, gstack-pregate clear --remote <lane> --run-url <run>, rerun ready. The ci-hint: line is advisory text for the lane;
nothing parses it. ready --screen on such a PR has no merge ref to screen,
so it builds the scratch merge described below (SCREEN_MERGE:, pushed as
screen/<pr>-<head12>) and screens that; only a real conflict with the base
(or a remote whose refs cannot be listed) is QUEUE_SCREEN_STALE_REF. A CHANGELOG-only conflict is screenable, and a
lane whose skill's own paid cases ran focused may still READY with --no-screen.
ci: counts the latest run per check (one app's check name), as GitHub's
rollup does: a check that was re-run or superseded on the same commit (a
cancelled run beside a later pass of the same name from a second workflow run)
is judged by its newest run (latest completed_at, then id; a run still in
progress is newest); two apps reporting the same name stay separate; a run with
no name or no readable completed_at counts on its own; commit-status contexts
are counted one each.
It refuses (QUEUE_NOT_READY) when the working tree has tracked edits, HEAD
is not the PR's pushed head, CI at that exact head is not green, or the pregate
result is not a current pass, and (QUEUE_RECEIPT_MISSING,
QUEUE_RECEIPT_STALE) when the PR body carries no /ship receipt for that
head: /ship runs before every READY (details). It also
refuses QUEUE_RECEIPT_DEPTH_RAISED when the receipt's review depth is below
what the head needs and QUEUE_RECEIPT_EXCEPTION_INVALID when an owner
exception the receipt records does not stand (the ship receipt).
Send the block as printed.
2. Lane behind another PR: gate ahead. Gate the tree the queue will have once the predecessor lands, while it is still open:
git fetch origin pull/41/head
gstack-ship-queue gate-ahead --after 41 --pr 42 --predecessor-ref FETCH_HEAD --allow-repo-commands
prints GATE_AHEAD: pr=42 attempt=<id> gated=<sha> tree=<tree> merge_tree=<tree> predecessor=#41@<sha12> version=<N+1>, the attempt's own worktree:, its
record: file, GATE_COMMAND: (the policy's gate_command) and the exact
next: line. Run the gate there, in the background or in CI, then bind it:
(cd <worktree> && <gate command>) > gate.log 2>&1
gstack-ship-queue gate-ahead --record --pr 42 --attempt <id> --exit $? --log gate.log
GATE_RECORD: pr=42 attempt=<id> exit=0 gated_tree=<tree> log_sha256=<sha256> current=yes. The record keeps the log and removes only that attempt's
worktree, and refuses (GATE_AHEAD_ATTEMPT_INVALID) when the worktree no
longer holds the gated commit. If the predecessor moves, prepare a new
attempt; attempts never share a worktree, the newest is current, and
recording an older one later prints current=no (superseded by <id>).
gate-ahead --list --pr 42 shows them all. Send the record file with READY.
3. Coordinator: call the slot. Name the version, the base sha and the
order: "#42 next at 1.2.7.0, base <sha>, order 41,42".
4. Lane: stamp. A gate-ahead lane first merges origin/main (with the
predecessor in it) into its branch, never a rebase. Then:
gstack-ship-queue stamp --pr 42 --version 1.2.7.0 --expect-gated <gated> \
--expect-base <sha> --expect-order 41,42 --allow-repo-commands
<gated> is the head the gate ran on: the pushed head the lane gated, or the
record's gated= after a gate-ahead. The preflight refuses before anything is
written unless the tree to stamp equals the gated one modulo stamps, the base
is <sha> and, under full_e2e, a passing gate result is bound to the gated
head. Then it runs the restamp, commits, reruns the tree receipt and prints
QUEUE_STAMP: 42 1.2.7.0 head=<sha> gated=<sha> tree=same-modulo-stamps receipt=<sha256>
Each stage is journaled in .gstack/tmp/ship-queue/stamp-42.json; the
identical command resumes at the first unfinished stage, or returns the stored
QUEUE_STAMP when it already finished. A refusal prints
QUEUE_STAMP_REFUSED: 42 head=<sha> committed=no|yes <sha> stage=<stage>
and the coded line (QUEUE_STALE carries a recompute: command). Push the
stamp commit, wait for CI on it, run ready again
(READY #42 <head> STAMP_VERSION=1.2.7.0) and send it with the
QUEUE_STAMP line.
5. Coordinator: verify, then merge. From the coordinator's own clone:
gstack-ship-queue verify --pr 42 --gated <gated> --receipt <sha256> --expect-head <head> [--gate-exit 0 --gate-log <file>]
gstack-ship-queue verify --pr 42 --record <gate-ahead record> --receipt <sha256> --expect-head <head>
It fetches refs/pull/42/head and reruns the tree receipt there, so
QUEUE_STAMP is never a pasted claim: VERIFY: 42 1.2.7.0 head=<sha> gated=<sha> tree=same-modulo-stamps receipt=<same sha256>. Under full_e2e,
--gated also needs the lane's passing gate log from its run on that head
(--gate-exit 0 --gate-log <file>), else QUEUE_GATE_MISSING. With a gate-ahead
record, whose gated commit never left the lane's machine, it first recomputes
the merge with git merge-tree --write-tree <branch head> <predecessor head>
and compares it with the recorded merge tree, rebuilds the gated tree from
the recorded stamp patch and compares it with the recorded gated_tree,
proves that patch is stamps only, checks the predecessor did not move, and
prints merge_tree:, gated_tree: and gate: lines. The last line is
MERGE_CONDITION: sha=<head>: merge with that expected head. Any
QUEUE_VERIFY_MISMATCH means do not merge.
Rolling out gate_command and full_e2e. Commit them (with
min_gstack_version at their introducing version, which init --explain
prints) only after every lane's gstack is at least that version, the crew's
install floor (GSTACK_MIN_REVISION). A lane on an older gstack refuses the
policy by name rather than reading it differently.
Run a merge train
A merge train gates several READY PRs with one full gate instead of one gate each. The coordinator merges the pinned, unstamped PR heads in slot order onto main in a scratch worktree, records each prefix tree (T0 is main, Tk is the train head), runs the free tests and then one full gate on the train head, and merges the PRs one at a time, each only while main's tree equals the prefix the gate covered modulo stamps. A red train is bisected on its red cases only.
The surface is off unless the base revision's .gstack/ship-policy.json
declares a train block. Without one, train … and verify --train refuse
with QUEUE_TRAIN_OFF, and ready --pr <n> prints exactly the per-PR READY
block above (--screen, --no-screen and --broad are ignored with a
warning on stderr). A block for gstack itself would read:
"train": {
"gate_workflow": "evals.yml", "free_workflow": "free-tests.yml",
"full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record",
"max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid",
"trial_reservation_usd": 1.5
},
"min_gstack_version": "<the version that introduced train>"
gstack's own policy carries this block. It went live after the workflow side
(the train-nonce record, the cases and prefix_shas inputs, the dispatched
screen) merged and one dry-run train passed: two unstamped scratch docs PRs on
a scratch base whose route step read the policy from that base instead of
the default branch. gate_workflow is dispatched with
evals_all, expected_sha and nonce (bisects add cases and
prefix_shas, screens add screen_pr, screen_merge_sha and
screen_policy). That run publishes full_gate_check as a check run on the
train head and the train record as the record_artifact artifact only when
its census is complete and green; gstack reads both and never writes a
check. screen_selection: gstack-paid computes gstack's paid case selection
for the screen recommendation; none leaves the choice to the lane.
trial_reservation_usd is what the bisect reserves for a trial without cost
data.
Every command prints its lines, the coded error line on stderr, then
NEXT: <command> and GSTACK_RESULT: skill=ship-queue status=… run=<record>.
Records live under $GSTACK_STATE_ROOT/ship-queue/ (trains/<nonce>/train.json
with one attempt record per dispatch, ready/ receipts and freeze/) and
are mirrored to origin as git notes on refs/notes/gstack-trains before any
remote effect, so a lost machine loses nothing: train status on a clean
state root rebuilds them. GSTACK_SHIP_QUEUE_NOW pins the clock for tests.
Stamped trains under changelog_fragments: train land
When the base policy also declares changelog_fragments,
train build builds every prefix twice. T_i is the plain chain above. S_i is
what slot i publishes: S_0 is the base and S_i = stamp(merge(S_(i-1), pin_i)),
stamped by gstack-restamp with S_(i-1) as its base, so slot i folds only its
own fragments (a stacked pin's parent fragments are already folded) and the
versions run in slot order, each with its slot's effective bump. Before
anything is pushed, each S_i must be merge(S_(i-1), pin_i) modulo stamps,
keep every release entry of S_(i-1) byte for byte, and equal T_i outside
release paths (version files, CHANGELOG, fragments, release_outputs,
stamp_paths, stamp_files); otherwise QUEUE_TRAIN_STAMP_MISMATCH. Pass
--allow-repo-commands when the policy has a restamp_command or
release_tool. The record keeps both chains (prefixes and stamped), the
train head and branch are S_k, and the one full gate runs on S_k, the exact
tree the last merge publishes.
Slots merge with train land --train <nonce> --pr <n>, never a slot call:
- The record must be the gated head's own chain. Walking first parents from
S_k gives T0 and S_1..S_k with the recorded commits and trees, and each
S_i's second parent is slot i's pin. A record rebuilt from notes that
differs anywhere refuses (
QUEUE_PROOF_MISSING), and every later step uses only that verified chain. main's tree must equal S_(i-1)'s exactly (slot 1: the train base), and the PR's head its pin, with its /ship receipt. main equal to an earlier prefix refuses and names the slot to land first; main equal to a later prefix confirms the slot (a rerun). A base that cannot be fetched refuses (QUEUE_PROOF_MISSING), a fork head refuses (QUEUE_TRAIN_UNTRUSTED), and a PR whose base is no longer the train's base refuses (QUEUE_NOT_READY), here and again before the merge. - It commits S_i's tree with parents [pin, main], adds the
evals-no-debouncelabel (a tool-made head gets no later push, so the 10-minute quiet window protects nothing), and pushes under--force-with-lease=<branch>:<pin>; a foreign push is never overwritten (QUEUE_TRAIN_MOVED). The stamp commit is deterministic and journaled (land-<n>.jsonbeside the train record) before the push, so a rerun adopts its own stamp head instead of reading it as a move. A push that fails while the branch still reads the pin isQUEUE_NOT_READY; rerun. - It waits for the PR's checks on that commit (
--no-waitprints the command). Required checks run live at the slot until GSTA-156's exact-tree reuse is on main. Every name inchangelog_fragments.required_checksmust be present and green on the stamp commit (a check run of that name, all of its<name> (…)matrix runs, or a status context); a missing one holds the merge (QUEUE_NOT_READY) and the fix names it. Without the list, any green set counts, so set it to the checks branch protection does not enforce. - It refuses if main moved during the checks, merges with
--match-head-commit, and confirms main's tree is S_i exactly. Any other main is the freeze path above (QUEUE_TRAIN_TREE_MISMATCH, backstop). A slot counts as merged only when GitHub reports its PR MERGED. Main at S_i with the PR still open refuses (QUEUE_PROOF_MISSING), and the train's merged count, its freeze decision and the freeze record read GitHub's state for every slot, never the record'smergedlist alone.
verify --train refuses a stamped train and names train land. The backstop
covers main only when its exact tree is some S_i of a green record (plain
T_i records still cover modulo stamps). A dropped slot changes every later
version, so the train is rebuilt, as today.
0. Lane: /ship before READY. Every READY follows a /ship run on the branch
at the head being READY'd, including a re-READY after a push (Garry,
2026-10-25: "we should really be using the gstack ship skill before merging
PRs"). /ship runs the tests, both reviews, the gate-integrity list and the docs
audit, and ends by writing its receipt into the PR body
(gstack-ship-receipt write --pr <n> … --body <file>, posted with
gstack-post pr-body). Under queue_mode: stamp-at-merge it bumps no version
and never merges or merges main into the branch; the train still stamps at the
slot. The queue enforces this at every step from the PR body alone:
readyrefuses withQUEUE_RECEIPT_MISSINGwhen the body has no valid receipt and withQUEUE_RECEIPT_STALEwhen the receipt'sheadis not the head being READY'd (or itspris another PR); the informationalSHIP_RECEIPT:line still prints. It refusesQUEUE_RECEIPT_DEPTH_RAISED(with aREVIEW_DEPTH:line) when the receipt records a review depth below what the head needs, andQUEUE_RECEIPT_EXCEPTION_INVALIDwhen an owner exception it records does not stand (the ship receipt). There is no flag around it.train buildrefuses a pin whose PR has no current receipt with the same codes, naming the PR, before anything is written, pushed or dispatched,--dry-runincluded.verify --trainrefuses the slot withQUEUE_RECEIPT_MISSINGorQUEUE_RECEIPT_STALEbefore printingMERGE_CONDITION; it does not recompute the review depth.
The stamp keeps the pin's receipt. The slot's stamp commit changes only
stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs
on it: the receipt written for the pinned head stays the binding one. verify --train accepts a receipt whose head is either the slot's pinned head or
the current head, and the re-READY after the stamp push (ready at the stamp
commit) accepts the receipt for the pin when HEAD is the pin plus stamp files,
or (from slot 2 on) the pin merged with main plus stamp files on top of the
merge; any other head is QUEUE_RECEIPT_STALE, including a merge of main with
no stamp above it (a lane's own push under rule 14) and a merge whose pin side
carries an edit made after /ship.
This is a process gate, not an authenticity check: the receipt is a block in
the PR body, which the PR author writes, so the codes prove that /ship was run
and recorded for this head, not that nobody typed the block by hand. Fork heads
are already refused (QUEUE_TRAIN_UNTRUSTED). The ship-run.json that
gstack-ship-receipt result writes under the lane's state root is a
normalized copy of that same block bound to the head it was run at, not an
authenticity proof either.
1. Lane: READY with a screen choice. Under a train policy READY needs an explicit choice:
gstack-ship-queue ready --pr 42 # prints SCREEN: lines and the recommendation, refuses
gstack-ship-queue ready --pr 42 --no-screen # READY receipt; the PR rides unscreened
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (a scratch merge when GitHub's merge ref lags the head; QUEUE_SCREEN_STALE_REF only on a real conflict)
Without a choice it prints the SCREEN: selection (with
screen_selection: gstack-paid, the skill's own cases, the selector's mode and
recommend --screen or --no-screen: --screen only when the selector's
mode is full-fallback, an unknown dependency or a shared runtime input that
restores every case, or when it cannot select at all; every other PR rides
unscreened, and the train gate's full census runs its cases on the tree that
merges) and refuses with READY_SCREEN_CHOICE_REQUIRED. The price line says
the dollar figure is pending the cost replay; until it lands, SCREEN: shows
case counts and the recommendation only. A lane that already ran focused
local cases says so in the READY block and may pass --no-screen. --screen
dispatches gate_workflow with screen_pr, screen_merge_sha (the
refs/pull/<n>/merge SHA) and screen_policy (narrow, or broad with
--broad, the full PR selection). A narrow screen, and eval:bg:pr, skip a
changed file whose derived references reach every gate case and nothing
narrower selects, along with its derived judges: the plan's reason names it,
and the train gate's full census runs those cases. A pull_request run, the
merge gate when the base has no train block, keeps the broad selection. Full-gate files, shared runtime and paid-runner
inputs, and files the derivation cannot place still run the full gate on a
narrow screen. The nonce key carries the purpose and
the policy, so a repeat follows the same run and a broad screen never adopts
a narrow one. The READY block gains screen: …, and the receipt is written
locally and to the notes ref. --dry-run prints the dispatch without sending
it. Before dispatching, ready fetches the merge ref and requires its second
parent to be the PR head and its first parent the current base: a missing,
lagging or unfetchable merge ref (GitHub stops recomputing it while the PR
conflicts with the base, CHANGELOG-only conflicts included) is replaced by a
scratch merge of the head onto the base, pushed as screen/<pr>-<head12>
(the SCREEN_MERGE: line says which); a screen dispatched on a stale ref would
plan no cases and report success. Only a real conflict with the base (or a
remote whose refs cannot be listed) is QUEUE_SCREEN_STALE_REF. Under either
choice ready first reads what gstack-pr-wait's base-overlap event reports,
from git: when the base moved since the head forked and its new commits touch
files the PR changes, BASE_OVERLAP: <base> moved to <sha7> and touches files in this PR: <files>; the train's merge onto <base> is clean (or conflicts beyond the CHANGELOG and the stamp files (<files>)), the train's own merge
rules run as a dry run in object storage; a git read that fails prints
conflicts unknown and refuses nothing. A conflict under --no-screen is
QUEUE_TRAIN_CONFLICT at READY, since the train would stop at this PR: the
lane reports it to the controller and, on its word (crew template rule 14),
merges the base into the pin, resolves, pushes and runs /ship before READY,
instead of learning it when the train is built. A finished screen that planned no cases (no
paid-plan artifact, which a superseded run never uploads, or a plan manifest
with nothing planned) is never followed as a screen: ready --screen
dispatches a fresh one and train build prints SCREEN: #<n> run <id> planned no cases …; counted as not-run and seats the slot unscreened. A plan the
queue cannot read keeps the slot screened, with SCREEN: #<n> <reason>; counted as screened, unverified.
2. Coordinator: build. Name the pins exactly as READY reported them:
gstack-ship-queue train build --prs 41@<sha>,42@<sha>,43@<sha> --dry-run
gstack-ship-queue train build --prs 41@<sha>,42@<sha>,43@<sha>
It prints ORDER: (the slot order to announce: screened PRs first in READY
order, then unscreened PRs fill the remaining seats, and HELD: lists the PRs
over max_size; --order <pr>,<pr>,... naming every
pinned PR once replaces that rule, for a PR that must merge before another,
and the train's later NEXT: rebuilds keep it), one PREFIX: T<i> <commit> tree=<tree> per prefix and one SLOT: per PR. --dry-run adds the
ESTIMATE: line and writes, pushes and dispatches nothing. Otherwise it
writes train.json, pushes train/<nonce>, dispatches free_workflow there
and waits (--free-timeout <s>, default 2700) for a green
workflow_dispatch run on that exact SHA, then dispatches the full gate
through eval-bg's nonce. A train fills at max_size READY PRs or when its
oldest READY has waited fill_hours, whichever comes first; until then
QUEUE_TRAIN_WAITING names the time, and --urgent dispatches a one-PR
train at once. The scratch merges run before the fill rule: every train build prints #<pr> BASE_OVERLAP: <base> moved to <sha7> and touches files in this PR: <files> for a seated pin whose files the base moved onto since it
forked (the chain's merge onto the pin's own prefix is the verdict), refuses
QUEUE_TRAIN_CONFLICT for a pin that does not merge, and otherwise prints
MERGE_CHECK: T1..T<k> merge clean onto <base>@<sha>, so a
QUEUE_TRAIN_WAITING result is also a clean dry-run merge of the pool (the
crew template's sibling trial merge, done by the tool). gstack's own block fills at 3 PRs or one hour (it was 5 PRs or
three hours until 2026-10-26, which left READY PRs waiting most of a day). The same pins on the same base return the live train
instead of a second one; train build --train <nonce> resumes a train from
its record. Refusals, all before any spend: QUEUE_TRAIN_UNTRUSTED (a fork
head), QUEUE_TRAIN_MOVED (a head moved from its pin),
QUEUE_RECEIPT_MISSING and QUEUE_RECEIPT_STALE (the PR body has no /ship
receipt for the pin; see /ship before READY),
QUEUE_RECEIPT_DEPTH_RAISED and QUEUE_RECEIPT_EXCEPTION_INVALID (the
receipt's review depth is below what the pin needs, or an owner exception it
records does not stand; see the ship receipt),
QUEUE_NOT_READY (no READY receipt at that head), QUEUE_TRAIN_CONFLICT (the printed
QUEUE_TRAIN_CONFLICT: train <id> stopped at #<pr> (conflicts: <files>) after merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: … line names
the build without that PR) and QUEUE_TRAIN_FREE_RED (free tests red,
missing, cancelled or on another SHA). In a merge, the version file takes the
prefix's side, a manifest or npm lockfile conflict merges three-way with the
prefix's version (so a pin's dependency edit survives), and the CHANGELOG
merges by section, since the stamp at merge rewrites them; a PR whose CHANGELOG drops or renames a released heading (or
carries a released entry's text in a section of its own) is a CHANGELOG
conflict, never merged with the damage, except the stacked child whose parent
was released since their merge base, which mergeChangelogs carries (see A
stacked child whose parent merged earlier, below). READY receipts and train records are
keyed on origin's GitHub owner/repo, so lanes whose origin uses another URL
form (git.capy.ai, ssh, a token URL) share the coordinator's queue.
3. Coordinator: status and verdict.
gstack-ship-queue train status [--train <nonce>]
gstack-ship-queue train verdict --train <nonce>
status never spends. When a dispatch was attempted but its run never showed
up (QUEUE_TRAIN_DISPATCH_UNKNOWN), it lists the workflow's dispatch runs and
either adopts the run carrying the nonce or marks the dispatch absent; only
an absent dispatch is sent again, by train build --train <nonce>, under the
same nonce. verdict reads the run, its record and the full_gate_check
check: GREEN only for a complete census bound to the train nonce, head and
tree; QUEUE_TRAIN_PENDING while it runs; QUEUE_TRAIN_INCOMPLETE for INFRA
or INCOMPLETE panels, unbound, partial or cancelled shards, a slice killed
before upload, or no record (never green); QUEUE_TRAIN_RED with the red
cases.
4. A red train: bisect, then at most one rebuild.
gstack-ship-queue train bisect --train <nonce> --spend-cap 5 --dry-run
gstack-ship-queue train bisect --train <nonce> --spend-cap 5
gstack-ship-queue train rebuild --train <nonce>
The bisect dispatches the red cases (cases) on every prefix T0..Tk
(prefix_shas; for a stamped train, T0 and S_1..S_k, the
chain its gate ran), one trial each, after checking every prefix is an ancestor
of train/<nonce> (QUEUE_TRAIN_PREFIX_INVALID). It reserves every
case×prefix trial (unknown costs at trial_reservation_usd) and launches only
the prefixes that fit the cap (QUEUE_TRAIN_BISECT_CAPPED; completed prefixes
are kept and a rerun with a higher cap runs only the rest). Rerun it to read
the results. The pattern decides: ATTRIBUTED #<pr> only when monotone
(green before, red from that PR on), and NEXT: builds the train without it;
red on every prefix with T0 included is QUEUE_TRAIN_RED_ON_MAIN (fix forward
on main first); anything else is QUEUE_TRAIN_UNATTRIBUTED. An all-green
bisect earns exactly one unchanged rebuild (train rebuild, reporting both
runs); a non-monotone pattern earns none and goes to a repair lane. Every
other rebuild is QUEUE_TRAIN_REBUILD_REFUSED, and a second red stays red.
An INFRA/INCOMPLETE-only census earns the single re-dispatch
EVAL_POLICY.infraRedispatch allows: train rebuild --train <nonce> --infra.
The rebuild-once rule lives in the train module beside that constant until
EVAL_POLICY records it. Bisect results never turn a train green.
Stamping a slot. When the slot is called, the lane stamps it at main plus PATCH. Slot 1's pin applies cleanly to main. From slot 2 on, main already carries the earlier slots' stamps, so the pin conflicts on CHANGELOG.md and GitHub can't merge it. The lane merges main into the pin first, then stamps:
git fetch origin main
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
bun --no-env-file --config=/dev/null -e "import { mergeChangelogsWithNotes } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogsWithNotes(s(2), s(3), s(1)); for (const n of m.notes) console.error('CHANGELOG: carried as a Gate integrity correction: ' + n); if (!m.text) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m.text);"
git add CHANGELOG.md && git commit --no-edit
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands
git add CHANGELOG.md VERSION package.json <release outputs> && git commit -m "v<X> stamp" && git push
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. verify accepts the resulting head because it contains main and its tree equals T modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the head: line says "T with main merged in".
The stamp commit is the base merge plus the stamp files, never a fifth. The
restamp writes the version files, the CHANGELOG, the release_outputs and the
stamp_paths lines the branch itself added (gstack: CHANGELOG.md, VERSION,
package.json, agents-digest/gstack-AGENTS.md, and a *_KEY_VERSION the PR
introduced). A line the base added since the pin's fork point is the base's,
whether the base merge is committed or still staged (stage stamp_paths says
"N line(s) the base added since the fork point are the base's"; train 24,
2026-10-27, moved main's DOCS_CAPS_KEY_VERSION in both slots before this
rule). A file the restamp lists that the slot call did not name is not a stamp:
leave it out and report it. The tree receipt refuses such a move after the fact
only while the moved version is older than the base's at the build, so the
restamp, not the receipt, is what keeps the base's key in place.
Size caps compose; nothing re-pins at the slot. Ship's union-size cap is a
ledger, test/size-ledger/ship/: base.json plus one <branch>.json per
branch that changed ship's union bytes ({ "bytes": <signed>, "why": "…" }),
and the parity suite requires ship's union bytes to equal the base plus every
entry, exactly. A lane records its own change once, before /ship, with bun --no-env-file --config=/dev/null scripts/size-ledger.ts record ship --why "<what changed>". The entry is a property of the branch's own diff, so it
stays right across base merges, and each prefix carries the entries of the
slots in it, so T passes with no re-pin and the stamp commit stays the four
files (a cap re-pinned at the slot would be a fifth file on a tree the gate
never ran). A clean merge adds byte deltas exactly; overlapping hunks are a
train conflict anyway, and two branches making an identical edit (git keeps
one copy, both entries count) fail closed with the same record command as the
fix. A branch that grows ship beyond its entry, or shrinks it without a
negative one, fails on its own tree and on every prefix that includes it.
scripts/size-ledger.ts check ship prints the ledger; fold ship --measured-at <tree> moves the entries into base.json in a tidy-up PR,
never in a stamp commit. Other skills keep their maxSizeRatio caps in
test/helpers/carve-guards.ts.
A stacked child whose parent merged earlier. When the pin was stacked on a
PR that squash-merged in an earlier slot or train, the base merge conflicts
beyond the CHANGELOG wherever the parent's squash and the pin touched the same
lines. Take the pin's bytes for those files: verify checks the tree against
T, the train's own merge of that pin, so the parent's content is already in
the prefix. The CHANGELOG is mergeChangelogs' job and needs no hand merge for
this shape: the pin's [Unreleased] still holds the parent's entry (as forked,
maybe edited) while main carries it under a released heading the merge base
lacks, so the parent's lines leave the child's section and each edit the child
made to them becomes a - Correction to the <version> entry below: "<old>" should read "<new>". bullet under the child's own ### Gate integrity (the
one-liner prints each carried correction). The released entry stays main's byte
for byte, and verify accepts those bullets as the pin's own lines because
the tool writes them. A pin that does not carry base can still be refused here
(ours's Unreleased section holds released <version> text): that is the
GSTA-67 shape, a released heading dropped by hand, and stays a hand merge.
A generator change on an unstamped head. A release output such as
agents-digest/gstack-AGENTS.md is stamped at the slot, and an unstamped PR
changes it the ordinary way, which is to edit the generator, regenerate at the
branch's own VERSION (its merge base's), and commit both. CI's Version Gate
regenerates the output at that VERSION and reads a byte-identical result as
unstamped (what /ship reads, since 1.91.237.0); the freshness test
compares against the checkout's VERSION, so it passes before and after the
slot's restamp regenerates the file at the new version. Never revert a
regenerated output to the base's bytes to get past the gate (#3434 did, on a
gate older than that rule, and shipped a stale digest line until its merge).
5. Coordinator: verify, merge, verify again. For each slot in order:
gstack-ship-queue verify --pr 41 --train <nonce> # MERGE_CONDITION: sha=<head>
# merge #41 with that expected head; gstack-restamp stamps it at merge
gstack-ship-queue verify --pr 41 --train <nonce> # VERIFY_TRAIN: #41 merged; main's tree is T1 …
Before a merge, main's tree must equal the prefix before the PR modulo stamps
and the PR must be at its pin (stamps aside) or contain main and be T
modulo stamps (its pin with main merged in; its CHANGELOG must be main's with
only lines from the pin's own CHANGELOG diff, or lines mergeChangelogs
writes for that pin against main, above it); after it, main's tree must equal
the prefix after it modulo stamps, or differ from it in the CHANGELOG alone
while being its own first parent's CHANGELOG (the main the pre-merge verify
saw, itself the prefix before modulo stamps) plus the PR's own entry under the
same rule. One CHANGELOG rule applies before and after, so a head the pre-merge
verify accepted confirms after its squash merge with the same CHANGELOG, and a
stacked child whose one [Unreleased] became two released sections is not a
mismatch (train 24, 2026-10-27, froze on exactly that). Anything else is
QUEUE_TRAIN_TREE_MISMATCH (or QUEUE_TRAIN_MOVED). When nothing of the train
has merged, that is a plain refusal, and a new train is built on current main. When
earlier slots already merged, main holds a prefix no gate covered on its own,
so verify freezes the queue. The freeze goes to local state and the notes ref
first, then the full gate is dispatched on main's SHA at once (the backstop),
and the QUEUE_TRAIN_FROZEN: line lists the merged PRs, main's tree and the
backstop run. A slot GitHub reports MERGED counts as merged in that record,
verified tree or not, so the rebuild after the repair never seats it again
(the reason says "merged on GitHub without a verified tree"); a first slot
whose squash GitHub reports MERGED but whose tree fails the post-merge check
freezes the same way, since main already holds the unverified squash. The
record comes from the one GitHub read that decided to freeze, never a second
one: a slot that read MERGED stays merged even when GitHub stops answering
before the record is written, and a slot unreadable beside a merged one is
left out of unmerged, recorded under unreadable and printed on an
unreadable: line; the NEXT: after the repair names it (gh pr view <n> --json state,headRefOid (unreadable at the freeze: #<n>@<sha>); seat each that is OPEN in a train).
When every slot of the train already merged on GitHub and main's SHA is a
prefix another train's verify confirmed (read after the notes ref, so a
machine that never ran that train still sees it), the mismatch says nothing
about this train: verify refuses with QUEUE_TRAIN_STALE and NEXT: gstack-ship-queue train prune --stale --train <nonce>, freezing nothing and
dispatching no backstop (train 24's record froze the queue that way on a main
train 28 had verified, #3519). Only a verify that fetched main takes this
exit: under --no-fetch the local main may be older than GitHub's, so the
mismatch freezes as before. When
whether a slot merged on GitHub cannot be read, verify refuses
(QUEUE_PROOF_MISSING, "whether # merged on GitHub could not be read")
and asks to be rerun once gh pr view answers; an unreadable state is never
"nothing merged". While frozen, ordinary trains and other trains' merges are refused
(QUEUE_TRAIN_FROZEN). Admit one repair PR at its exact head:
gstack-ship-queue train build --repair --prs 44@<sha>
It runs the normal free tests, full gate and verification on main plus the
repair. When verify confirms the repair merged, it prints FREEZE: cleared
and NEXT: builds a train of the PRs still unmerged.
5b. Coordinator: after the merge. gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (the merged commit's full 40-character SHA) watches
every check run attached to it (the push workflows: free tests, the Windows
subset, the main backstop) and every commit status until none is still
running, prints one CHECK: <name> <conclusion> per check and
POST_MERGE: <sha12> green|red|pending|none (passed= failed= pending= skipped=).
Green needs at least one completed check and no failure; neutral and
skipped (the backstop superseding itself) count as skipped; a pending
commit status keeps the commit pending and a failed one makes it red. A
failure is QUEUE_POST_MERGE_RED naming the checks; a timeout (default 2700 s) is
QUEUE_TRAIN_PENDING with the same command as NEXT:. It keeps no process
state, so rerunning it after a machine restart continues the watch.
6. Clean up. gstack-ship-queue train prune [--train <nonce>] [--dry-run]
deletes the train/<nonce> branches of merged, superseded and dropped trains
(WOULD-DELETE:, DELETED:, GONE:). train prune --stale also settles a
merging or green train whose every PR is MERGED or CLOSED on GitHub (its
PRs landed through another train or by hand): it logs each PR's state on a
STALE: line, marks the record stale and deletes its branch; a train with an
OPEN PR is kept (KEEP: … still needs the train). The same run deletes every
screen/<pr>-* scratch branch whose PR is MERGED or CLOSED and keeps one whose
PR is still OPEN. train forget --train <nonce> | --receipt <pr>@<sha> [--dry-run] removes one record (a settled train with its attempts,
or one READY receipt) from local state and from refs/notes/gstack-trains
with an exact lease on the notes ref, printing FORGET: then FORGOTTEN:; a
train that is still building, running or merging (built, pushed,
free-pending, free-green, dispatch-unknown, dispatch-absent,
gate-pending, merging) is refused; a rejected lease removes nothing and
restores the local notes ref.
Following a train without a daemon. gstack-ship-queue train watch --train <nonce> [--timeout <s>] is the one resumable follow command: it reads the
record, runs the step its state asks for (train build --train to push, run
the free tests and dispatch the gate; train status to settle an unconfirmed
dispatch), waits for the pending run by polling gh, reads the verdict and
prints the last step's NEXT:. It never dispatches anything the record would
not dispatch on its own, so rerunning it after an interruption continues from
the record (WATCH: lines say what it did; a timeout, default 7200 s, is
QUEUE_TRAIN_PENDING with the same command as NEXT:; --free-timeout <s>,
default 2700, bounds the wait for the free tests).
Screens are read from the run. ready --screen and verify --pr <n> --screen read a dispatched screen's verdict from the run's own artifacts
(gate-binding-a<attempt>, report-verdict-a<attempt>), never from a lane's
prose, and record it in the READY receipt (screen.verdict): SCREEN_VERDICT: #<n> run <id> GREEN|RED|NOT-RUN|INCOMPLETE [red=…] [flaky=…] plus one
FLAKY: line per rule-16 flaky case. PENDING is a run still going,
UNKNOWN one the queue cannot read, and INCOMPLETE a finished run that bound
no verdict (its report job did not finish): ready --screen dispatches afresh
instead of following it, verify --screen refuses it (QUEUE_PROOF_MISSING)
and train build seats the pin unscreened, as for NOT-RUN. A RED screen refuses ready under
either choice, is never re-dispatched at the same merge SHA and is not seated
by train build, whose NEXT: names the build without that PR
(QUEUE_SCREEN_RED); a run that concluded success with no slice (its route
job found the head superseded or the plan selected nothing) is NOT-RUN:
verify --screen refuses with QUEUE_SCREEN_NOT_RUN, ready --screen
dispatches afresh instead of following it, and train build seats the pin
unscreened with a SCREEN: … counted as not-run line. When GitHub's
refs/pull/<n>/merge lags the PR head or is missing, ready --screen plans
the screen on a scratch merge of the head onto the base, pushed as
screen/<pr>-<head12> (SCREEN_MERGE: names it; prune --stale deletes it
once the PR merged or closed); only a real conflict with the base (or a remote
whose refs cannot be listed) is QUEUE_SCREEN_STALE_REF.
Stacked tests before the gate. train build --prs … --dry-run --stack-tests [--stack-tests-command "<cmd>"] runs each PR's new test files (added relative
to the base, with a name ending in .test, _test, .spec or _spec and a
JS or TS extension, bun's own rule, so a helper or fixture under test/ is not
one) on the integrated prefix that includes it, T, in a scratch worktree, and prints
STACK_TESTS: #<pr> T<k> <n> new test file(s) pass|FAIL (<files>) or no new test files. A failure is QUEUE_TRAIN_STACK_RED (nothing written, pushed or
dispatched): the tests pass on the PR head alone only because nothing before
it in the train had changed what they read, so fix them against that prefix or
reorder with --order. The default command is bun --no-env-file --config=/dev/null test with the files appended.
Escape hatches. Remove the train block on the base branch to return
every lane to the per-PR queue at once. --urgent ships one PR alone,
--no-screen skips the paid screen, --broad widens it to the full PR selection,
and train build --prs <list without it> drops a PR at any step.
What /ship reads from it
/ship Step 3 runs show-effective --base <base> and follows its ship: lines,
which carry the workflow consequences the skill prose would otherwise spell out:
| Line | Effect |
|---|---|
ship: history merge-only: … |
never rebase, squash or force-push; check-history --plan <merge|squash|rebase> refuses a rewrite plan (HISTORY_POLICY_VIOLATION, exit 3) |
ship: queue_mode stamp-at-merge: … |
Step 12 items 1–4 are replaced by gstack-restamp --next --base <base> --pr <n> --expect-base <sha> when the PR is next, --after <predecessor-pr> when it is not; the restamp's own next: line says what follows: under stamp-at-merge, commit and push only the stamp diff above (FILES), with no edit of your own and the merge's expected head (the stamp commit holds restamp's output alone); under the per-PR queue, NEW_VERSION and the CHANGELOG entry text |
ship: shared merge queue: … |
in a coordinator-run queue, gstack-ship-queue ready, gate-ahead and stamp replace the restamp calls; see Run a shared merge queue |
ship: gate-ahead: … |
with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json marks reusable; stamp-sensitive and not-eligible lanes run live; --force-gate <reason> is the coordinator's call |
ship: bump patch: … |
Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size |
ship: bump never: … |
Step 12 prints VERSION: <v> kept (policy: bump never; no write), asks nothing and refuses --bump; a branch that moved VERSION stops until it is put back |
ship: bump ask: … |
Step 12 asks for MINOR and MAJOR; on FRESH, --keep-current (or --version <current>) keeps VERSION, and in a session no person answers any version Step 12 would write waits for --answer |
ship: preregistration: … |
gstack-evidence ancestor --from-policy --base <base> must print only ANCESTOR: ok before push |
ship: release_tool: … |
the command runs only inside gstack-restamp (staging worktree, outputs confined to release_outputs) |
ship: review_scope … |
Step 9.4 item 0 runs gstack-review-scope classify on each pass's findings; see review_scope |
ship: changelog_fragments <dir>: … |
Step 12 writes no version; Step 13 writes the entry as <dir>/<branch-slug>.md (entry text only, bump: front matter only when the change warrants more than the policy bump), never an [Unreleased] section, linted with gstack-prose-lint <file> --gate; gstack-ship-queue land assigns the version; see changelog fragments |
The PR body's first section is the ship receipt (gstack-ship-receipt write … --body),
written by the pr-body section of /ship after the body is composed.
The ship receipt
The ```gstack-ship-receipt block is the handoff record: identifiers
first (pr, head, version, base), then the evidence (gate, ci,
head_tree, product_patch_id, gstack_version, review_depth, pregate,
issues, approval, docs, review_loop, review_loop_exception, carry),
validated
against the ship-receipt schema (gstack-artifact schema ship-receipt) and
read back by gstack-ship-receipt read, result, carry and the queue.
version. The version source at HEAD: the path .gstack/version-path
pins, else VERSION, else package.json#version. A repository with none of
those, none of the policy's stamp_files and no CHANGELOG (the policy's
changelog, or CHANGELOG.md) at HEAD has no release version to record, and
the receipt says so with "version": "none": /ship Step 12 printed
Version bump: skipped (no version source: <what was looked for>) and Step 13
CHANGELOG: skipped (none at <path>), the PR title carries no version prefix,
and read, result, gstack-ship-queue ready, train build and verify --train accept the receipt like any other. A repository that has a CHANGELOG
but no version source records no version at all (the audit still says what
it looked for); pass --version to name one.
docs. Step 14.5's outcome, from gstack-ship-receipt write --docs <json|@file>: { status: updated | current | blocked, scope, exception? },
where scope names the docs roots the audit covered and exception is
{ accepted_by, date, via, reason }, the owner's acceptance of a risk the
audit could not clear. A blocked status with no exception is
DOCS_AUDIT_BLOCKED and nothing is
written: only the owner accepts a documentation risk, so an unattended run
stops there. With --body, the body's ## Documentation section must carry
the exception word for word (Documentation exception: accepted by <owner> <date> via <ref>: <reason>), or the write refuses with the same code. Doc
caps: a repository declares docs.caps: [{ path, max_bytes }] in its policy,
and Step 14.5 runs gstack-ship-receipt docs-caps --base <base> before the
verification gate, reading the caps from origin/<base>'s policy so the branch
cannot raise its own (--policy <file> only while bootstrapping a policy; an
unreadable base policy is POLICY_SOURCE_UNAVAILABLE); one
DOCS_CAP: <path> <bytes>/<max> ok|over line per cap
(absent/<max> over for a path the tree lacks), exit 1 on any over, whose
repair is trimming the file, never raising the cap in the same PR.
review_depth and gstack_version. write records the gstack that
wrote the receipt and the review depth at the checked-out head
(review-policy.md#review-depth):
{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons },
computed from git objects for the range against origin/<base>
(gstack-diff-scope <base> --head <sha> for the scope signals), with the gate
scan run on that head. --head must be the checked-out HEAD
(RECEIPT_INVALID otherwise), so the record and head_tree describe the
tree the review read; tracked edits in the checkout make the scan unavailable,
which records full. gstack-ship-queue ready and train build recompute
the depth the head needs from the same range and scope and print REVIEW_DEPTH: receipt=<d> required=<d> runtime_lines=<n> class=<c> reasons=<…> inputs=base@<sha>,registry@<sha>,gate_scan@<sha|missing>;
a recorded gate scan counts only when its head is the full bound commit,
otherwise the scan is unavailable and the head needs full. A receipt below the required
depth, or one whose writer records gstack_version but no review_depth, is
QUEUE_RECEIPT_DEPTH_RAISED:
the receipt is left unchanged and the fix is /ship on that head. A receipt with
neither field predates depth receipts and is accepted as legacy. A carry
recomputes the depth at the new head, rebinds the gate scan, and refuses when
the new head needs a deeper review than the receipt records (a test change that
skips a test raises the diff to gate class), when --head is not the
checked-out HEAD, when the receipt records gstack_version without
review_depth, or when an owner exception no longer stands, each as
RECEIPT_CARRY_REFUSED.
review_loop_exception. The owner's acceptance of a stop after a
fix-check edit under frozen scope (#3559), from write --review-loop-exception <json|@file>: { accepted_by, date, via, reason, fix_check: { head, findings[, files] } }.
It needs --review-loop with fix_checks of 1 or more, a fix_check.head
that is the receipt head or its ancestor, and, with --body, the line
Review-loop exception: accepted by <owner> <date> via <ref>: <reason> (fix-check at <sha12>, <n> findings)
word for word; otherwise
REVIEW_LOOP_EXCEPTION_INVALID
and nothing is written. The queue checks both owner exceptions the same way
before READY and train build: the record must parse, the body must still
carry its line, and the fix-check head must be the bound head or its ancestor
(after a carry across rewritten history the old fix-check commit is off the
branch, so that check is skipped), or the receipt is
QUEUE_RECEIPT_EXCEPTION_INVALID.
Prose against the receipt. gstack-ship-receipt read --pr <n> --check-prose
(or --body <file>) compares every counter the body quotes with the receipt's
integers under review_loop, gate and ci: passes=2, passes: 2 and
2 passes are claims about review_loop.passes, 1 deferred about
review_loop.deferred, passed: 41 about gate.passed (a leaf key shared by
several counters, passed under gate and ci or pass under every gate
lane, matches when the number equals any of them; an underscore in a key
matches _, a space or -; a name inside a longer word, 3 pass-through, is
not a claim; a number that is the value of a key= or key: pair is that
key's claim alone, so passes=2 blocking_found=1 is two claims, never a
2 blocking_found one, #3513). A body that quotes a different number is
RECEIPT_PROSE_MISMATCH (exit 1) and the PROSE: line names each field with
both values; prose that quotes no counter is not a claim. Data PRs (evals,
measurements, sweeps) run it before READY so the body's numbers are the run's.
review_scope: how the review loop converges
Since the release that added review_scope (REVIEW_SCOPE_KEY_VERSION in
lib/ship-policy.ts). /ship Step 9 runs the pre-landing review in passes and fixes
what it finds between them (review-policy.md is the canonical
statement). Under review_scope: frozen (the default) the loop converges by
construction:
- Pass 1 reviews the whole diff. Every finding goes through
gstack-review-scope classify --base <base> --pass 1 --findings <file>, which gives each one alocus(diff: a hunk the PR changed, with three lines of context;outside: anything else), akind,blockingand ascope.blockingis a CRITICAL defect: a test that would fail, a production break, a security or data-loss defect. Everything else (an advisory, an informational item, a test gap, a wording note) is below the floor. Pass 1 fixes findings in the diff (scope: fix). A blocking finding in code the PR did not change isscope: defer:gstack-review-scope deferfiles it as a GitHub issue (reusing an open issue that carries the same<!-- gstack-deferred-finding: <fingerprint> -->marker; the title and body go through the same publication scan as the PR body, so a finding that quotes a credential staysunfiled (publication scan refused: …);unfiled (<reason>)withoutgh) andrenderwrites it under## Deferred findingsin the PR body. Below-floor findings outside the diff, every below-floor finding from pass 2 on, and every below-floor finding from Step 11's voices or a fix-check (raised_byadversarial-native,outside-voiceorfix-check, even classified at--pass 1) arescope: noteand go under## Review notesin the PR body. - Pass 2 and later review the range since the previous pass. Each pass saves
PASS_HEAD; the next pass setsRANGE_BASEto it, diffsRANGE_BASE..HEADfor every reviewer (the core checklist, specialists, Red Team, the outside voice; the gate scan and QA probes keep the whole candidate) and classifies with--range-base <sha>. Only a blocking finding in that range (locus: fix-range) or in the diff isscope: fix; nothing below the floor earns a pass.NO FINDINGSis the expected reviewer result for a clean range, never a failed review. - Two full passes plus one fix-check is the cap, stepped by the transition table; a scoped pass counts as a full pass. A pass that applied no edits continues to Step 10: the loop ships with its deferred and noted items named in the PR body. A blocking finding still open after the last allowed step stops the run and names the finding (path, severity, problem) for the owner instead of a third full pass.
gstack-review-scope summary --since <REVIEW_SINCE> reads the branch's review-log
rows for this invocation and writes the counters the ship receipt carries
(gstack-ship-receipt write … --review-loop @<file>): review_loop: { passes, blocking_found, deferred, noted, fixed, scope }, plus fix_checks, dispatches
and est_input_tokens when the run recorded them, printed on the receipt summary
line as passes=<n> blocking_found=<k> deferred=<m>, so a scorecard can track
passes per PR.
The floor and the pass structure are stated once, in review-policy.md; this section documents the knob.
review_scope: diff-plus-blocking is the documented fallback for a repository
where deferred issues pile up: the same passes, range and cap as frozen, but a
blocking finding in code the PR did not change is scope: fix in this PR
instead of defer. review_scope: all is the opt-back to the earlier loop:
every finding is scope: fix on every pass, reviewers read the whole diff each
time, and only the three-cycle fix cap stops the loop. show-effective prints the choice as a ship: review_scope …
line, and the codes a refusal ends with are
REVIEW_SCOPE_INPUT and
REVIEW_SCOPE_RANGE.
Carrying a receipt across a base merge or a test-only fix
Merge main into a reviewed branch; never rebase it. What happens to the receipt when the base moves:
| What changed since /ship | The receipt |
|---|---|
| a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate's regen check proved) | carries; no review reruns |
| a merge of the base whose resolution changed product code | a fresh /ship (RECEIPT_CARRY_PRODUCT_CHANGED) |
| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (carry.ancestry: rewritten) |
| any other rebase or force-push | a fresh /ship (RECEIPT_CARRY_TREE_MISMATCH) |
| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason stack parent <branch> landed) |
Stacked PRs (#3570). A child PR's receipt is written against its parent
branch, so its base names that branch and its product_patch_id is the
child's own range. After the parent lands, the PR is retargeted to the base, and
/ship's carry check runs with --base <base>. When that base differs from the
receipt's, the carry compares the reviewed range (the recorded
product_patch_id, or for an older receipt the range against
origin/<parent> while that branch still resolves) with HEAD's whole product
diff against the base, computed the way write records it, so a parent's
release-metadata change that never reached the base counts too. Equal means
the parent fully landed and the child is unchanged, so the receipt carries
and from then on names the base it was carried to. A parent that has not landed, a child edited
since, or a parent that landed with changes the child never merged all leave a
different diff and refuse with RECEIPT_CARRY_PRODUCT_CHANGED. An older
receipt whose parent branch is gone refuses with RECEIPT_CARRY_REFUSED.
Since the release that added review_scope. A /ship receipt names the head it
reviewed, so a conflict-only merge of the base or a test-only fix used to
invalidate it and cost a full re-run on identical product code.
gstack-ship-receipt carry --pr <n> --base <base> (or --body <file>) moves
the receipt to HEAD when the working tree is clean, the receipt's head is an
ancestor of HEAD and git patch-id --verbatim of the product diff against the
base is the same at both heads (verbatim: whitespace counts, hunk line numbers
do not). --verbatim needs git 2.39 or later: the carry probes git --version
first and refuses an older git by name
(RECEIPT_GIT_TOO_OLD) instead of
falling back to the whitespace-stripping default, and a git diff or
patch-id failure is reported with git's own first stderr line. When the
patch-ids differ, the refusal names up to CARRY_NAMED_PATHS (20) product
paths whose patch against the base changed, walking only the paths the branch
itself touched at either head (a base merge brings in every path the base
changed, none of which can differ between the two base diffs). Product means every changed path that is not test code (test/**,
spec/**, e2e/**, **/*.test.<code ext>, **/*.spec.<code ext> and the
other CARRY_TEST_GLOBS in lib/ship-receipt.ts; an openapi.spec.yaml is
product) or release metadata (CHANGELOG.md, agents-digest/**, each
<changelog_fragments.dir>/<slug>.md fragment when the base policy sets
changelog_fragments (not the directory's README.md or a nested path; a README
change also makes the range's fragments product), and VERSION
or package.json only while the change is a version stamp and nothing else: a
dependency, a script or a new key makes them product). A path the policy's
stamp_files, stamp_paths or release_outputs name is metadata only while
its change is a stamp, the rule the tree receipt applies to the same paths: a
manifest by its version key, any other file when its new text reads as the old
once the new release version is replaced by the old one, or when every changed
line is a stamp-form rewrite to the new version of the old version or of a
newer tentative one the stamp moved (a *_KEY_VERSION written as the next
minor, #3512; a version older than the old one or of another width is a hand
edit). A logic change in a
stamped source file is product. Lockfiles are product unless the policy names
them. An output of an entry with a command in origin/'s
.gstack/generated.json is generated, not product, only when the
--pregate result passed for HEAD lists it under the regen check's
proven paths (the check deleted the touched output in its scratch worktree
and the command recreated it byte-identically) and that check ran the same
registry, origin/ at the commit the carry reads. A file that matches an
output glob but that the command never writes is not proven. Without that
evidence, or for an entry with no command or one only the branch registers,
the output is product (U1 on gstack-evals paid a
full rerun because a merge changed its generated index's diff). A base merge
contributes no product change of its own. A receipt carried twice keeps the reviewed head as
carry.from and adds up the merges and paths. The carried receipt records
carry: { from, reason, product_patch_id, changed_paths, merge_commits, range_diff, covers, reruns, ancestry, trees } (ancestry is linear
along the receipt's own history, rewritten once any hop was a rebase;
trees lists the earlier covered trees) with the git range-diff evidence, prints
CARRY: carried from <old> to <new> (<reason>) and the GSTACK_RESULT: skill=ship status=complete line, and its summary line ends with
carried_from=<old12>. CARRY: current means the receipt already names HEAD;
--dry-run prints CARRY: would carry … and writes nothing.
A changed product path, or a product patch-id that differs, is
RECEIPT_CARRY_PRODUCT_CHANGED
(exit 3; the refusal names the paths), and a working tree with uncommitted
changes is RECEIPT_CARRY_REFUSED.
Rewritten history. write records head_tree (the reviewed head's tree)
and product_patch_id (its product patch against the base), and every carry
rebinds both to the new head and keeps the earlier trees in carry.trees.
When the receipt's head is no longer in HEAD's history, the carry follows only
when HEAD's tree is one of those trees and HEAD's product patch-id against the
current base equals the recorded one; it records carry.ancestry: rewritten
and range_diff: tree-equal, and needs neither the old commit nor its
history, so a fresh clone carries too. The tree alone is not enough: a stacked
branch rebased onto main after its base closed unmerged keeps its tree while
its diff grows by the base's code, and that is RECEIPT_CARRY_PRODUCT_CHANGED.
Any other tree is
RECEIPT_CARRY_TREE_MISMATCH,
which names HEAD's tree and the covered trees. A receipt from before
head_tree existed, whose head is not in this clone, is
RECEIPT_CARRY_REFUSED: run /ship on the new head. A refusal never
rewrites the body. The queue's ready, train build and verify --train read the
carried receipt like any other.
Delta /ship: eligibility, then checks, then publication. /ship Step 1 runs
the carry when the branch has an open PR, in this order, and never reruns the
review loop for a carried receipt:
gstack-ship-receipt carry --pr <n> --base <base> --dry-run.CARRY: would carryis eligibility only; a refusal continues the full ship.- At that pinned HEAD: the CHANGELOG check, the size-cap check the policy
declares, and
gstack-pregate(the remote tests stage whenrequires_remoteasks for it). A failing check, or HEAD moving, continues the full ship from Step 2. gstack-ship-receipt carry --pr <n> --base <base> --pregate <pregate.json>rebinds the receipt with a pregate current for HEAD and ends the run.
A receipt that records a pregate never rebinds without a current one:
carry without --pregate (or with a stale one) is
RECEIPT_CARRY_PREGATE_STALE
and leaves the body unchanged. A crew that reran the whole /ship after every
base merge should run this sequence instead.
What /land-and-deploy reads from it
Step 3.4 runs show-effective --base <base> before the merge and prints one
VERSION_CHECK: verdict for the PR head:
| Policy | Stops when | Passes |
|---|---|---|
queue_mode: stamp-at-merge |
the head's VERSION is not the base's version plus one bump, or the changelog's top two headings at the head are not that version and then the base's top entry (STOP stamp stale: run gstack-restamp --next on the branch, push the stamp, rerun) |
an exact stamp; other open PRs' tentative stamps are ignored |
queue_mode: claim-at-open (and no policy file) |
another open PR holds the same VERSION, or the base is already at or above it (VERSION drift detected: rerun /ship) |
any unique VERSION above the base, including a MICRO bump or one below a sibling's claim |
An invalid policy, an unreadable claim list or a missing VERSION is unavailable:
the skill reports it and CI's version gate is the backstop. A base at or above the
branch VERSION still stops.
queue_mode: stamp-at-merge
Under stamp-at-merge nothing is claimed when a PR opens. The stamp happens
when the PR is next, from the base branch's version at that moment, through
the repo's own release tool, across every stamp location at once:
gstack-restamp --next --base main --allow-repo-commands --pr 42 \
--expect-base <sha of origin/main the queue assumed> --expect-order 40,41,42
prints ship, next in queue at <version>, STAMP_VERSION: <version>, one
line per stage, the diff limited to stamp files, MERGE_CONDITION: sha=<head>
(pass it to the merge API's expected-head sha so a check-to-merge race is
rejected by the server) and the GSTACK_RESULT: line. claims: ignored (stamp-at-merge) says the legacy claim reader was not consulted. When the base
or the order moved, the result is QUEUE_STALE (exit 3) with the exact
recompute command; nothing is written.
Stages, each journaled in .gstack/tmp/restamp-journal.json:
- restamp_command (only when the policy sets it): the repo's own restamp, see restamp_command.
- version:
gstack-version-bump write --version <X>in the staging worktree (VERSION, the manifest, npm lockfiles); skipped whenrestamp_commandowns the version. The manifest gets the npm-valid three-part form, except when the policy lists it instamp_pathsorstamp_filesand its copy at the base holds four parts (gbrain's convention): then it keeps the full version (--manifest-form full) and the stage detail says so. - release_tool: the policy command, under the contract below.
- changelog: the PR's
[Unreleased]or old-version section moves to the top under<X>, above its predecessor's entry. A duplicate heading the PR introduces (CHANGELOG_DUPLICATE_HEADING: the headings are counted against the base's CHANGELOG atorigin/<base>and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not<X>(CHANGELOG_TOP_MISMATCH) are errors; the section being re-headed itself may exist only once. - stamp_paths: exact
(was X),since: XandvXrewrites, plus everystamp_patternstemplate, withinstamp_pathsandstamp_files. A quote around X in the first two forms is part of the form and is kept as written:since: 'X',since: "X",(was 'X')and(was "X")move like the bare ones (the shape of a quoted source row such as gbrain's{ since: '0.60.468.0', … }), an opened quote must close right after X (since: '1.2.3-beta'andsince: '1.2.3"are not the form, in the writer and in the tree receipt alike), andvXalready accepts a quote before thev. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and--was <X>, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (git diff --unified=0from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail saysN line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N), and a base diff git cannot run refuses the stage (POLICY_SOURCE_UNAVAILABLE,cannot diff <since>..<base> for the stamp_paths stage) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line insidestamp_pathsthat means the base's own release in a stamp form (Requires vX or laterwritten on a branch forked at X) moves with it, which the--dry-rundiff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out ofstamp_paths.--was Xis the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section## [X]. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. - mirror: tier 2; declared and validated, reported as not run.
- apply: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is
RESTAMP_CONFLICTand the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
Idempotent: a second run with the same version is noop. --dry-run prints
the full diff and writes nothing (not even the journal). --version <X> stamps
exactly X; --bump overrides the level; --queue-mode overrides the policy.
Gate-ahead: --after <pr>
A reorder is cheap only when the gate can run before the predecessor merges.
gstack-restamp --after <pr> --predecessor-ref <ref> merges HEAD and the
predecessor's head in a throwaway worktree (.gstack/tmp/gate-ahead-worktree),
resolving only version-file and CHANGELOG-ordering conflicts, stamps N+1
relative to the predecessor's version there, commits, and records
.gstack/tmp/gate-ahead.json (predecessor_head, branch_head,
gated_head, gated_tree, version, evidence_branch_key). The PR branch's
history is never touched, so squash-history PRs stay clean. The gate runs in
that worktree; gstack-evidence bundle there exports the evidence.
The release-tool contract
The release tool runs in an isolated staging worktree seeded from the working
tree's content fingerprint (gstack-wtree), with the repo's ignored
node_modules linked in. Its outputs are enumerated: the version files,
lockfiles, the CHANGELOG, stamp_paths and release_outputs. A write outside
that set refuses the whole restamp before anything reaches the branch
(RESTAMP_OUTPUT_OUTSIDE_ALLOWED); a non-zero exit is
RESTAMP_RELEASE_TOOL_FAILED. Preimages and postimages of every write are
recorded in the journal. Pinning the command string does not freeze its
implementation (bun run release:restamp still runs the PR's package script),
so the receipt names the policy revision, the command, its working directory
and the trust scope (policy: base origin/main@<sha> or head (unreviewed)).
restamp_command: the repo owns the stamp
A repo whose own tool knows its stamp locations sets restamp_command.
gstack-restamp runs it as the first stage inside its staging worktree, a
clean checkout of HEAD, with the allocated version as the last argument and in
GSTACK_RESTAMP_VERSION. The caller's HEAD, index and worktree must be clean
first (RESTAMP_CONFLICT, nothing written); the generic path keeps its
working-tree fingerprint seed. Anything the command stages or commits in the
staging worktree is folded back into plain edits; then output confinement
applies as for the release tool (RESTAMP_OUTPUT_OUTSIDE_ALLOWED), the
version file must hold the target version (RESTAMP_VERSION_SOURCE), and a
non-zero exit is RESTAMP_RELEASE_TOOL_FAILED. In every failure nothing
reaches the branch. gstack's version writer is skipped; the CHANGELOG and
stamp_paths stages still run (a no-op when the tool already did the work).
Read from the base branch and run only with --allow-repo-commands, like
release_tool. A tool that fetches, merges the base branch or needs a version
of its own choosing cannot run here; give it a mode that takes the version and
writes only stamps.
The command also gets GSTACK_RESTAMP_BASE_SHA, the release predecessor: the
base branch commit the version is allocated after (origin/ for a per-PR
restamp and for land, S_(i-1) for a stamped train's S_i). A repo counter that must be predecessor + 1 (a
SAVE_VERSION, a rules number) reads its old value from that commit
(git show "$GSTACK_RESTAMP_BASE_SHA:<file>"), never from the branch, so two PRs
cut from one base never both claim the same number. Regenerated goldens go in
release_outputs.
changelog_fragments: entries without versions, versioned at merge
With changelog_fragments: { "dir": "changelog.d" } on the base branch, a PR
never edits VERSION, the manifest version key or a released CHANGELOG
section. It adds one or more fragments, and the merge step assigns the
version, so two PRs cut from one base cannot collide and no lane redoes a
stamp after another PR merges.
A fragment is <dir>/<slug>.md, one regular file directly in dir
(README.md there is skipped). Its body is the entry text, exactly what the
CHANGELOG section will hold, with no ## [x] heading. It may open with front
matter that raises the bump:
---
bump: minor
---
**What changed, in one sentence.**
### Fixed
- ...
The effective bump of a stamp is the highest of the policy bump and every
folded fragment's bump: (micro < patch < minor < major). Fragments
are read as Git blobs at the commit being stamped (git ls-tree and
git cat-file, never the working tree), so a symlink, a gitlink, a nested
path, a non-.md file, an empty fragment, one over 64 KiB, one carrying a
release heading, or one whose name already landed on the base with other text
is CHANGELOG_FRAGMENT_INVALID, and nothing is allocated. Only fragments the
branch adds against the base count.
/ship. show-effective prints a ship: changelog_fragments line, so /ship
writes no version in Step 12 and writes Step 13's entry as
<dir>/<branch-slug>.md, linted with gstack-prose-lint <file> --gate, never an
[Unreleased] section.
The fold. gstack-restamp (and so land) concatenates the fragment
bodies in path order above any legacy [Unreleased] section, re-heads both as
one ## [<version>] - <date> section at the top, and deletes the fragment
files, in one stamp. The tree receipt, and so gstack-pregate clear's
stamp-only substitution, calls a deleted fragment a stamp (rule
changelog_fragments) only when the new CHANGELOG section is exactly the
deleted fragments' bodies in path order above any legacy [Unreleased] text
(rule changelog-fragments); an edited fold, or a fragment deleted without
its text, is a change.
Version Gate. The workflow runs on every PR (no path filter), so a PR
that touches no release file still has to prove its entry. Under the key, an
unstamped head must keep VERSION, the manifest version key and every
released CHANGELOG section at its merge base's values, and add at least one
valid fragment (a legacy [Unreleased]-only entry passes during migration;
a [bot] author, such as Dependabot, needs none). Other manifest edits, a
dependency or a script, pass. A head made by the merge step, a commit whose
parents are [pin, base], must carry the base's version plus the effective
bump of the fragments its pin adds; a stacked pin's parent fragment whose
text the base already released is skipped, not counted again. Under a train policy gstack-ship-queue ready applies the same rule itself; without one, ready requires the head's
checks green, Version Gate included.
gstack-ship-queue land: the per-PR merge step
land --pr <n> merges one READY PR whose head is unstamped. It replaces the
lane's stamp round trip. Nobody stamps by hand; the tool makes and pushes the
stamp commit. Steps, each journaled in
.gstack/tmp/ship-queue/land/<n>.json and resumable by rerunning the same
command:
- Refuse a fork head, a closed PR, a PR whose base is another branch, a
base policy with
changelog_fragmentsoff (QUEUE_NOT_READY), a pin without a READY receipt (gstack-ship-queue ready --pr <n>records one onrefs/notes/gstack-trainsonce its pregate, full_e2e and receipt checks pass; land checks nothing weaker), a /ship receipt that is missing or bound to another head (QUEUE_RECEIPT_MISSING,QUEUE_RECEIPT_STALE), and required checks that are not green at the pin. - Lock. Take the repository land lock,
refs/notes/gstack-land(its own ref, never the trains notes ref), by exact lease. It records the PR, holder, phase and journal. A held lock isQUEUE_LAND_LOCKEDnaming the holder;--break-locktakes over a crashed holder's lock and is refused while that holder entered a push or a merge less than 10 minutes ago, and for a drift lock. A refusal before the push releases the lock. - Merge and stamp. In a scratch worktree, merge the base into the pin.
Conflicts in release fields only (the version file and
release_outputstake the base's side, the CHANGELOG merges by section, a manifest or npm lockfile is re-merged with every side's release version set to the base's, so a clean dependency edit survives) are resolved; anything else isQUEUE_LAND_CONFLICT, nothing pushed, back to the lane. A legacy pin that bumped VERSION itself has its release fields put back to its merge base first and its entry re-headed[Unreleased](a manifest version source keeps its other edits; a symlinked release file refuses). A PR with no entry gets a fragment written from its title. Thengstack-restamp --nextfolds and allocates (restamp_commandandrelease_toolneed--allow-repo-commands, as everywhere). - Push.
commit-treethe stamped tree with parents [pin, base] and push it to the PR branch under--force-with-lease=<branch>:<pin>. The journal keeps the reviewed pin apart from the last pushed head, so a rerun leases on what it pushed and never on a head someone else pushed (QUEUE_TRAIN_MOVED, never overwritten). - Wait for the PR's checks on the stamp commit, polling every 30 seconds
with the same check read the pin passed (
--timeout <min>, default 60).--no-waitstops after the push and printsgstack-pr-wait <n> --until green && gstack-ship-queue land --pr <n>to run instead. CI runs for real on the stamp commit in this mode. - Merge. Re-read the base first: if it moved, restamp from the new base
and push again under the last-pushed lease, at most twice
(
QUEUE_LAND_BASE_BUSYon a third move, which releases the lock). Thengh pr merge --squash --match-head-commit <stamp>(--mergeunderhistory: merge-only) and confirm the base's tree is the stamped tree on the base land stamped. The stamp date is taken once and reused by every restamp. A rerun after GitHub merged the PR (an error reply, a crash) goes straight to that tree check. - Drift. GitHub's merge API cannot bind the base, so a merge made outside
landcan slip in between the check and the squash. The post-merge check then fails withQUEUE_LAND_DRIFTand keeps the lock.land --pr <n> --repairopens a PR from the base whose only change is a fragment for what the outside merge brought (its unfolded fragments, moved, or one entry naming its commits) and records it on the lock; /ship and land that PR, from any checkout, and its land takes over the drift lock and releases it. A rerun of--repairfinds the PR it already opened.
--dry-run merges and stamps in a scratch worktree and prints the version and
tree, taking no lock and pushing nothing.
Migrating to fragments
Before turning the key on, run gstack-ship-queue land --census (read-only).
It prints one line per open PR, saying whether land would fold its fragments, re-head a legacy
[Unreleased], rewrite a tentative per-PR VERSION bump, resolve a
release-field-only conflict, write an entry from the title, or refuse on a
product conflict. Then set changelog_fragments and min_gstack_version in
one policy PR; a claim-at-open repository switches queue_mode to
stamp-at-merge in the same PR. Open PRs need no change: land handles each
census case. Rollback is removing the key. With it off, gstack-restamp
neither folds nor deletes fragment files, so first land every open PR that
carries a fragment (land --census lists them) or move each fragment's text
under [Unreleased] in its PR; otherwise that entry never reaches CHANGELOG.md.
The tree receipt
gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr 41 --bundle evidence-bundle.json
gstack-tree-receipt --gated <sha> --current HEAD --json
Compares two committed heads (a working-tree fingerprint can name objects unreachable once the gate VM is gone) and prints the gated and current heads, both tree ids, the version pair, the predecessor the gate assumed, the diff limited to stamp files, one line per file with its rule and verdict, one line per gate, then two verdict lines:
TREE: same-modulo-stamps | changed (<residual paths>)
gate-reuse: eligible (reusable: tests; rerun: digest) | not-eligible (<what changed>) — fix: <rerun command> (GATE_REUSE_NOT_ELIGIBLE)
Hunks are classified per file type: the version file (old → new only), the
manifest (version key only), npm and bun lockfiles (root version keys
only, where gstack-version-bump writes; a dependency entry is changed),
the CHANGELOG (a heading move or re-heading, compared as an order-independent
multiset of entries; under changelog_fragments, also exactly the deleted
fragments folded under the new heading, and those deletions are stamps), stamp_paths and release_outputs (every
removed/added pair must be an exact (was X) / since: X / vX rewrite (a quote around X in the first two forms included) with
X the gated version, or a newer version of the repo's width that the
stamp_paths stage moves (a tentative *_KEY_VERSION written as the next
minor, #3512; a version older than the gated one, or of another width, is a
hand edit), and the replacement the current one, so a dependency pin
that happens to equal the old version is changed; stamp_patterns
templates count as stamp forms too). With stamp_files set, a file outside it
is changed (stamp_files: not in the repo's stamp_files allowlist) whatever
its diff. Any other byte is a change.
Equivalence is decided per gate. A lane in stamp_sensitive_gates always
reruns; a verified lane is reusable only when the tree is same-modulo-stamps
and the execution identity in the bundle equals the current one: Bun and Node
versions, OS and arch, every lockfile's identity (root version normalized
away), the .github/workflows tree id, runtime pin files and
package.json#engines, and the runner image when either side knows it. Without
a bundle the identity is unknown and the receipt is not-eligible. When the
recorded predecessor head is no longer the predecessor PR's head (checked
through --predecessor-ref or gh pr view --predecessor-pr) the receipt
prints PREDECESSOR MOVED <old> -> <new> — fix: gstack-restamp --after <pr> again (RESTAMP_PREDECESSOR_MOVED) and refuses eligible.