mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(pregate): price_command config key for the non-executing call plan a paid gate is priced from
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(pregate): repo guards a lane must pass, verified launch Bun and per-file serial runs in lanes, unknown flags exit 2
The guards check runs each .gstack/pregate.json guard whose globs match a
touched file in its own process with its own timeout (PREGATE_GUARD_FAILED,
never downgradable, incomplete without --allow-repo-commands). The lanes
runner and guards get the first Bun in $BUN_INSTALL/bin or ~/.bun/bin that
satisfies gstack's and the project's engines.bun; serial_patterns files run
one process each. price_file replaces the unreleased price_command key: a
paid gate is priced from a call-plan data file, never by running a command.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pricing): Sonnet 5.5, Gemini 3.x and gbrain embedding rows from fetched pages; Opus 4.7 at its current list price
Each new row carries its source URL and fetch date (2026-10-12). Embedding
models bill input only, so they live in EMBEDDING_PRICING beside PRICING
(eval arms never resolve to them); pricingRow also accepts gbrain's
provider:model ids (voyage:voyage-4). claude-opus-4-7 moves from 15/75 to the
5/25 the Anthropic pricing page lists; benchmark-runner's expected costs follow.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(eval-plan): price-gate prices a paid gate from a call-plan data file, never executing it
gstack-eval-plan price-gate --gate "<exact command>" reads a GSTACK_EVAL_CALLS
v1 file (--plan-file, else the base policy's price_file at HEAD), checks its
binding (gate command string equality, head with only the plan file changed
since, every input's sha256 at HEAD), prices each call purpose (aggregate
totals) and each one-call reservation, and writes a receipt binding the price
to the gate, head, inputs and plan bytes. Unpriced models print unknown, never
zero (EVAL_PRICE_MISSING); a missing plan or no call lines is
EVAL_GATE_UNPRICED unless --allow-unpriced records an owner override; over
--cap-usd / --smoke-cap-usd is EVAL_GATE_OVER_CAP; malformed or partial files
refuse EVAL_DRY_RUN_FAILED and stale ones EVAL_PLAN_STALE. dry-run --cap adds
the same reservation-fits-cap check. Wire format, receipt and the reference
emitter (formatEvalCalls): docs/eval-calls.md.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(eval-plan): the approval step prices every paid gate with price-gate
Step 4 runs gstack-eval-plan price-gate for each paid gate the plan names and
puts its GATE_PRICE line in the ask; the dry-run checklist documents
reservation-fits-cap under --cap.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(sealed): sealed-output guard over every published commit, in the pre-push hook and gstack-pregate sealed
A repo with a sealed key in .gstack/pregate.json (base branch union the
pushed commit, so a push can tighten but never relax) refuses publishing
sealed paths, transcripts, quoted answers, seeds or local home paths to a
public or unknown destination (SEALED_OUTPUT_REFUSED); private warns.
Path rules see every added, renamed or copied path in each published
commit (git rev-list <local> --not --remotes=<remote>), text rules each
commit's added lines; waivers only from the base branch allow file; a
malformed policy fails closed; visibility caches public 24 h, private 1 h.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(eval-plan): only an undeclared plan is unpriced; a missing price_file is stale, a bad base policy refuses
price-gate's EVAL_GATE_UNPRICED (and --allow-unpriced) now covers only no
plan declared at all or a plan with zero call lines. A declared price_file
missing from the HEAD commit is EVAL_PLAN_STALE (exit 1, not overridable); an
unresolvable origin/<base> is POLICY_SOURCE_UNAVAILABLE and an invalid
.gstack/pregate.json is PREGATE_CONFIG_INVALID (both exit 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(restamp): the repo's stamp rule (stamp_files, literal stamp_patterns) and restamp_command in the staging worktree
stamp_files is an allowlist applied before every stamp rule; stamp_patterns
are bounded literal {version} templates shared by the restamp writer and the
tree receipt. restamp_command runs first in the staging worktree with the
target version (argument and GSTACK_RESTAMP_VERSION), needs a clean caller
tree, skips gstack's version writer and keeps output confinement; a failure
or a wrong version writes nothing. migrations { dir, id } is the policy key
gstack-pregate regate reads.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(pregate): gstack-pregate regate --against <gated-head> with per-lane reuse and migration collisions
Reuse comes only from a tree receipt evaluated now, with a bundle produced
on the gated tree, the current execution identity and an unmoved, known
predecessor head. Each lane prints reusable/rerun/not-reusable; REGATE:
not-needed needs stamp-only, every bundle and --require lane reusable (a
stamp-sensitive lane reruns) and a finished migration lookup. Migration
numbers the branch adds are checked against the base tree and every other
open PR (gh pr list --limit 1000; 100-file lists refetched with gh api
--paginate); any lookup that cannot finish is unknown and blocks
not-needed (MIGRATION_COLLISION). Bare --against routes to the verb.
docs/pregate.md gains the regate section and the gbrain worked configs.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(contract): lanes run gstack-pregate (and the sealed guard where configured) before done; handoff carries the gstack trailer and PREGATE: line
The coordinator contract gains a 'Before done' paragraph and the handoff
order GSTACK_RESULT, gstack: ok|fail <rev>, PREGATE:, gate summary; the
spec section is regenerated and docs/unattended.md states the same rule.
A new test pins the terms and their order; the byte-identity pins stay.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.98.0: pregate guards, priced paid gates, regate and the sealed-output guard
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: price-gate --gate-file keeps free text out of the /eval-plan fence; shortest dependency traces; bounded test spawns
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(windows): curate out the new pregate/restamp tests that spawn bin/ shebang scripts through runBin
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>
375 lines
24 KiB
Markdown
375 lines
24 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-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.
|
||
|
||
| 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`. |
|
||
|
||
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="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: 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.
|