A diff-selected run never maps a touched file to a scanner that reads the whole tree, so tripwires, ratchets and conformance tests passed the selected gate and failed CI after review (#3592, #3593). The lanes selection now adds every tracked test the list's globs name whenever the branch selects a test, owes a lane or edits the list. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
38 KiB
The pre-gate: generated files, cheap checks, issue links
/ship Step 5 opens with gstack-pregate: the free checks that catch what the
multi-agent wave kept paying remote gates to find (stale generated files, a
scratch script at the repo root, a nightly-only test still asserting an old
constant, a Windows-only change merged with its lane unrun, a | tail-hidden
failure). Plan: docs/designs/MULTI_AGENT_WAVE_2026_10_10.md C4, C6, C8.
Result codes: docs/troubleshooting.md.
Three bins, all over lib/:
| Bin | Pin file (read from origin/<base>) |
Does |
|---|---|---|
gstack-regen |
.gstack/generated.json |
generated files regenerate, never hand-merge |
gstack-pregate |
.gstack/pregate.json |
two-stage cheap gate, pregate.json record, publication check |
gstack-issue-links |
— | Fixes #N only on explicit closure intent; Related PRs and validated flake issues |
Trust boundary
A pin file names commands. Its presence never authorizes execution: both tools
read the committed copy on origin/<base> (a PR cannot smuggle a command into
its own gate; gstack-pregate run alone also reads a user-owned file outside
the repository when the base has none, see pregate v1) and run commands only with --allow-repo-commands, which /ship
passes. Standalone, gstack-regen check lists the commands and exits 3
(REPO_COMMANDS_NOT_ALLOWED); gstack-pregate marks regen and lanes
incomplete, never pass. --policy-from head (a PR that introduces or
changes a pin file) is accepted only with --allow-repo-commands and the record
says policy head (unreviewed). So a lane or allowlist entry a branch adds to
its own pregate.json counts only once it is on the base; PREGATE_NO_LANE's
fix line says so when the branch edits the file
(troubleshooting). An unresolvable base exits 3 with
POLICY_SOURCE_UNAVAILABLE. Globs are repo-relative (no .., no absolute
paths, at most 64 per list); commands are one line under 512 bytes.
.gstack/generated.json (generated-v1)
{ "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1",
"entries": [ { "id": "skill-docs", "command": "bun run gen:skill-docs --host all",
"outputs": ["*/SKILL.md", "*/sections/*.md"], "inputs": ["*/SKILL.md.tmpl", "hosts/**"] } ] }
gstack-regen checkregenerates every entry (or--only <id>, or--touched <paths>for entries whose inputs or outputs intersect) in a scratchgit worktreeseeded from the working tree, withnode_moduleslinked rather than copied, and reports stale outputs (REGEN_STALE, exit 1) without touching the tree. When outputs differ butpackage.jsondeclares dependencies andnode_modulesis missing, the entry isREGEN_DEPS_MISSING(install, then rerun) instead of stale. An entry with nocommandis tracked only (goldens regenerated by another entry).gstack-regen writeregenerates in place: whatgstack-restampand the Step 3 conflict rule use. On a conflict in a registered output, take either side, thengstack-regen write; the agent never hand-merges a generated file.list,validate,init [--explain] [--force].
gstack's entries: skill-docs (every host render, the agents digest, llms.txt,
the doctor component table) and ship-goldens (bun scripts/gen-ship-goldens.ts,
the byte-pinned /ship renders in test/fixtures/golden/).
.gstack/pregate.json (pregate-v1)
| Key | Meaning |
|---|---|
runner |
the local test command with {files} (gstack: bun test {files}); unset, it is detected (bun, vitest, jest, pytest, node --test, and flutter test/dart test per Dart package root, through fvm when that package or an ancestor pins it with .fvmrc or .fvm/fvm_config.json and fvm is on PATH; cargo test -p <name> per Rust crate, run from the root of the workspace the crate is a member of (Cargo's members globs, exclude and package.workspace), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its test script) runs from that package, with package-relative paths, through the package manager its packageManager field or the nearest lockfile up to the repo root names: pnpm exec, yarn, bun x, else npx; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a .dart file with no pubspec.yaml above it, a .rs file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, _ and -), is incomplete (the runnable groups still run, so a real failure still fails) |
dependencies |
glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; [] declares "no lane needed" (docs, licences) |
empty_lanes |
extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a join() of path segments, an import, or a dependencies entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without .md, and so does licence text under any vendored name (roboto-LICENSE.txt, LICENSE-MIT, licenses/*.txt). Source code and files under a test directory never qualify. A test that reads the file still selects that test |
selection |
workflow.yml/job → a command printing the lane's file list, for matrices a planner computes (gstack: scripts/test-free-shards.ts --list) |
warn |
check ids downgraded to warn; secrets and required lanes never |
strays.allow |
intentional root files or new top-level directories |
local |
workflow.yml/job lanes a local run satisfies: a platform-agnostic toolchain (an ubuntu-latest suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming requires-remote, except that on a machine under 8 cores with a remote_gate or a remote test_backend the small-machine rule still records the test tier as a requires-remote obligation. --explain notes an id that matches no workflow lane |
guards |
[{ id, when: [globs], command, fix?, timeout_s? }]: repo commands a lane must pass when a touched file matches when (manifest checks, subprocess goldens the import graph cannot see); see guards |
serial_patterns |
test-file globs the lanes check runs one process per file, apart from the unit files (default **/*.serial.test.*) |
sealed |
sealed or eval output that must never reach a public destination; read by the pre-push hook and gstack-pregate sealed (see sealed output) |
price_file |
repo-relative path of the paid gate's call-plan data file (GSTACK_EVAL_CALLS v1); gstack-eval-plan price-gate prices from it and never executes anything (docs/eval-calls.md) |
stall_timeout_s |
seconds a test command may print nothing before its process group is killed and the check is incomplete (PREGATE_STALLED); default 300, 0 disables; --stall-timeout overrides (see supervision) |
remote_gate |
the repo's remote test gate command; under 8 cores the local test tier becomes a requires-remote obligation naming it, and --remote runs it (see remote gate) |
allowlist |
[{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never env.value (see allowlist) |
Only the origin/<base> copy or the user-owned file below is honoured, so a
downgrade is reviewed (or is the user's own) before it takes effect. A repo that
keeps .gstack/ out of git (a shared repo where gstack is one contributor's
tool) can hold the same file at <state root>/projects/<slug>/pregate.json. The
state root is GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR,
else a gstack plugin's data directory, else ~/.gstack (docs/state-root.md); <slug> is what
~/.claude/skills/gstack/bin/gstack-slug prints as SLUG= in the repository
(GSTACK_PROJECT_SLUG overrides it). It is read only when the base carries none
(never under --policy-from head), it lives outside the repository so a PR still
cannot reach it, and the record names it (policy user <path>) and fingerprints
its bytes (policy_digest, and each check's inputs.policy); verify and the
ship receipt treat an edited or deleted user file, or one that appears (or a
link on its path that starts pointing nowhere) after a run under the defaults,
as PREGATE_STALE. A path that is relative, resolves inside the repository, or
reaches it through any symlink on the way (a state root linked into the
checkout, or a link hop inside it, even when the last hop points back outside)
is ignored, since a PR could commit it or choose the file; --explain says
so, or says (absent) when no file is there. The slug and state root come from
the caller's environment, never the checkout's .env: the bin's shebang passes
--no-env-file, and a bare bun gstack-pregate launched beside any .env file
refuses (POLICY_SOURCE_UNAVAILABLE). verify reads both paths from the
record, not from its own environment.
It covers gstack-pregate run only: sealed and price_file are read from the
base branch's copy, so the user file refuses them (PREGATE_CONFIG_INVALID)
rather than holding a policy nothing reads. A user file that exists but cannot
be read, or a symlink on its path that points nowhere, refuses the same way
instead of falling back to defaults. A base copy git cannot read (and
git ls-tree cannot confirm is absent) refuses with POLICY_SOURCE_UNAVAILABLE
instead of handing the run to the user file.
gstack-pregate validate / init cover the working-tree file.
No policy yet. A repository with no .gstack/pregate.json on the base
branch and no user-owned file runs under the defaults, and a touched file that
maps to no test is then a warning, not the hard PREGATE_NO_LANE: the
lanes row is warn with PREGATE_NO_POLICY, the selected tests still run
(a red lane is still fail, an unknown platform matrix is still a
requires-remote obligation), and the table prints a starter policy drafted
from the repository (PREGATE_STARTER: lines: the detected runner, the test
directories, the CI lanes, then the JSON between begin and end, with a
dependencies entry per unmapped touched file mapped to the tests nearest it
or sharing its stem, never [], and a TODO line for each file nothing
covers). run writes nothing; gstack-pregate init is the explicit write.
Once any policy exists (committed on the base, or the user-owned file), an
unmapped file is PREGATE_NO_LANE again: the warning is for the first /ship
in a new repository (three Captains lost their first pass to it), never a way
to hide missing coverage. --policy-from head with a head policy is strict too.
gstack-pregate init writes a draft built from the repo, not a fixed example.
It prints the languages it found and the runner each language's tests would
get, then maps every file the import graph cannot place to the tests of the
nearest directory above it that holds at most 25 of them, up to the
256-entry schema limit. The draft never
writes [], which would clear a file without a test. A file with no nearby
test, and a lane whose matrix a planner computes, is printed as a TODO.
Review each entry before committing the file on the base branch.
.gstack/always-run.json
{ "tests": [globs] } names whole-tree tests that scan every file of a kind (tripwires, ratchets, conformance and fixture inventories). A diff-selected run never maps a touched file to them, so they used to pass the selected gate and then fail CI (GST-98). When a branch selects any test, owes a lane, or edits this file, the lanes selection adds every tracked test file a glob matches. --explain prints each one as <test> (always_run, .gstack/always-run.json), and a glob that matches no test as <glob> matches no test file. A docs-only branch adds nothing.
The list lives in its own file, not in pregate.json, because older pregate readers reject unknown keys there. A repository that ignores .gstack/ (gstack does) tracks the file with git add -f, as it does pregate.json. A missing file means an empty list. A file that is not JSON, or whose tests is not a list of non-empty strings, stops the check with that message. gstack's own list runs in about 14 s on the CI VM.
gstack-pregate
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init
Two stages with separate clocks, both printed:
Stage 1, mechanical preflight (the two-minute promise; gstack measured
~2.5 s for regen and under 200 ms for each of the others):
-
regen: every registry entry whose inputs or outputs are touched is fresh (gstack-regen checkin a scratch worktree). A fresh entry with touched outputs then runs once more with those outputs deleted; the ones the command recreates byte-identically are the check'sprovenpaths, whichgstack-ship-receipt carryreads. The verdict comes from the first run: an output the second run does not recreate, or a second run that fails, is simply not proven. -
secrets:gstack-redactover the branch's added lines (merge-base to the working tree, untracked files included). HIGH, or MEDIUM in thesecretcategory, fails; other MEDIUM findings warn. Cannot be downgraded. -
strays: a new file at the repository root, a new top-level directory, or a scratch name (zz-*,tmp*,scratch*) anywhere. -
literals: for every changed exported constant or named timeout/retry literal, the whole test tree (nightly- and Heavy-only files included) is searched for the old value; a consumer the branch did not update fails withfile:line. -
timing: added or changed test lines that compare an elapsed wall-clock or CPU time against a bound, or sleep and then assert, printTIMING_ASSERTION: <file>:<line> <pattern>and aTIMING_ASSERTIONS: found=<n> escaped=<n> files=<n>summary. It warns (PREGATE_TIMING_ASSERTION); it does not block. See timing. -
guards: each guard in the active policy (.gstack/pregate.json, or the user-owned file) whosewhenglobs match a touched file (renames included) runs itscommandin its own process, under the verified launch Bun, with its owntimeout_s(default--timeout). A non-zero exit fails pregate naming the guard and itsfix(PREGATE_GUARD_FAILED); a guard that could not start, or whose own binary is off PATH (exit 127), is the machine's,PREGATE_ENVIRONMENT(incomplete), not a guard failure; a timeout isincomplete; without--allow-repo-commandsthe selected guards are listed and the check isincomplete. Guards are read from the base branch's copy (or the user-owned file when the base carries none) and cannot be downgraded to warn. See guards.
Stage 2, test execution:
lanes: touched files map to tests throughlib/pregate/import-graph.ts(static imports ∪ repo-path string literals ∪ thebin/<name>→test/<name>*.test.tsconvention ∪dependencies; Dartimport/export/partdirectives resolvepackage:<name>/xthrough every trackedpubspec.yaml, and*_test.dartfiles under atest/directory are tests, except underintegration_test/andtest_driver/: they need a device, so pregate never runs them, and a touched one has no lane until you declare"**/integration_test/**": [](and"**/test_driver/**": []) underdependenciesand let CI run them; Dart files, device tests included, are source code, so adependencies[]declaration clears them andempty_lanesdoes not); in a Rust crate (the nearest Cargo.toml with a[package]name) a.rsfile is a test when it sits under<crate>/tests/, holds a#[cfg(test)](any cfg predicate namingtest) or a test attribute (#[test],#[tokio::test]), and every.rsfile of the crate and its Cargo.toml select the crate's tests and those of every crate whose manifest depends on it, directly or through other crates (cargo runs a crate's tests, not a file, so the lane is the crate; a crate with no tests still has no lane, and a workspace-only Cargo.toml or Cargo.lock needs adependenciesentry), transitively through non-test files; deleted and renamed files select the tests that named them. A removed test (a deleted one, or a rename's old name) that no remaining test selects owes no lane, and printsremoved (no lane owed): <path> — <why>(thelanesdetail addsN removed path(s)), when it is a JS, TS, Python, Dart or bats test and no remaining file imports it (directly or through a directory it reads; otherwisestill imported by …). It runs the tests of each remaining non-Markdown file that mentions its name without the final extension anywhere (alpha.test,test_alpha: a path with or without the extension, or a dotted module), such as a curation list (files naming it run: …), and fails closed when such a file has no test (still named by …, which no test covers). A test renamed to another test path then defers to its new name (old name of <new>). A deleted test, or one renamed to a non-test path, also runs its owning suite, the remaining tests that directly exercise the files its merge-base text exercised (owning suite runs: …; test helpers and fixtures are not subjects), and names each file left with no test at all (coverage removed: <file> has no remaining test, andM subject(s) lost their last testin the detail). Every other removal owes what an edit owes, as before: a deleted or renamed Go or Ruby test (it may define helpers its package still uses), a deleted test whose merge-base text cannot be read (a symlink, invalid UTF-8, over 1 MiB), and any removed non-test path, a renamed source file's old name included, are stillPREGATE_NO_LANEuntil a test or adependenciesentry covers them. A modified file whose only test the branch deleted still has no lane, and a remaining test that reads a deleted file or directory is selected. /review lists a deleted test as a gate edit (RH-1) either way. An extensionless JS/TS import (relative, a tsconfig/jsconfigpathsalias orbaseUrl) tries the exact path, then.ts,.tsx,.js,.jsx,.mjs,.cjs,.mts,.cts, thenindex.ts,index.tsx,index.js,index.jsxandindex.mjsinside the directory; the first file that exists wins. A touched source file with no lane isFAIL lanes — <file> has no test laneonce any policy exists (the base copy, the user-owned file or--policy-from head); with no policy anywhere it is thewarnPREGATE_NO_POLICYwith a starter printed (no policy); never a pass. Markdown that no test depends on is a docs/no-tests empty lane instead (docs/no-tests: N documentation file(s)), including when the base branch has no.gstack/pregate.json. The dependency is the same import graph as every other file, so a test that reads the Markdown (a fixture or a doc) still selects that test.empty_lanesadds globs for other documentation; it cannot clear a source file or a file under a test directory. Files no test runner loads take a second empty lane by the same rule (no-tests (assets and tool config): …): images, fonts, media and PDFs outside snapshot, golden and fixture directories, plus an allowlist of tool configuration (.editorconfig,.gitignore,.pre-commit-config.yaml, lint and format config,.vscode/,.idea/,.impeccable/, and GitHub issue templates, CODEOWNERS and dependabot config). Stylesheets (they import each other), any other dotfile (.rspec,.husky/hooks,.env*, version pins), CI workflows and actions, and.gstack/still need a lane. A release manifest the branch only re-stamped is waived as version-stamp-only (version-stamp-only (no lane needed): …): the file's diff against the merge base changes nothing but its version values. Three file kinds qualify, each by its own exact rule (lib/pregate/stamp-only.ts). A JSON file (*.json) whose two sides parse to objects of the same shape (the same own keys in the same order in every object, the same length in every array, the same type at every leaf), whose only differing leaf is the document's top-levelversionwith a bare version on both sides (1.2.3,v1.2.3.4,1.2.3-rc.1; never a range), and whose text is line-for-line identical once that line has its old quoted version replaced by the new one. A plist whose differing lines are all<string>versions under<key>CFBundleShortVersionString</key>or<key>CFBundleVersion</key>. AVERSIONorVERSION.txtfile (any case) holding one bare version, with at most a trailing LF, on both sides. Texts are compared byte for byte outside the stamped value, so a line-ending change or a BOM is content. Nothing nested in JSON qualifies, so a dependency pin or range, a lockfile entry, a catalog or a package namedversionis never a stamp; nor is a changed description, a new, removed or reordered key, a floor such asminHostVersion,schemaVersionorMinimumOSVersion, a reflow, a change the parser would erase (a big integer, a duplicate key), a file that is not valid UTF-8 or is over 1 MiB, a mode change or a symlink on either side, an added, deleted or renamed manifest, or any other file kind: each keeps itsPREGATE_NO_LANE. The rule runs only after the import graph, the declarations, the regen registry and both empty lanes found nothing, so a manifest a test reads still selects that test, and never under a test, fixture, golden, snapshot,.gstack/or CI-workflow tree, whose JSON a test may compare against by a path the graph cannot see.--explainprints the verdict per file (version-stamp-only against <merge-base> (version 1.0.0 → 1.0.1)ornot version-stamp-only (<reason>)). In a Django project (a manage.py or code importing django, plus Python code, not a string or comment, that assignsINSTALLED_APPSorROOT_URLCONF, or a config file namesDJANGO_SETTINGS_MODULE), the files Django loads by name getframeworkedges: every Python test depends on each settings module; a dotted string in a settings module and aninclude("app.urls")argument count as imports; an app's migrations,admin.py,templates/,static/,templatetags/,management/,fixtures/andlocale/files select that app's tests and the tests that reach itsmodels.py(the settings module, so the suite, when the app has no tests of its own); a template under a project-leveltemplates/directory selects the suite through the settings module. Lanes and platform tiers come from.github/workflows/*.yml(gstack-pregate lanesprints them; a test path a step names resolves against that step'sworking-directory, else the job's or workflow'sdefaults.run.working-directory): local ones run through the runner underset -o pipefail, judged on the runner's summary line (zero tests ran is a failure, a| tail-hidden exit is still an exit; cargo'stest result:lines, one per test binary and doc-tests included, are summed); a platform lane this machine cannot run becomes arequires-remoteobligation namingworkflow.yml/job, unlesslocalnames that lane.--explainprints each test's provenance and every uncertain mapping (a planner-computed matrix without aselectionmanifest selects conservatively). Test files matchingserial_patterns(default**/*.serial.test.*) run one process per file after the unit files. The runner and every guard run with the verified launch Bun first on PATH: the first of$BUN_INSTALL/binand~/.bun/binwhose Bun satisfies gstack's and the project'sengines.bun(the row printsruntime: bun <v> from <dir>), so a Capy host's older image Bun cannot flip the verdict.- Every test-tier command (the lanes runner, each guard, the
--remotegate) runs supervised: see supervision.--timeoutbounds each runner process (the unit-file group, each serial file, each guard without its owntimeout_s), not their total; the stall timeout catches a hung or silent one sooner. - Under 8 cores, a
remote_gatein the active policy or a remotetest_backend(gstack-config set test_backend ubicloud) records the local test tier as arequires-remoteobligation instead of running it: see remote gate.
Every check records the input identity it ran against (the working-tree
fingerprint before and after, the selection, the command hash); a tree that
moved during a check, a timeout or a missing tool is incomplete, never pass.
The record is .gstack/tmp/pregate.json (gstack-artifact validate --as pregate).
Publication. gstack-ship-receipt write --pregate .gstack/tmp/pregate.json
(Step 18) refuses unless the record is current for the tree being published
(PREGATE_STALE: rerun after the final stamp), green (PREGATE_LANE_FAILED,
PREGATE_INCOMPLETE, or PREGATE_ENVIRONMENT when every incomplete check is
the machine's) and every requires-remote obligation carries a receipt
(PREGATE_REMOTE_UNCLEARED: dispatch the job, then
gstack-pregate clear --remote <workflow.yml/job> --run-url <url>). The ship
receipt then carries pregate and requires_remote. gstack-pregate verify
runs the same check standalone.
By tree. Every record gstack-pregate writes is also mirrored, byte for
byte, to <state root>/pregate/by-tree/<tree>.json (the state root from
docs/state-root.md). gstack-ship-queue ready reads the checkout's
.gstack/tmp/pregate.json first; when that file is missing or was recorded
for another tree, or is current but still waits on a remote receipt, it looks
the current tree up in the mirror and uses that record when it verifies,
printing PREGATE: … (from the state root's pregate/by-tree/<tree12>.json, recorded by another checkout). This is for the lane whose gstack-pregate --remote had to run from a separate clone (a worktree cannot host it): the
clone's record clears the lane's own checkout as long as both hold the same
tree. Only a run that holds the tests stage stands in (what --stage tests --remote writes, checks=[lanes]; a --only timing or preflight-only run
is "a run without the tests stage"), and it must be recorded against
origin/<base> and not under --policy-from head;
otherwise the PREGATE: line says why the mirror cannot stand in. A record
whose own checks failed or stayed incomplete is final and no mirror overrides
it. An explicit --pregate <file> that verifies is used as before; a mirror
for a different tree changes nothing. Writing the mirror is best effort (a
temp file renamed into place) and never fails the record itself.
Unknown flags exit 2 with usage on every verb (an agent told to run a flag this
gstack lacks never gets a green it did not ask for). Tier 2 checks still to come
(ratchets, lint, links, hermetic, patches, integrator) are named in
--only's help.
Supervision: progress, stalls, deadlines
GBRA-95 (gbrain, 4 cores): a core edit selected 3,683 local files, 358 of them
serial; the test stage printed nothing for 90 minutes: each runner process
ran up to its own --timeout (one per serial file) with its output buffered
until it returned, and nothing noticed a process that had gone quiet. Now each
test-tier command:
- runs in its own process group (
setsid), with stdout and stderr appended to a log file, never a pipe, so a backgrounded grandchild that keeps the output open cannot hold the check; after the command exits, the group's leftover members are killed; - reports live on stderr (stdout stays the table, or one JSON document with
--json):Per-file lines come from a progress adapter where the runner's reporter provides them:PREGATE_TEST: <n>/<total> <file> start PREGATE_TEST: <n>/<total> <file> pass|fail <seconds> PREGATE_HEARTBEAT: <label> running <s>s; last file <file>; log <bytes> bytes, last growth <s>s ago (<log>) PREGATE: tests <verdict> in <s>s — lanes: <detail>bun test(its<file>:headers) is the only adapter today; a one-file run (each serial file) reports its file from the launch for any runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test, cargo test) get heartbeats only. A heartbeat follows each 60 s with no progress line; - is killed as stalled when its log does not grow for the stall timeout
(
--stall-timeout <s>, elsestall_timeout_s, else 300;0disables): the check isincompletewithPREGATE_STALLEDand names the last file started; the next serial file still runs; - is killed as timed out when that runner process reaches
--timeout(per runner process, so many serial files that each fit still pass); the check isincompleteand the next serial file still runs.
A kill is SIGTERM to the group, then SIGKILL after 5 s (a group that ignores
SIGTERM still dies). SIGINT or SIGTERM to gstack-pregate itself SIGKILLs every
running group, writes no pregate.json and exits 130 or 143. Windows has no
process groups here: the kill is taskkill /T /F on the shell's tree, which
misses a descendant that was reparented or started detached.
Small machines and the remote gate
Under 8 cores (os.availableParallelism()), when the active policy (the
base branch's .gstack/pregate.json, or the user-owned file) declares remote_gate or gstack-config get test_backend is not local, the lanes check runs nothing locally: it records
the selected local files as a requires-remote obligation with the command to
run, pregate.json/remote_gate (the declared command) or
test_backend/<backend> (gstack's scripts/ubicloud/ubi-runner.sh run --src <repo> -- '<runner> <files>'). Without either, the tier runs locally under
the supervision above. Close the obligation one of two ways:
- after running it,
gstack-pregate clear --remote pregate.json/remote_gate --receipt <file>with the gate's output, validated as--remotevalidates it (a run URL is refused,PREGATE_RECEIPT_INVALIDfor a bad receipt; a receipt for a tree that differs from this one only by the repo's stamp rule is accepted and recorded, see below), orgstack-pregate clear --remote test_backend/<backend> --run-url <url>, which has no machine-checkable receipt and is recorded asattested:<url>; or gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands, which runs the declaredremote_gatenow (never under--policy-from head; atest_backendwithoutremote_gatecannot be run this way). The gate runs in the repository root withPREGATE_TREE(the working-tree fingerprint being tested),PREGATE_FILES_FILE(the selected files, one per line) andPREGATE_SPEND_CAP_USD, and must printPREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>], reporting the tree it actually tested. The obligation clears only when that tree isPREGATE_TREE,tests> 0,failed= 0 and the gate exits 0; anything else leaves it uncleared and the checkincomplete. The cap defaults to$GSTACK_SPEND_CAP_USD; there is no default spend. Spend prints asspent, reserved, unknown: a gate that prints nospent=isunknown, counted at the cap.
A receipt from a stamp-only predecessor tree (gate ahead of the stamp)
The receipt clear --receipt validates names the tree the gate tested, and
pregate.json names the tree being published. A repo whose full gate runs before
its turn in the queue ("gate ahead": the gate tests commit G, then /ship's
restamp moves the version files, so the head H it publishes is G plus the
stamp) could never clear the obligation with that run, and paid a second full
gate on H for a diff that was nothing but stamps. clear now accepts that
receipt for H when the two trees differ only as a stamp does:
- gstack diffs the gated tree against pregate.json's tree
(
git diff --unified=0 <gated> <head>, both trees as objects in this repository: the gated commit's tree once it is fetched, and the head treebin/gstack-wtreewrote) and classifies every differing file with the tree receipt's own rule,lib/tree-receipt.tsunder.gstack/ship-policy.jsononorigin/<base>(ship-policy). That is the rulegstack-tree-receipt,gstack-pregate regateand the train'sverify --trainapply: withstamp_filesset only those files can be stamps, each hunk in them must be the exact old → new rewrite understamp_patterns(literal{version}templates) or the built-in forms ((was X),since: X,vX), and the version file,package.json'sversion, a lockfile's root version keys and a CHANGELOG re-heading have their own built-in rules. There is no second list and no looser rule here: the file verdictsclearreaches on a pair are the verdictsgstack-tree-receipt --gated G --current Hreaches, and its gate rule holds too: a policy whosestamp_sensitive_gatesnames the test lane (tests, as gstack's own does because its tests read VERSION and the agents digest) is refused outright, since the tree receipt never marks such a lane reusable. The policy is the one on this repository's base branch (origin/HEAD, asregatereads it) at its current tip, never a branch pregate.json names: a pregate.json recorded against another base is refused. What the receipt does not compare, exactly as a same-tree receipt does not, is the gate's execution identity (Bun, OS, lockfiles); that stays the evidence bundle's job. - Accepted, the obligation's receipt reads
remote-gate tree=<gated> tests=<n> accepted-for=<head> stamp-only=<file count> receipt=<file> sha256=<digest>, and pregate.json records the substitution beside it:substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }, the policy label (origin/<base>@<sha>:.gstack/ship-policy.json) and every differing path with the rule that classed it (version-file,version-manifest,manifest-version,lockfile-root-version,changelog-heading,stamp_files,stamp_paths,release_outputs).clearprints the same asPREGATE_RECEIPT_SUBSTITUTED: …,verifyrepeats it on its ok line, and the ship receipt carries the receipt string. The receipt's own tests and failed checks apply as before (its exit status is checked only when--remoteruns the gate itself), andverifyrefuses a recorded substitution whosegated_treeis not the receipt string'stree=. - Refused, with
PREGATE_RECEIPT_INVALIDand a fix naming the offending path: any differing path outside the rule (lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)), a stamp path whose hunk is not the stamp rewrite (plugin.json (stamp_files: "…description…" -> "…", line 4)), a gated tree that is not a 40- or 64-hex object id or not an object here (fetch the gated commit first), a base branch whose policy cannot be read or is invalid, a pregate.json recorded with--baseagainst another branch than this repository's base (origin/HEAD), a policy whosestamp_sensitive_gatesnames the test lane (testsorremote_gate; gstack's own policy does, so gstack itself never substitutes), or a patch that does not account for every pathgit diff-treelists between the two trees. The version rule is the tree receipt's: astamp_files,stamp_pathsorrelease_outputshunk is a stamp only as the rewrite to the head's version from the gated tree's version, or from a newer tentative version of the repo's width that the stamp moved (a*_KEY_VERSIONwritten as the next minor, #3512) (so two trees at the same version refuse such a hunk asother), while the built-in version-file, manifest, lockfile and CHANGELOG rules compare content and do not check the direction of the version move. The obligation stays uncleared; rerun the gate on the head, or make the extra change a stamp under the repo's rule.
A consuming repo declares its stamp paths once, in .gstack/ship-policy.json
on its base branch (stamp_files, stamp_patterns, changelog), the same
file the tree receipt and restamp already read; nothing is declared in
.gstack/pregate.json for this. gbrain's is in
gbrain worked configs.
The gate must have run on a clean, committed checkout: PREGATE_TREE is the
bin/gstack-wtree fingerprint, which equals the commit's tree only when nothing
was uncommitted or untracked there, and clear can compare only trees that are
objects in this repository. A receipt whose tree is not here is refused, never
guessed. The sequence that reuses a gate-ahead run, on a machine under 8 cores:
git fetch origin <gated sha> # the gated commit's tree must be here
gstack-pregate --allow-repo-commands # on the stamped head: records pregate.json/remote_gate uncleared
gstack-pregate clear --remote pregate.json/remote_gate --receipt <the gate's output for the gated tree>
gstack-pregate verify # PREGATE_VERIFY: ok …; pregate.json/remote_gate: gated tree … accepted for tree …
gstack's own example: .gstack/pregate.json declares
"remote_gate": "bash scripts/ubicloud/pregate-remote.sh". With
UBICLOUD_API_KEY set the script runs the selected files on one Ubicloud VM
with bun run test:ubicloud's environment, refuses before creating the VM when
the cap cannot buy a 20-minute run at the size's list price (UBI_SIZE,
default standard-16), checks the tree and the planned file set on the VM