mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(ship-queue): merge trains with pattern bisect and train-record verify (GSTA-49 T1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): curate ship-queue-train.test.ts out of the Windows subset (gh shim not resolved on windows-latest) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.145.0 feat(ship-queue): merge trains with pattern bisect and train-record verify (GSTA-49 T1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.146.0 restamp: #3295 takes v1.91.145.0 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>
762 lines
45 KiB
Markdown
762 lines
45 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 does not carry it yet: trains go live for gstack only
|
||
after the workflow side (the train-nonce record, the `cases` and
|
||
`prefix_shas` inputs, the dispatched screen) has merged and one dry-run train
|
||
of two already-merged heads has passed. `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), 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.
|
||
|
||
**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.
|
||
|
||
**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 still be at its pin (stamps aside); 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.
|
||
|
||
<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`.
|
||
|
||
`/ship` Step 16 consults the receipt; when it says `eligible`, a repeated full
|
||
gate needs `--force-gate <reason>`, and the coordinator makes the call.
|
||
|
||
### The evidence bundle
|
||
|
||
`gstack-evidence` is machine-local by design, so the portable form is a bundle:
|
||
|
||
```bash
|
||
gstack-evidence bundle --out evidence-bundle.json --label tests --label e2e --require tests,e2e \
|
||
--selection tests='bun run test' --run-url tests=https://github.com/.../actions/runs/1
|
||
```
|
||
|
||
A bundle binds to a committed head (`repo.commit`, `repo.tree`) and carries the
|
||
comparison material (version source, CHANGELOG path, `stamp_paths`,
|
||
`release_outputs`), the execution identity above, the required-lane manifest,
|
||
and per lane the command, `cmd_sha256`, exit, timestamp, executed selection,
|
||
run URL, log hash and whether it is `verified` (produced by the ledger on a
|
||
clean committed tree at this head) or not (dirty tree, other commit, or an
|
||
`--import`ed assertion). A fresh coordinator machine reads it with
|
||
`gstack-tree-receipt --bundle` after the gate VM is gone; the receipt refuses
|
||
reuse when the bundle was produced on another tree or lacks the identity
|
||
(`EVIDENCE_IDENTITY_UNKNOWN`).
|
||
|
||
<a id="ship-receipt"></a>
|
||
## The ship receipt
|
||
|
||
The handoff record is a fenced block, identifiers first, validated against
|
||
`gstack-artifact schema ship-receipt`:
|
||
|
||
````
|
||
```gstack-ship-receipt
|
||
{ "schema_version": 1, "pr": 42, "head": "<sha>", "version": "1.2.7.0", "base": "main",
|
||
"gated_tree": "<tree>", "predecessor": "<sha>", "tree": "same-modulo-stamps",
|
||
"gate_reuse": "eligible (reusable: tests; rerun: digest)", "gate": { "tests": { "pass": 10, "fail": 0, "skip": 1 } },
|
||
"ci": { "checks": 3, "failed": 0 }, "spend_usd": 0.42, "session_kind": "unattended",
|
||
"artifacts_consumed": "1/1", "run": "<run id>", "thread": "<thread id>", "policy": "base origin/main@<sha>" }
|
||
```
|
||
````
|
||
|
||
`gstack-ship-receipt write --pr <n> --receipt <tree-receipt.json> --gate '<json>' --ci '<json>' --body <pr-body.md>`
|
||
makes it the first section of the PR body (`/ship` Step 18) and prints
|
||
`SHIP_RECEIPT: pr=… head=… version=…` for the first line of the completion
|
||
message. `/land-and-deploy` reads it with `gstack-ship-receipt read --pr <n>
|
||
--json` (`RECEIPT_MISSING`, `RECEIPT_INVALID`).
|
||
|
||
### The adoption census (P7)
|
||
|
||
```bash
|
||
gstack-ship-receipt census --repo garrytan/gstack --since v1.91.70.0 --cohort threads.txt
|
||
```
|
||
|
||
reads merged PRs since the tag through `gh`, parses each receipt, deduplicates
|
||
by `run` and `thread`, and prints `CENSUS: … unattended=<n> consumed=<n>
|
||
adoption=<consumed threads>/<cohort> durable=yes`. The cohort file lists one
|
||
thread id per line (threads that never invoke gstack count in the
|
||
denominator); without it the denominator is the threads the receipts name.
|
||
|
||
## History policy and preregistration (C7)
|
||
|
||
Under `history: merge-only`, `/ship` refuses a squash or rebase plan and merges
|
||
the base branch instead: `gstack-ship-policy check-history --plan squash`
|
||
prints `HISTORY: refused (…) (HISTORY_POLICY_VIOLATION)` with exit 3, and
|
||
without `--plan` it prints `HISTORY: ok` or `HISTORY: rewritten …
|
||
(HISTORY_POLICY_VIOLATION)` (exit 1) when the pushed branch is no longer an
|
||
ancestor of HEAD. `gstack-evidence ancestor
|
||
--from-policy --base main` prints one `ANCESTOR: ok|missing <sha>` line per
|
||
`preregistration_shas` entry (`PREREGISTRATION_NOT_ANCESTOR`, exit 1), and the
|
||
ship receipt records the result in `preregistration`.
|
||
|
||
## Contributor mode
|
||
|
||
`/review` runs `gstack-contributor-mode detect` at Step 1 (decision D10). The PR
|
||
author is outside the maintainer set when `maintainers` in `.gstack/ship-policy.json`
|
||
on the base branch is non-empty and does not list the login (`source=policy`);
|
||
with an empty list, when `gh api repos/<owner>/<repo>/collaborators/<login>/permission`
|
||
reports less than push permission (`source=collaborators`, an egress receipt
|
||
`contributor-mode` precedes the call); when neither is available the mode is off
|
||
and `CONTRIBUTOR_SOURCE_UNAVAILABLE` names what was missing. `--contributor`
|
||
forces it on (`source=flag`). `CODEOWNERS` is file ownership, not maintenance
|
||
authority, and is never read. The printed line is
|
||
`CONTRIBUTOR_MODE: on|off author=<login> source=flag|policy|collaborators|off`,
|
||
followed when on by the evidence-first instructions: a table per change
|
||
(evidence value, risk surfaces, `accept` / `supersede` / `decline`), where
|
||
`accept` keeps a direct path for a correct outside implementation judged by the
|
||
same bar as a maintainer change, `supersede` rewrites from our own plan only after
|
||
a plain-language approval, and the credit lines (`Contributed by @handle`,
|
||
`Co-Authored-By:`, `Supersedes #N`) appear only when a diagnosis or a test carried
|
||
over. `gstack-contributor-mode instructions` prints the same text without detecting.
|
||
|
||
## Banned terms
|
||
|
||
`.gstack/banned-terms.json` lists terms the repository forbids outside named
|
||
locations: `terms` (a string, or `{ "term", "allowed_paths", "reason" }`) and a
|
||
top-level `allowed_paths` that every term inherits. Globs follow the same
|
||
containment rules as the ship policy (repo-relative, no `..`, under 256
|
||
characters). `gstack-banned-terms check --base <ref>` scans the lines the branch
|
||
adds since its merge base (or whole files with `--files <csv>`), reading the term
|
||
list from the committed copy on the base by default (`--policy-from head` reads
|
||
the working tree). Each finding is one line,
|
||
`BANNED_TERM: "<term>" <file>:<line> allowed: <locations> — <text>`, and the
|
||
last line is `BANNED_TERMS: ok | found=<n> | none` (`none`: the repository keeps
|
||
no list). Exit 0 for ok or none, 1 when found, 3 when the file is invalid
|
||
(`BANNED_TERMS_INVALID`, also from `validate`). `/review` turns each finding into
|
||
a Step 4 finding and `/document-release` into a documentation finding;
|
||
`gstack-banned-terms init` writes a commented example.
|
||
|
||
<a id="approval-classes"></a>
|
||
## Approval classes
|
||
|
||
`.gstack/approval-classes.json` names the changes that need the owner's
|
||
approval before they ship: upgrade-time data rewrites, trust or visibility
|
||
changes, existing-user default changes, data repairs, anything the owner wants
|
||
cited. Each entry in `classes` has a `name` (lowercase, dashes) and at least
|
||
one matcher: `paths` (globs of changed files), `patterns` (literal text in an
|
||
added line, case-sensitive) or `migrations: true` (the files the ship policy's
|
||
`migrations` key describes). `allowed_paths` exempts files, such as tests;
|
||
`description` is for people. Globs follow the ship policy's containment rules.
|
||
|
||
Classes come only from the copy committed on `origin/<base>`, so a branch can
|
||
neither declare nor drop its own; `gstack-approval-classes validate` checks the
|
||
working-tree copy before you commit it, and `init` writes an example.
|
||
`gstack-approval-classes check --base <ref>` scans the branch's changes since
|
||
the merge base and prints one `APPROVAL_CLASS: <class> <file>:<line>` line per
|
||
hit, one `APPROVAL_SCOPE: <class>@<digest12>` line per declared class with hits,
|
||
and `APPROVAL_CLASSES: ok | found=<n> | none` last (exit 0, 1 when found, 3 when
|
||
the base file is invalid or `origin/<base>` does not resolve). Built-in
|
||
detectors only report: the ship policy's migration files, when no class sets
|
||
`migrations: true`, print as `(built-in, report-only)` and need no citation.
|
||
|
||
The digest covers the class's effects: each matched file with its blob at the
|
||
scanned head, and each matched added line. The scan reads git's native diff
|
||
(`--no-ext-diff --no-textconv`), so a configured external diff driver or text
|
||
conversion cannot hide a line; when the content diff cannot be read, or its
|
||
added lines disagree with git's own count, `check` and the receipt refuse with
|
||
`APPROVAL_SOURCE_UNAVAILABLE` instead of passing. Adding, removing or editing an
|
||
effect changes the token, so an approval given earlier no longer binds.
|
||
|
||
**Enforcement.** `gstack-ship-receipt write` refuses with
|
||
`APPROVAL_CITATION_MISSING`, writing nothing, unless each declared class with
|
||
hits has a line in the `--body` file (or an `--approval` value) of the form
|
||
|
||
```
|
||
Approved by <owner> <YYYY-MM-DD> via <ref> [for <class>@<digest12>[,<class>@<digest12>]]
|
||
```
|
||
|
||
that resolves and binds:
|
||
|
||
- A plan sha (7 to 64 hex characters) must match the plan of an autoplan run
|
||
passed with `--approval-run <run dir>` whose gate is APPROVED, and the plan
|
||
must name the scope token, or the class and every effect path.
|
||
- A GitHub comment or review URL must be written by a `maintainers` login from
|
||
the base's ship policy that equals the cited owner. A review must be in
|
||
state `APPROVED`; a comment-only, dismissed or changes-requested review is
|
||
not an approval. The evidence is the body's first non-blank line only. It
|
||
must start at column 0 with `Approved` or `Approve` (any case, optionally
|
||
in `**` bold; no list marker, quote or indent) and name the token, or the
|
||
class and every effect path. Nothing later in the body counts, so a
|
||
rejection that quotes or nests an example approval approves nothing. For
|
||
example, a comment whose first line is
|
||
`Approved data-repair@0123456789ab: preview-first, never at upgrade.`
|
||
verifies; one that opens with any other line does not.
|
||
- A token is authoritative. Evidence that names the class with a different
|
||
digest approved earlier effects: it does not bind, and it never falls back
|
||
to matching the class and paths.
|
||
- Anything else (a thread code, a chat message, a URL gstack cannot read) is
|
||
recorded `citation: unverified` for the coordinator, and only when the line
|
||
attests which scope it covers with `for <class>@<digest12>` matching the
|
||
current token.
|
||
|
||
The receipt's `approval` object records the source, `status` (`checked`, or
|
||
`unavailable` when `origin/<base>` does not resolve), one record per class
|
||
(scope token, effects, `citation: verified | unverified`, owner, date, ref,
|
||
`binding: token | paths | attested`, evidence) and any report-only hits; the
|
||
`SHIP_RECEIPT:` line ends with `approval=verified`, `approval=unverified:<classes>`,
|
||
`approval=none` or `approval=unchecked`. A repository without the file gets no
|
||
`approval` object unless a built-in detector reported.
|
||
|
||
A data repair is a class to declare, and /review checks its shape: explicit,
|
||
preview-first, and never reachable from an upgrade or migration path.
|