mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
* fix(land-and-deploy): Step 3.4 reads the ship policy and stops only on a real VERSION collision (#3318) Under stamp-at-merge, verify the exact head stamp (base + one bump, changelog headings bound to it) and ignore claims. Under claim-at-open, stop only when another open PR holds the same VERSION or the base is at or above it. MICRO bumps and versions below a sibling's claim pass. Reported-by: Evgenii Lopatin (@exGeni) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): what /land-and-deploy Step 3.4 reads from the policy (#3318) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): Unreleased entry for #3318 /land-and-deploy Step 3.4 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(size): raise land-and-deploy size pins and parity cap for #3318 Step 3.4 (owner-approved) Garry, 2026-10-24: "do it". Pins set to the measured sizes on 11 hosts (+1,084 to +1,111 chars); maxSizeRatio 1.112 -> 1.125 (measured 1.1201). 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>
617 lines
39 KiB
Markdown
617 lines
39 KiB
Markdown
# 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](#run-a-shared-merge-queue), [merge trains](#run-a-merge-train)),
|
||
`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](troubleshooting.md#queue-aware-ship-policy-restamp-tree-receipt-ship-receipt).
|
||
|
||
<a id="schema-v1"></a>
|
||
## 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](#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-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](#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](#run-a-merge-train), 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:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
1. **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 and
|
||
`gstack-next-version` is not consulted; the tentative version is only the
|
||
guess that makes the common case free. CI runs on this head as usual.
|
||
2. **Register.** The queue records the PR and its intended order.
|
||
3. **Stamp when next.** With every check green on the current head and
|
||
`origin/main` at 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 prints
|
||
`RESTAMP: X -> X (noop)` and `FILES: (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, prints `STAMP_VERSION: <X>` and the stamp diff, and refuses
|
||
with `QUEUE_STALE` (nothing written) when the base or order moved after
|
||
`--expect-base` was read. The owner commits and pushes the stamp commit,
|
||
lets CI run on it, and reports `READY #<n> <sha> STAMP_VERSION=<X>`.
|
||
4. **Merge.** The queue verifies on that exact SHA: `VERSION` equals
|
||
`STAMP_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 only `VERSION`,
|
||
`package.json`, `CHANGELOG.md` and 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-head
|
||
`sha` set 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".
|
||
5. **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 merges `origin/main` in (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.
|
||
|
||
<a id="run-a-shared-merge-queue"></a>
|
||
## 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:
|
||
|
||
```bash
|
||
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> …
|
||
```
|
||
|
||
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. 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
(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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
<a id="run-a-merge-train"></a>
|
||
## 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:
|
||
|
||
```json
|
||
"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.
|
||
|
||
**1. Lane: READY with a screen choice.** Under a train policy READY needs an
|
||
explicit choice:
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
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.
|
||
|
||
**2. Coordinator: build.** Name the pins exactly as READY reported them:
|
||
|
||
```bash
|
||
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_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.**
|
||
|
||
```bash
|
||
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.**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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<i> modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T<i> with main merged in".
|
||
|
||
**5. Coordinator: verify, merge, verify again.** For each slot in order:
|
||
|
||
```bash
|
||
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<i>
|
||
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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
<a id="ship"></a>
|
||
## 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](#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.
|
||
|
||
<a id="stamp-at-merge"></a>
|
||
## `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:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command).
|
||
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
|
||
2. **release_tool**: the policy command, under the contract below.
|
||
3. **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.
|
||
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. `X` is 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 its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||
5. **mirror**: tier 2; declared and validated, reported as not run.
|
||
6. **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_CONFLICT` and 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.
|
||
|
||
<a id="release-tool-contract"></a>
|
||
### 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)`).
|
||
|
||
<a id="restamp-command"></a>
|
||
### `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.
|
||
|
||
<a id="tree-receipt"></a>
|
||
## The tree receipt
|
||
|
||
```bash
|
||
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
|
||
`rerun`s; 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`. |