* fix(version-gate): a release output regenerated at the base VERSION is not a stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(version-gate): run the head release tool with a bare env, drop outputs it must rewrite, prune the staging worktree Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(version-gate): regenerate at the merge base VERSION, read tool output from a file, never follow a VERSION symlink Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(version-gate): kill a release tool that ignores TERM at the limit, read a bounded log 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>
43 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 |
ask |
patch skips the MINOR/MAJOR prompt in /ship Step 12 and gstack-restamp --next bumps PATCH; ask keeps the prompt. |
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 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 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. |
min_gstack_version |
version or null |
null |
The oldest gstack that may read the file; see the compatibility rule above. |
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. |
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": [],
"changelog": "CHANGELOG.md",
"history": "squash-ok",
"stamp_sensitive_gates": ["tests"]
}
.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.
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> …
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.
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). 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": 5, "fill_hours": 3, "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.
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. 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 with the same codes before printingMERGE_CONDITION.
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 (QUEUE_SCREEN_STALE_REF when GitHub's merge ref lags the head)
Without a choice it prints the SCREEN: selection (with
screen_selection: gstack-paid, the skill's own cases and recommend --screen or --no-screen: --screen when the screen selects a skill's own
cases) 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, today's full PR selection). 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: a missing or lagging merge ref (GitHub stops
recomputing it while the PR conflicts with the base, CHANGELOG-only conflicts
included) is QUEUE_SCREEN_STALE_REF, because a screen dispatched there plans
no cases and reports success. 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; when max_size or more unscreened PRs wait they form
their own train and HELD: lists them; --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 under max_size waits until its oldest READY
has waited fill_hours (QUEUE_TRAIN_WAITING); --urgent dispatches a
one-PR train at once. 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_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, version files take the
prefix's side and the CHANGELOG merges by section, since the stamp at merge
rewrites both; 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. 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), 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 { mergeChangelogs } 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 = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
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".
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 above it); after it, main's tree
must equal the prefix after it. Anything else is QUEUE_TRAIN_TREE_MISMATCH
(or QUEUE_TRAIN_MOVED). When nothing of the train has merged, that is a plain
refusal: build a new train 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. 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.
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:).
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 today's 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 (NEW_VERSION, CHANGELOG entry text only, the merge's expected head) |
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: 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) |
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.
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. - 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. Duplicate headings (CHANGELOG_DUPLICATE_HEADING) and a top entry that is not<X>(CHANGELOG_TOP_MISMATCH) are errors. - stamp_paths: exact
(was X),since: XandvXrewrites, plus everystamp_patternstemplate, withinstamp_pathsandstamp_files.Xis the branch's own tentative stamp (its VERSION when it moved past base), else--was <X>, else the version an unstamped branch would have taken next (base + bump); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds itssince:fields. 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 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), stamp_paths and release_outputs (every
removed/added pair must be an exact (was X) / since: X / vX rewrite with
X the gated version 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.