v1.91.257.0 feat(pregate): clear --receipt accepts a remote_gate receipt from a stamp-only predecessor tree (gbrain gate-ahead) (#3486)

* feat(pregate): clear --receipt accepts a remote_gate receipt from a stamp-only predecessor tree (gbrain gate-ahead)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (hex object ids, stamp_sensitive_gates and base-branch refusals, one substitution type, docs)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): harden the stamp-only diff read (fixed presentation, diff-tree reconciliation), refuse an unreadable base policy, check the receipt before git work, bind verify to the receipt tree; docs audit corrections

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): the stamp-only suite expects the Linux-host lanes verdict; skip itself on win32 like test/pregate-remote.test.ts

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 22:09:32 +00:00
1 parent 576839cb8c
commit 7f53ddb1f3
13 files changed
+612 -28

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# Changelog
## [1.91.257.0] - 2026-10-26
**A remote gate that ran ahead of the stamp now clears /ship's small-machine obligation; gbrain stops paying a second full gate per PR.** `gstack-pregate clear --remote pregate.json/remote_gate --receipt <file>` accepts a `PREGATE_REMOTE:` receipt whose tree differs from pregate.json's tree only in the repo's declared stamp paths. gstack proves it itself: a `git diff` of the two trees, every differing file classified by the tree receipt's own rule (`lib/tree-receipt.ts` `classifyFile` under `.gstack/ship-policy.json` on the base branch: `stamp_files`, `stamp_patterns`, and the built-in version-file, manifest-version, lockfile-root-version and changelog-heading forms), so the pregate verdict and `gstack-tree-receipt`'s can never disagree; there is no second stamp list, and its gate rule holds too: a policy whose `stamp_sensitive_gates` names the test lane (`tests`, as gstack's own policy does) refuses the substitution, because the tree receipt never marks that lane reusable. The policy is the ship's own base branch's (`origin/HEAD`); a pregate.json recorded against another base is refused. gbrain's rule lets a head reuse a full gate whose tree differs only in release stamps, but /ship's Step 12 restamp always changed those files, so no gate-ahead run could satisfy `remote_gate` and every gbrain PR ran the whole gate again after /ship (#7621, #7674, #7681: 17–37 min, 64–128 vCPU each).
### Added
- **`lib/pregate/remote-receipt.ts`** (`substituteStampOnlyTree`): both trees must be objects here (the gated commit fetched, the head tree `bin/gstack-wtree` wrote); a path outside the rule, a stamp path whose hunk is not the exact old → new rewrite, an unfetched or non-hex gated tree, an unreadable, unfetched or invalid base policy, a policy whose `stamp_sensitive_gates` names the test lane, a pregate.json recorded against another base, a patch that does not account for every path `git diff-tree` lists, or a `stamp_files` hunk between two trees at the same version refuse with `PREGATE_RECEIPT_INVALID` and a fix naming the offending path (`lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)`). The patch is read with a fixed presentation (`--no-textconv --no-renames --ignore-submodules=none`, `a/` `b/` prefixes) so a diff driver or `diff.*` setting can neither hide nor rename a path. The receipt's tests and failed checks are unchanged and run before any git work.
- **The substitution is recorded.** The receipt string gains `accepted-for=<head> stamp-only=<n>` after `tests=`, and pregate.json's obligation carries `substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`; `clear` prints `PREGATE_RECEIPT_SUBSTITUTED: gated tree … accepted for tree … under <policy>; stamp-only: <path> (<rule>), …`, `verify` repeats it on its ok line, and a substitution whose `accepted_for` is not pregate.json's tree, or whose `gated_tree` is not the receipt string's `tree=`, is `PREGATE_REMOTE_UNCLEARED`.
- **Docs:** [docs/pregate.md#remote-gate-stamp-only](docs/pregate.md#remote-gate-stamp-only) (what is accepted, what is refused, how a consuming repo declares its stamp paths: `.gstack/ship-policy.json` on the base branch, the file the tree receipt already reads, and the four-command sequence that reuses a gate-ahead run); the `PREGATE_RECEIPT_INVALID` and `PREGATE_REMOTE_UNCLEARED` troubleshooting anchors.
### Changed
- `lib/tree-receipt.ts` exports `classifyFile` (the function is unchanged) so pregate reuses the rule instead of restating it; `PregateRemote` in `lib/headless-artifacts.ts` gains the optional `substitution` field (`PregateReceiptSubstitution`) and its schema entry. Both trees must be 40- or 64-hex object ids before any git call (a symbolic `HEAD^{tree}` in a receipt is refused, never recorded as the gated tree), and `clearDecision` now takes the repository root.
### Tests
- `test/pregate-remote-stamp-only.test.ts`: accept (five stamp files under a `stamp_files` + `stamp_patterns` policy, the recorded substitution equal to `gstack-tree-receipt`'s file verdicts on the same pair, `verify` ok), the unchanged same-tree path, refusals (a path outside the rule, a stamp path with a description edit, an unfetched gated tree, an unreadable receipt file, zero tests, a failure, a symbolic tree, a head tree not in the object store, a mode-only difference, a stamp file at the wrong version, a pregate.json recorded against another base with and without origin/HEAD, an invalid base policy, a policy whose `stamp_sensitive_gates` names the test lane), `verify` on a misapplied substitution, and a same-tree re-clear dropping the recorded substitution.
## [1.91.256.0] - 2026-10-26
**The /ship review loop converges, and a receipt survives a base merge.** Five Captains' /ship runs hit the review cap on the same PR shape: every pass re-reviewed the whole diff, reviewers answered an empty range with an invented advisory, and each advisory earned another full pass; four more re-ran /ship on product code they had not changed because a conflict-only main merge or a test fix moved the head the receipt named. The loop now fixes blocking findings in the diff on pass 1, reads only the range since the previous pass afterwards, caps at three passes, and counts `passes=<n> blocking_found=<k> deferred=<m>` in the ship receipt; the receipt carries to the new head when the product patch-id is unchanged. The blocking floor is the owner's call (Garry, 2026-10-26: "Ok set it at that"), and the policy lives in gstack rather than in any one team's rules (Garry, 2026-10-26: "Gstack should be the actual holder of how to do things not one off Capy files"): `docs/review-policy.md` is the canonical statement, the skills carry it as their written policy, and a crew rule can be one line pointing at it.
+1 -1
View File
@@ -1 +1 @@
1.91.256.0
1.91.257.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.256.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.257.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+8 -4
View File
@@ -33,6 +33,7 @@ import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
import { describeSubstitution } from '../lib/pregate/remote-receipt';
import { ensureTmpExcluded } from '../lib/restamp';
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
@@ -48,7 +49,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
run (default) stage 1 mechanical preflight (${CHECKS.filter(c => c.stage === 'preflight').map(c => c.id).join(', ')}), then stage 2 test execution (${CHECKS.filter(c => c.stage === 'tests').map(c => c.id).join(', ')})
verify publication check against ${PREGATE_OUT_REL}: current tree, no fail or incomplete, every requires-remote obligation cleared
clear record the receipt that clears one requires-remote obligation: pregate.json/remote_gate needs
--receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree); a
--receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree; a tree that
differs from pregate.json's only in the base branch's stamp rule is accepted and recorded as a
substitution, docs/pregate.md#remote-gate-stamp-only); a
test_backend/<backend> run URL is recorded as attested, not validated
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
validate | init the working-tree ${PREGATE_CONFIG_PATH}
@@ -211,14 +214,15 @@ function cmdClear(root: string, rest: string[]): number {
const out = outPath(root, rest);
const read = fs.existsSync(out) ? readPregateFile(out) : { file: null, errors: [] };
if (!read.file) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} is missing or invalid; run gstack-pregate first`) + '\n'); return EXIT.fail; }
const decision = clearDecision(read.file, lane, { runUrl: flag(rest, '--run-url'), bundleLane: flag(rest, '--bundle-lane'), receiptPath: flag(rest, '--receipt') });
const decision = clearDecision(read.file, lane, { runUrl: flag(rest, '--run-url'), bundleLane: flag(rest, '--bundle-lane'), receiptPath: flag(rest, '--receipt') }, root);
if ('refused' in decision) {
if (decision.refused === 'usage') { process.stderr.write(`gstack-pregate clear: ${decision.message}\n`); return EXIT.usage; }
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${decision.fix ?? RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
return EXIT.fail;
}
const file = clearObligation(read.file, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`);
const file = clearObligation(read.file, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution);
writePregateFile(root, out, file);
if (decision.substitution) process.stdout.write(`PREGATE_RECEIPT_SUBSTITUTED: ${describeSubstitution(decision.substitution)}\n`);
process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${decision.receipt} verdict=${file.verdict}${decision.attested ? ` (attested, not validated: ${lane} has no machine-checkable receipt)` : ''}\n`);
return EXIT.ok;
}
+89 -1
View File
@@ -357,7 +357,9 @@ 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 `--remote` validates it
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt), or
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for 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](#remote-gate-stamp-only)), or
`gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which
has no machine-checkable receipt and is recorded as `attested:<url>`; or
- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`,
@@ -374,6 +376,92 @@ the supervision above. Close the obligation one of two ways:
`spent, reserved, unknown`: a gate that prints no `spent=` is `unknown`,
counted at the cap.
<a id="remote-gate-stamp-only"></a>
### 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 tree
`bin/gstack-wtree` wrote) and classifies every differing file with the tree
receipt's own rule, `lib/tree-receipt.ts` under `.gstack/ship-policy.json` on
`origin/<base>` ([ship-policy](ship-policy.md#tree-receipt)). That is the rule
`gstack-tree-receipt`, `gstack-pregate regate` and the train's
`verify --train` apply: with `stamp_files` set only those files can be
stamps, each hunk in them must be the exact old → new rewrite under
`stamp_patterns` (literal `{version}` templates) or the built-in forms
(`(was X)`, `since: X`, `vX`), and the version file, `package.json`'s
`version`, 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 verdicts `clear` reaches on a pair are the verdicts
`gstack-tree-receipt --gated G --current H` reaches, and its gate rule holds
too: a policy whose `stamp_sensitive_gates` names 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`, as
`regate` reads 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`). `clear` prints the same
as `PREGATE_RECEIPT_SUBSTITUTED: …`, `verify` repeats 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
`--remote` runs the gate itself), and `verify` refuses a recorded
substitution whose `gated_tree` is not the receipt string's `tree=`.
- Refused, with `PREGATE_RECEIPT_INVALID` and 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 `--base` against another branch
than this repository's base (`origin/HEAD`), a policy whose
`stamp_sensitive_gates` names the test lane (`tests` or `remote_gate`;
gstack's own policy does, so gstack itself never substitutes), or a patch
that does not account for every path `git diff-tree` lists between the two
trees. The version rule is the tree
receipt's: a `stamp_files`, `stamp_paths` or `release_outputs` hunk is a stamp
only as the rewrite from the gated tree's version to the head's (so two trees
at the same version refuse such a hunk as `other`), 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](#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:
```bash
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
+35 -4
View File
@@ -3122,9 +3122,32 @@ reports failures. The obligation stays uncleared. A run URL alone never clears
this obligation; a `test_backend/<backend>` obligation takes a run URL, recorded
as attested rather than validated.
Another tree is accepted in exactly one case: it differs from `pregate.json`'s
tree only in the repo's stamp rule (`.gstack/ship-policy.json` on the base
branch: `stamp_files`, `stamp_patterns`, the built-in version, manifest,
lockfile and CHANGELOG forms), judged by the tree receipt's own classifier on a
`git diff` of the two trees ([the gate ran ahead of the
stamp](pregate.md#remote-gate-stamp-only)). The refusal line then says `and the
trees differ beyond the stamp rule of <policy>: <path> (<rule>: <why>, line
<n>)`, and the path it names is the one that is not a stamp: a file outside
`stamp_files`, or a stamp file with a hunk that is not the old → new rewrite.
`the gated tree <t> is not an object in this repository` means the gated
commit is not fetched here, so the two trees cannot be compared. Three more
refusals close the substitution without classifying anything: `lists the test
lane as stamp-sensitive (stamp_sensitive_gates: tests)` (the base policy says
the test tier reads the stamps, as gstack's own does, so no run on another tree
covers this one; rerun the gate on the head), `pregate.json was recorded against
origin/<a>, not this repository's base origin/<b>` (rerun `gstack-pregate --base
<b>`, or `git remote set-head origin <a>` when `origin/HEAD` was unset), and
`.gstack/ship-policy.json on origin/<base>@<sha> is invalid` or `could not be
read` (fix the policy on the base, or fetch it).
**Fix.** Run the remote gate on the tree pregate tested and pass its output
file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap
<usd> --allow-repo-commands` run and validate it.
<usd> --allow-repo-commands` run and validate it. For a gate-ahead receipt:
fetch the gated commit (`git fetch origin <sha>`) and clear again, or make the
named path a stamp under the repo's rule; a receipt is never accepted while any
differing path is a change.
<a id="pregate-no-policy"></a>
### `PREGATE_NO_POLICY`
@@ -3190,12 +3213,20 @@ refuses to write the ship receipt otherwise.
### `PREGATE_REMOTE_UNCLEARED`
**Meaning.** A `requires-remote` obligation (a platform lane this machine cannot
run, named by `workflow.yml/job`) has no receipt.
run, named by `workflow.yml/job`, or the small-machine `pregate.json/remote_gate`
tier) has no receipt; or a `remote_gate` receipt accepted from a stamp-only
predecessor tree records `accepted_for` as a tree other than `pregate.json`'s,
or a `gated_tree` other than its receipt string's `tree=` (the file was edited
after `clear`), so it clears nothing here.
**Fix.** Dispatch that job, then
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>` (or
`--bundle-lane <label>` from an evidence bundle). The ship receipt carries each
obligation with its receipt.
`--bundle-lane <label>` from an evidence bundle). For `pregate.json/remote_gate`,
`clear --remote pregate.json/remote_gate --receipt <gate output>`: the gate's
own run on this tree, or a run on a predecessor tree that differs only by the
repo's stamp rule ([gate ahead of the stamp](pregate.md#remote-gate-stamp-only));
`verify` then prints the accepted substitution on its ok line. The ship receipt
carries each obligation with its receipt.
<a id="issue-unreadable"></a>
### `ISSUE_UNREADABLE`
+5 -2
View File
@@ -70,7 +70,9 @@ export interface PregateCheck {
// Lane D (GSTA-112): a non-pass names its cause; an environment cause carries its kind and evidence.
cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' };
}
export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string }
/** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */
export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> }
export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string; substitution?: PregateReceiptSubstitution }
export interface PregateFile {
schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number;
// C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict.
@@ -201,7 +203,8 @@ export const ARTIFACT_SCHEMAS = {
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } } } } },
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str,
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } },
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str,
substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } },
},
},
'ship-receipt': {
+4 -1
View File
@@ -382,9 +382,12 @@ export function parseRemoteReport(output: string): RemoteReport | null {
return m ? { tree: m[1]!, tests: Number(m[2]), failed: Number(m[3]), spent: m[4] } : null;
}
/** The tree-mismatch clause `--remote`, `clear --receipt` and the stamp-only substitution (lib/pregate/remote-receipt.ts) all open with. */
export const treeMismatch = (tested: string, tree: string) => `the remote gate tested tree ${tested.slice(0, 12)}, not ${tree.slice(0, 12)}`;
/** Why a remote gate report does not clear the obligation for `tree` (`--remote` and `clear --receipt`), or null when it does. */
export function remoteReportProblem(report: RemoteReport, tree: string): string | null {
if (report.tree !== tree) return `the remote gate tested tree ${report.tree.slice(0, 12)}, not ${tree.slice(0, 12)}`;
if (report.tree !== tree) return treeMismatch(report.tree, tree);
if (report.tests === 0) return 'the remote gate reported 0 tests';
if (report.failed > 0) return `the remote gate reported ${report.failed} failed of ${report.tests}`;
return null;
+37 -12
View File
@@ -13,10 +13,10 @@
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateRemote, type ValidationError } from '../headless-artifacts';
import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts';
import { wtreeFingerprint, type WtreeResult } from '../script-invocation';
import { guardsCheck } from './checks/guards';
import { clearHint, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem } from './checks/lanes';
import { clearHint, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem, treeMismatch } from './checks/lanes';
import { literalsCheck } from './checks/literals';
import { regenCheck } from './checks/regen';
import { secretsCheck } from './checks/secrets';
@@ -24,6 +24,8 @@ import { straysCheck } from './checks/strays';
import { timingCheck } from './checks/timing';
import { NON_DOWNGRADABLE, userPolicyAbsent } from './config';
import { sha256, withFingerprint } from './context';
import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt';
import { resolveBaseBranch } from '../ship-policy';
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
@@ -146,7 +148,11 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
}
if (incomplete.length) return { ok: false, code: 'PREGATE_INCOMPLETE', message: `${incomplete.length} check(s) incomplete: ${incomplete.map(c => c.id).join(', ')}${environment.length ? ` (blocked by the environment: ${environment.map(c => `${c.id} ${c.environment?.kind}`).join(', ')})` : ''}`, current, uncleared };
if (uncleared.length) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: `${uncleared.length} requires-remote obligation(s) without a receipt: ${uncleared.map(r => r.lane).join(', ')}`, current, uncleared };
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared };
// A receipt accepted from a stamp-only predecessor tree was accepted for one head tree; recorded for another, it clears nothing here.
const misapplied = (file.requires_remote ?? []).filter(r => r.substitution && (r.substitution.accepted_for !== file.tree || !(r.receipt ?? '').includes(`tree=${r.substitution.gated_tree.slice(0, 12)} `)));
if (misapplied.length) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: `${misapplied.length} remote receipt(s) accepted for another tree or recorded against another gated tree: ${misapplied.map(r => `${r.lane} (accepted for ${r.substitution!.accepted_for.slice(0, 12)} from gated ${r.substitution!.gated_tree.slice(0, 12)}, pregate.json is ${file.tree.slice(0, 12)})`).join(', ')}; clear again`, current, uncleared: misapplied };
const substituted = (file.requires_remote ?? []).filter(r => r.substitution).map(r => `${r.lane}: ${describeSubstitution(r.substitution!)}`);
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}`, current, uncleared };
}
/**
@@ -171,17 +177,21 @@ function userPolicyChange(file: PregateFile): string | null {
}
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string }
export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string };
export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
/**
* What `clear` may record for one obligation. A `pregate.json/remote_gate`
* obligation needs the gate's own report (`--receipt <file>` holding its
* PREGATE_REMOTE: line), validated as `--remote` validates it against the
* tested tree; a run URL is not checkable there. A `test_backend/<backend>`
* obligation has no machine-checkable receipt, so its run URL is recorded as
* attested. Platform lanes take a run URL or an evidence-bundle lane label.
* tested tree; a run URL is not checkable there. A report whose tree is a
* stamp-only predecessor of pregate.json's tree in `repoRoot` (the gate ran
* ahead of the stamp; lib/pregate/remote-receipt.ts) is accepted for this
* tree and the substitution recorded; when any differing path is not a stamp,
* the tree mismatch refuses as before. A `test_backend/<backend>` obligation
* has no machine-checkable receipt, so its run URL is recorded as attested.
* Platform lanes take a run URL or an evidence-bundle lane label.
*/
export function clearDecision(file: PregateFile, lane: string, input: ClearInput): ClearDecision {
export function clearDecision(file: PregateFile, lane: string, input: ClearInput, repoRoot: string): ClearDecision {
const list = file.requires_remote ?? [];
const target = list.find(r => r.lane === lane);
if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` };
@@ -191,9 +201,23 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
try { text = fs.readFileSync(input.receiptPath, 'utf8'); }
catch (e: any) { return { refused: 'PREGATE_RECEIPT_INVALID', message: `cannot read ${input.receiptPath}: ${e.message}` }; }
const report = parseRemoteReport(text);
const problem = report ? remoteReportProblem(report, file.tree) : `${input.receiptPath} has no PREGATE_REMOTE: line`;
if (!report) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${input.receiptPath} has no PREGATE_REMOTE: line; ${lane} stays uncleared` };
const own = remoteReportProblem(report, report.tree);
if (own) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${own}; ${lane} stays uncleared` };
let substitution: PregateReceiptSubstitution | undefined;
if (report.tree !== file.tree) {
// The stamp rule is the ship's own base branch's policy (origin/HEAD, as regate reads it), never a branch the artifact names.
const base = resolveBaseBranch(repoRoot);
const recorded = baseBranchOf(file.base);
if (recorded && recorded !== base) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${treeMismatch(report.tree, file.tree)}; pregate.json was recorded against origin/${recorded}, not this repository's base origin/${base}, so the stamp rule that would class the difference is not the base's; ${lane} stays uncleared`, fix: baseMismatchFix(repoRoot, base, recorded) };
const s = substituteStampOnlyTree(repoRoot, base, report.tree, file.tree);
if ('problem' in s) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${s.problem}; ${lane} stays uncleared`, fix: s.fix };
substitution = s.substitution;
}
const problem = remoteReportProblem(report, substitution ? report.tree : file.tree);
if (problem) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${problem}; ${lane} stays uncleared` };
return { receipt: `remote-gate tree=${file.tree.slice(0, 12)} tests=${report!.tests} receipt=${path.resolve(input.receiptPath)} sha256=${createHash('sha256').update(text).digest('hex').slice(0, 12)}`, attested: false };
const accepted = substitution ? ` accepted-for=${file.tree.slice(0, 12)} stamp-only=${substitution.files.length}` : '';
return { receipt: `remote-gate tree=${report.tree.slice(0, 12)} tests=${report.tests}${accepted} receipt=${path.resolve(input.receiptPath)} sha256=${createHash('sha256').update(text).digest('hex').slice(0, 12)}`, attested: false, substitution };
}
if (input.receiptPath) return { refused: 'usage', message: `--receipt applies only to ${REMOTE_GATE_LANE}; clear ${lane} with --run-url <url>${target.workflow === 'test_backend' ? '' : ' or --bundle-lane <label>'}` };
if (target.workflow === 'test_backend') {
@@ -205,12 +229,13 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
return { receipt, attested: false };
}
/** Record the receipt that clears one obligation (a run URL or an evidence-bundle lane label). */
export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string): PregateFile {
/** Record the receipt that clears one obligation (a run URL, an evidence-bundle lane label, or a remote gate report, with the stamp-only substitution when one was accepted). */
export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution): PregateFile {
const list = file.requires_remote ?? [];
const target = list.find(r => r.lane === lane);
if (!target) throw new Error(`no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}`);
target.receipt = receipt; target.cleared_by = by;
if (substitution) target.substitution = substitution; else delete target.substitution;
file.verdict = verdictOf(file);
return file;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* pregate remote receipt — a remote_gate receipt from a predecessor tree.
*
* The `pregate.json/remote_gate` obligation clears on the gate's own
* `PREGATE_REMOTE: tree=… tests=… failed=…` line, and that tree had to be the
* tree pregate.json recorded. A repo whose full gate ran ahead of the ship
* ("gate ahead of your turn": the gate tested commit G, then /ship's restamp
* moved the version files) could never clear the obligation with that run:
* the stamp changed the tree, so every PR paid a second full gate on the
* stamped head (gbrain #7621, #7674, #7681: 17–37 minutes, 64–128 vCPU each).
*
* `substituteStampOnlyTree` is the one accepted substitution. The gated tree
* and the head tree are diffed path by path, and every differing file is
* classified by the tree receipt's own rule (lib/tree-receipt.ts
* `classifyFile`: the repo's `stamp_files` allowlist and `stamp_patterns`
* templates from `.gstack/ship-policy.json` on the base branch, beside the
* built-in version-file, manifest-version, lockfile-root-version and
* changelog-heading forms). The receipt is accepted for the head only when
* every differing file is a stamp; a path outside the rule, or a stamp path
* whose hunk is not the exact old → new rewrite, refuses it with the path
* named. There is no second stamp list here: a verdict this module reaches is
* the verdict `gstack-tree-receipt` reaches on the same pair, including its
* gate rule: a repo whose `stamp_sensitive_gates` names the test lane (`tests`,
* as gstack's own policy does, because its tests read VERSION and the digest)
* is refused, since the tree receipt never marks such a lane reusable.
*
* Both trees must be objects in this repository (the gated tree is the
* gated commit's tree once that commit is fetched; the head tree is the
* working-tree fingerprint `bin/gstack-wtree` wrote). A tree that is not
* here cannot be diffed and is refused, never trusted.
*/
import { spawnSync } from 'node:child_process';
import type { PregateReceiptSubstitution } from '../headless-artifacts';
import { parseUnifiedDiff, versionAtRef } from '../restamp';
import { isUnavailable, loadPolicyFromBase, SHIP_POLICY_PATH } from '../ship-policy';
import { classifyFile } from '../tree-receipt';
import { treeMismatch } from './checks/lanes';
/** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head (the pregate.json shape, lib/headless-artifacts.ts). */
export type StampOnlySubstitution = PregateReceiptSubstitution;
export type SubstitutionResult = { substitution: StampOnlySubstitution } | { problem: string; fix: string };
/** A git object id as the remote gate and bin/gstack-wtree print it: nothing symbolic (`HEAD^{tree}`) or option-shaped reaches git. */
const OBJECT_ID = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
/** The `stamp_sensitive_gates` labels that name the test tier the remote gate runs: gstack-evidence's `tests` lane, or the obligation itself. */
const STAMP_SENSITIVE_TEST_LANES = new Set(['tests', 'remote_gate']);
function git(cwd: string, args: string[]): string | null {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
return r.status === 0 ? (r.stdout ?? '').replace(/\n$/, '') : null;
}
/** The branch a pregate.json `base` field (`origin/<branch>@<sha>`) names; null when the field is absent or unreadable. */
export function baseBranchOf(base: string | undefined): string | null {
const m = /^(?:origin\/)?(.+?)@[0-9a-f]+$/.exec(base ?? '');
return m ? m[1]! : null;
}
/**
* The fix for a pregate.json recorded against `recorded` when the ship's base
* is `base`. `resolveBaseBranch` falls back to `main` when origin/HEAD is unset,
* so a repo whose base is another branch is told how to set it rather than
* sent to rerun against a branch it may not have.
*/
export function baseMismatchFix(repoRoot: string, base: string, recorded: string): string {
const fix = `rerun gstack-pregate --base ${base}, then clear again`;
if (git(repoRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) !== null) return fix;
return `${fix}; or, since origin/HEAD is unset here, git remote set-head origin ${recorded} so clear reads that base's policy`;
}
/**
* Whether `gatedTree` differs from `headTree` only as a stamp does, under the
* base branch's ship policy. Returns the substitution to record, or the
* problem (opening with the same tree-mismatch clause `remoteReportProblem`
* prints) with the fix.
*/
export function substituteStampOnlyTree(repoRoot: string, base: string, gatedTree: string, headTree: string): SubstitutionResult {
const short = (t: string) => t.slice(0, 12);
const mismatch = treeMismatch(gatedTree, headTree);
const rerun = `rerun the remote gate on tree ${short(headTree)} and pass its output with --receipt, or run gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`;
for (const [label, tree] of [['gated', gatedTree], ['head', headTree]] as const) {
if (!OBJECT_ID.test(tree)) return { problem: `${mismatch}; the ${label} tree ${JSON.stringify(tree.slice(0, 40))} is not a git object id, so it cannot be compared`, fix: rerun };
if (git(repoRoot, ['cat-file', '-t', '--end-of-options', tree]) !== 'tree') {
return { problem: `${mismatch}; the ${label} tree ${short(tree)} is not an object in this repository, so the two trees cannot be compared`, fix: label === 'gated' ? `fetch the commit the remote gate tested (git fetch origin <its sha>) so its tree is here, then clear again; or ${rerun}` : rerun };
}
}
const loaded = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(loaded)) return { problem: `${mismatch}; the ship policy on origin/${loaded.base} could not be read (${loaded.detail}), so the stamp rule is unknown`, fix: `git fetch origin ${loaded.base}, then clear again; or ${rerun}` };
if (loaded.source.kind === 'base' && !loaded.present) return { problem: `${mismatch}; ${SHIP_POLICY_PATH} exists on ${loaded.source.label} but could not be read, so the stamp rule is unknown`, fix: `git fetch origin ${base}, then clear again; or ${rerun}` };
if (loaded.errors.length) return { problem: `${mismatch}; ${SHIP_POLICY_PATH} on ${loaded.source.label} is invalid (${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}), so the stamp rule is unknown`, fix: `fix the policy on the base branch (gstack-ship-policy validate), then clear again; or ${rerun}` };
// The tree receipt never marks a stamp-sensitive lane reusable: a repo whose tests read VERSION or generated bytes says so here, and its test tier is what the remote gate ran.
const sensitive = loaded.policy.stamp_sensitive_gates.filter(g => STAMP_SENSITIVE_TEST_LANES.has(g));
if (sensitive.length) return { problem: `${mismatch}, and ${loaded.source.label} lists the test lane as stamp-sensitive (stamp_sensitive_gates: ${sensitive.join(', ')}), so a run on another tree never covers this one`, fix: rerun };
const gv = versionAtRef(repoRoot, gatedTree);
const cv = versionAtRef(repoRoot, headTree);
const versionRel = 'rel' in cv ? cv.rel : 'rel' in gv ? gv.rel : 'VERSION';
const oldV = 'version' in gv ? gv.version : null;
const newV = 'version' in cv ? cv.version : null;
// The patch is read with a fixed presentation (no textconv, no rename pairing, submodules included, a/ b/ prefixes) and
// reconciled against the plumbing inventory of the pair, so a diff driver or diff.* setting can neither hide a path nor rename it.
const diff = git(repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--ignore-submodules=none', '--src-prefix=a/', '--dst-prefix=b/', '--end-of-options', gatedTree, headTree]);
if (diff === null) return { problem: `${mismatch}; git diff ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun };
const inventory = git(repoRoot, ['diff-tree', '-r', '-z', '--name-only', '--no-renames', '--ignore-submodules=none', '--end-of-options', gatedTree, headTree]);
if (inventory === null) return { problem: `${mismatch}; git diff-tree ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun };
const files = parseUnifiedDiff(diff).map(f => classifyFile(repoRoot, f, gatedTree, headTree, loaded.policy, versionRel, oldV, newV));
if (!files.length) return { problem: `${mismatch}; the trees differ but git diff produced no file entry to classify`, fix: rerun };
const expected = new Set(inventory.split('\0').filter(Boolean));
const seen = new Set(files.map(f => f.path));
const unaccounted = [...expected].filter(p => !seen.has(p)).concat([...seen].filter(p => !expected.has(p)));
if (unaccounted.length) return { problem: `${mismatch}; the patch between the trees does not account for every differing path (${unaccounted.join(', ')}), so the difference cannot be classified`, fix: rerun };
const changed = files.filter(f => f.verdict === 'changed');
if (changed.length) {
const named = changed.map(c => `${c.path} (${c.rule}${c.residual ? `: ${c.residual}` : ''}${c.line ? `, line ${c.line}` : ''})`).join('; ');
return { problem: `${mismatch}, and the trees differ beyond the stamp rule of ${loaded.source.label}: ${named}`, fix: `${rerun}; a receipt from a predecessor tree is accepted only when every differing path is a stamp (stamp_files, stamp_patterns and the built-in version, manifest, lockfile and changelog forms; see docs/pregate.md#remote-gate-stamp-only)` };
}
return { substitution: { gated_tree: gatedTree, accepted_for: headTree, policy: loaded.source.label, files: files.map(f => ({ path: f.path, rule: f.rule })) } };
}
/** One line naming the substitution, for `clear`'s output and `verify`'s ok line. */
export function describeSubstitution(s: StampOnlySubstitution): string {
return `gated tree ${s.gated_tree.slice(0, 12)} accepted for tree ${s.accepted_for.slice(0, 12)} under ${s.policy}; stamp-only: ${s.files.map(f => `${f.path} (${f.rule})`).join(', ')}`;
}
+1 -1
View File
@@ -68,7 +68,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string
return normalizeLockfile(name, a) === normalizeLockfile(name, b);
}
function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict {
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict {
const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line });
const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' });
if (f.binary) return changed('binary', 'binary content differs');
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.256",
"version": "1.91.257",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+292
View File
@@ -0,0 +1,292 @@
/**
* GSTA-117 (gbrain's gate-ahead ask): `gstack-pregate clear --remote
* pregate.json/remote_gate --receipt <file>` accepts a remote gate report
* whose tree differs from pregate.json's tree only in the repo's declared
* stamp paths, classified by the tree receipt's own rule
* (lib/tree-receipt.ts classifyFile under .gstack/ship-policy.json on the
* base branch), and records the substitution: the gated tree, the tree it
* was accepted for, the policy, and every differing path with its rule. A
* non-stamp path, a stamp path with a non-stamp hunk, a gated tree that is
* not in the object store and an unreadable receipt file still refuse with
* PREGATE_RECEIPT_INVALID and a fix naming the offending path.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, runPregate, verifyPregate } from '../lib/pregate/index';
import { baseBranchOf, substituteStampOnlyTree } from '../lib/pregate/remote-receipt';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, readFile, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const GATE = 'node scripts/remote-gate.js';
const LANE = 'pregate.json/remote_gate';
const PREGATE_FILE = '.gstack/tmp/pregate.json';
const POLICY = {
bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md',
stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', 'plugin.json', 'README.md'],
stamp_patterns: ['"version": "{version}"', '<!-- fixture-stamp: {version} -->'],
};
const PLUGIN = (v: string, description = 'Fixture plugin') => `{\n "name": "fx",\n "version": "${v}",\n "description": "${description}"\n}\n`;
const STAMP_FILES = [
{ path: 'CHANGELOG.md', rule: 'changelog-heading' }, { path: 'README.md', rule: 'stamp_files' }, { path: 'VERSION', rule: 'version-file' },
{ path: 'package.json', rule: 'manifest-version' }, { path: 'plugin.json', rule: 'stamp_files' },
];
const ANCHOR = '(PREGATE_RECEIPT_INVALID; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pregate-receipt-invalid)';
/** A gated repo with a stamp rule on main: the feature branch's real change is committed as the gated head G. */
function gatedRepo(): { repo: FixtureRepo; gated: string; gatedTree: string } {
const repo = makeFixtureRepo({
pregate: { ...BASE_PREGATE, remote_gate: GATE, dependencies: { ...BASE_PREGATE.dependencies, 'CHANGELOG.md': [], 'plugin.json': [], VERSION: [] } },
baseFiles: { '.gstack/ship-policy.json': JSON.stringify(POLICY, null, 2) + '\n', VERSION: '1.0.0\n', 'plugin.json': PLUGIN('1.0.0'), 'README.md': '# fixture\n<!-- fixture-stamp: 1.0.0 -->\n', 'CHANGELOG.md': '# Changelog\n\n## [Unreleased]\n\n- add\n' },
});
repos.push(repo);
repo.git('checkout', '-q', 'main');
commit(repo, { 'scripts/remote-gate.js': "console.log('PREGATE_REMOTE: tree=' + process.env.PREGATE_TREE + ' tests=3 failed=0');\n" }, 'remote gate');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--ff-only', 'main');
const gated = commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'the real change (gated ahead)');
return { repo, gated, gatedTree: repo.git('rev-parse', `${gated}^{tree}`) };
}
/** /ship's restamp on top of the gated head: every stamp file moves 1.0.0 -> 1.0.1 and nothing else. */
function stamp(repo: FixtureRepo): string {
const pkg = JSON.parse(readFile(repo, 'package.json'));
pkg.version = '1.0.1';
return commit(repo, {
VERSION: '1.0.1\n', 'package.json': JSON.stringify(pkg, null, 2) + '\n', 'plugin.json': PLUGIN('1.0.1'),
'README.md': '# fixture\n<!-- fixture-stamp: 1.0.1 -->\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.1] - 2026-10-26\n\n- add\n',
}, 'v1.0.1 stamp');
}
/** pregate.json for the current head on a 4-core machine: the test tier is the remote_gate obligation, uncleared. */
async function pregateAt(repo: FixtureRepo) {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]);
fs.mkdirSync(path.join(repo.work, '.gstack/tmp'), { recursive: true });
fs.writeFileSync(path.join(repo.work, PREGATE_FILE), JSON.stringify(file));
return file;
}
const receiptFile = (repo: FixtureRepo, tree: string, tests = 3, failed = 0) => { const f = path.join(repo.root, `gate-${Math.random().toString(36).slice(2)}.log`); fs.writeFileSync(f, `gate log\nPREGATE_REMOTE: tree=${tree} tests=${tests} failed=${failed}\n`); return f; };
const clear = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, ...args]);
const pregateJson = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, PREGATE_FILE), 'utf8'));
const policyLabel = (repo: FixtureRepo) => `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}:.gstack/ship-policy.json`;
const describeStamps = STAMP_FILES.map(f => `${f.path} (${f.rule})`).join(', ');
// Like test/pregate-remote.test.ts, this expects the Linux-host lanes verdict: on windows-latest the fixture's Linux lane is a
// requires-remote obligation too, so the single-obligation assertions and `verify` ok do not hold there. The curation list in
// scripts/lib/windows-curation.ts is the usual home for that; this file skips itself so the shared list is not edited here.
describe.skipIf(process.platform === 'win32')('clear --receipt accepts a stamp-only predecessor tree (gate ahead of the stamp)', () => {
test('a receipt for the gated tree clears the stamped head; the substitution names the trees, the policy and every file with its rule, and verify passes', async () => {
const { repo, gated, gatedTree } = gatedRepo();
const head = stamp(repo);
const file = await pregateAt(repo);
expect(file.tree).toBe(repo.git('rev-parse', `${head}^{tree}`));
expect(file.tree).not.toBe(gatedTree);
const r = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
expect(r.err).toBe('');
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain(`PREGATE_RECEIPT_SUBSTITUTED: gated tree ${gatedTree.slice(0, 12)} accepted for tree ${file.tree.slice(0, 12)} under ${policyLabel(repo)}; stamp-only: ${describeStamps}`);
expect(r.out).toMatch(new RegExp(`^PREGATE_CLEARED: pregate\\.json/remote_gate receipt=remote-gate tree=${gatedTree.slice(0, 12)} tests=3 accepted-for=${file.tree.slice(0, 12)} stamp-only=5 receipt=\\S+ sha256=[0-9a-f]{12} verdict=requires-remote cleared$`, 'm'));
const stored = pregateJson(repo).requires_remote[0];
expect(stored.receipt).toMatch(/^remote-gate tree=/);
expect(stored.cleared_by).toMatch(/^gstack-pregate clear /);
expect(stored.substitution).toEqual({ gated_tree: gatedTree, accepted_for: file.tree, policy: policyLabel(repo), files: STAMP_FILES });
// The same rule the tree receipt applies to the same pair: identical files and verdicts.
const receipt = JSON.parse(runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--current', head, '--base', 'main', '--json']).out);
expect(receipt.tree).toBe('same-modulo-stamps');
expect(receipt.files.map((f: any) => ({ path: f.path, rule: f.rule }))).toEqual(STAMP_FILES);
const verify = runBin(repo, 'gstack-pregate', ['verify']);
expect(verify.status).toBe(EXIT.ok);
expect(verify.out).toContain(`PREGATE_VERIFY: ok pregate requires-remote cleared is current for tree ${file.tree.slice(0, 12)}; pregate.json/remote_gate: gated tree ${gatedTree.slice(0, 12)} accepted for tree ${file.tree.slice(0, 12)} under ${policyLabel(repo)}; stamp-only: ${describeStamps}`);
}, 120_000);
test('a receipt for the very tree pregate.json recorded still clears with no substitution', async () => {
const { repo } = gatedRepo();
stamp(repo);
const file = await pregateAt(repo);
const r = clear(repo, ['--receipt', receiptFile(repo, file.tree)]);
expect(r.status).toBe(EXIT.ok);
expect(r.out).not.toContain('PREGATE_RECEIPT_SUBSTITUTED');
expect(r.out).not.toContain('accepted-for=');
expect(pregateJson(repo).requires_remote[0].substitution).toBeUndefined();
}, 120_000);
test('a path outside the stamp rule refuses the receipt, names the path, and leaves the obligation uncleared', async () => {
const { repo, gatedTree } = gatedRepo();
stamp(repo);
commit(repo, { 'lib/retry.js': 'exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n' }, 'a real edit after the gate');
const file = await pregateAt(repo);
const r = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain(`receipt refused — the remote gate tested tree ${gatedTree.slice(0, 12)}, not ${file.tree.slice(0, 12)}, and the trees differ beyond the stamp rule of ${policyLabel(repo)}: lib/retry.js (stamp_files: not in the repo's stamp_files allowlist, line 1); pregate.json/remote_gate stays uncleared; fix: rerun the remote gate on tree ${file.tree.slice(0, 12)} and pass its output with --receipt`);
expect(r.err).toContain('a receipt from a predecessor tree is accepted only when every differing path is a stamp');
expect(r.err).toContain(ANCHOR);
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
expect(pregateJson(repo).requires_remote[0].substitution).toBeUndefined();
const verify = runBin(repo, 'gstack-pregate', ['verify']);
expect(verify.status).toBe(EXIT.fail);
expect(verify.err).toContain('PREGATE_REMOTE_UNCLEARED');
}, 120_000);
test('a stamp path whose hunk is not the stamp rewrite is a change: refused with the path, the rule and the line', async () => {
const { repo, gated, gatedTree } = gatedRepo();
stamp(repo);
commit(repo, { 'plugin.json': PLUGIN('1.0.1', 'Fixture Plugin') }, 'description edit inside a stamp file');
const file = await pregateAt(repo);
const r = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain(`the remote gate tested tree ${gatedTree.slice(0, 12)}, not ${file.tree.slice(0, 12)}, and the trees differ beyond the stamp rule`);
expect(r.err).toContain(': plugin.json (stamp_files: "\\"description\\": \\"Fixture plugin\\"" -> "\\"description\\": \\"Fixture Plugin\\"", line 4); pregate.json/remote_gate stays uncleared; fix: ');
expect(r.err).toContain(ANCHOR);
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
// The tree receipt agrees: the pair is `changed` on the same path.
const receipt = JSON.parse(runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--current', 'HEAD', '--base', 'main', '--json']).out);
expect(receipt.residual).toEqual(['plugin.json']);
}, 120_000);
test('a gated tree that is not in the object store, or an unreadable receipt file, refuses with the fix', async () => {
const { repo } = gatedRepo();
stamp(repo);
const file = await pregateAt(repo);
const unknown = clear(repo, ['--receipt', receiptFile(repo, 'deadbeef'.repeat(8))]);
expect(unknown.status).toBe(EXIT.fail);
expect(unknown.err).toContain(`the remote gate tested tree deadbeefdead, not ${file.tree.slice(0, 12)}; the gated tree deadbeefdead is not an object in this repository, so the two trees cannot be compared; pregate.json/remote_gate stays uncleared; fix: fetch the commit the remote gate tested (git fetch origin <its sha>) so its tree is here, then clear again`);
expect(unknown.err).toContain(ANCHOR);
const missing = clear(repo, ['--receipt', path.join(repo.root, 'no-such-receipt.log')]);
expect(missing.status).toBe(EXIT.fail);
expect(missing.err).toContain('receipt refused — cannot read ');
expect(missing.err).toContain(ANCHOR);
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
}, 120_000);
test('a predecessor receipt that reports zero tests or a failure is still refused on its own terms', async () => {
const { repo, gatedTree } = gatedRepo();
stamp(repo);
await pregateAt(repo);
const zero = clear(repo, ['--receipt', receiptFile(repo, gatedTree, 0)]);
expect(zero.status).toBe(EXIT.fail);
expect(zero.err).toContain('the remote gate reported 0 tests; pregate.json/remote_gate stays uncleared');
const failed = clear(repo, ['--receipt', receiptFile(repo, gatedTree, 3, 1)]);
expect(failed.status).toBe(EXIT.fail);
expect(failed.err).toContain('the remote gate reported 1 failed of 3');
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
}, 120_000);
test('verify refuses a recorded substitution that was accepted for another tree', async () => {
const { repo, gatedTree } = gatedRepo();
stamp(repo);
const file = await pregateAt(repo);
expect(clear(repo, ['--receipt', receiptFile(repo, gatedTree)]).status).toBe(EXIT.ok);
const edited = pregateJson(repo);
edited.requires_remote[0].substitution.accepted_for = 'cafe'.repeat(10);
fs.writeFileSync(path.join(repo.work, PREGATE_FILE), JSON.stringify(edited));
const v = verifyPregate(repo.work, edited, { tree: file.tree });
expect(v.ok).toBe(false);
expect(v.code).toBe('PREGATE_REMOTE_UNCLEARED');
expect(v.message).toContain(`pregate.json/remote_gate (accepted for cafecafecafe from gated ${gatedTree.slice(0, 12)}, pregate.json is ${file.tree.slice(0, 12)}); clear again`);
const r = runBin(repo, 'gstack-pregate', ['verify']);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain('PREGATE_REMOTE_UNCLEARED');
// A substitution whose gated tree is not the one the receipt string names is refused too: the record and the receipt must agree.
const swapped = pregateJson(repo);
swapped.requires_remote[0].substitution.gated_tree = 'beef'.repeat(10);
const w = verifyPregate(repo.work, swapped, { tree: file.tree });
expect(w.ok).toBe(false);
expect(w.code).toBe('PREGATE_REMOTE_UNCLEARED');
expect(w.message).toContain('from gated beefbeefbeef');
}, 120_000);
test('a same-tree receipt after a substituted clear drops the recorded substitution', async () => {
const { repo, gatedTree } = gatedRepo();
stamp(repo);
const file = await pregateAt(repo);
expect(clear(repo, ['--receipt', receiptFile(repo, gatedTree)]).status).toBe(EXIT.ok);
expect(pregateJson(repo).requires_remote[0].substitution).toBeDefined();
expect(clear(repo, ['--receipt', receiptFile(repo, file.tree)]).status).toBe(EXIT.ok);
expect(pregateJson(repo).requires_remote[0].substitution).toBeUndefined();
const verify = runBin(repo, 'gstack-pregate', ['verify']);
expect(verify.status).toBe(EXIT.ok);
expect(verify.out).not.toContain('stamp-only');
}, 120_000);
test('a symbolic tree, a head tree that is not here, a mode-only difference and a stamp file at the wrong version are refused', async () => {
const { repo, gatedTree } = gatedRepo();
stamp(repo);
const file = await pregateAt(repo);
const symbolic = clear(repo, ['--receipt', receiptFile(repo, 'HEAD^{tree}')]);
expect(symbolic.status).toBe(EXIT.fail);
expect(symbolic.err).toContain(`the remote gate tested tree HEAD^{tree}, not ${file.tree.slice(0, 12)}; the gated tree "HEAD^{tree}" is not a git object id, so it cannot be compared; pregate.json/remote_gate stays uncleared`);
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
const headGone = substituteStampOnlyTree(repo.work, 'main', gatedTree, 'f00d'.repeat(10));
expect('problem' in headGone && headGone.problem).toContain('the head tree f00df00df00d is not an object in this repository');
const upper = substituteStampOnlyTree(repo.work, 'main', gatedTree.toUpperCase(), file.tree);
expect('problem' in upper && upper.problem).toContain('is not a git object id');
// A mode-only change leaves git diff --unified=0 with a file header and no hunk: classifyFile's mode rule, a change.
fs.chmodSync(path.join(repo.work, 'lib/util.js'), 0o755);
const modeHead = commit(repo, {}, 'mode only');
const mode = substituteStampOnlyTree(repo.work, 'main', gatedTree, repo.git('rev-parse', `${modeHead}^{tree}`));
expect('problem' in mode && mode.problem).toContain('lib/util.js (mode: file mode changed');
fs.chmodSync(path.join(repo.work, 'lib/util.js'), 0o644);
commit(repo, {}, 'mode back');
// A stamp file rewritten to a version other than the head's is not the old -> new rewrite.
const wrongHead = commit(repo, { 'README.md': '# fixture\n<!-- fixture-stamp: 1.0.7 -->\n' }, 'README at the wrong version');
const wrong = substituteStampOnlyTree(repo.work, 'main', gatedTree, repo.git('rev-parse', `${wrongHead}^{tree}`));
expect('problem' in wrong && wrong.problem).toContain('README.md (stamp_files: ');
expect('problem' in wrong && wrong.problem).toContain('1.0.7');
}, 120_000);
test("the policy branch is origin/HEAD's; a pregate.json base naming another branch, a stamp-sensitive test lane and an invalid base policy refuse", async () => {
const { repo, gatedTree } = gatedRepo();
stamp(repo);
const file = await pregateAt(repo);
expect(baseBranchOf(file.base)).toBe('main');
expect(baseBranchOf('origin/release/2026@0123456789ab')).toBe('release/2026');
expect(baseBranchOf(undefined)).toBeNull();
expect(baseBranchOf('main')).toBeNull();
const noBase = substituteStampOnlyTree(repo.work, 'no-such-branch', gatedTree, file.tree);
expect('problem' in noBase && noBase.problem).toContain('the ship policy on origin/no-such-branch could not be read');
// No base field: clearDecision uses origin/HEAD's branch (main here) and still accepts; a base field naming another branch refuses.
const { base: _b, ...noField } = file;
const d = clearDecision(noField as typeof file, LANE, { receiptPath: receiptFile(repo, gatedTree) }, repo.work);
expect('substitution' in d && d.substitution?.files).toEqual(STAMP_FILES);
const other = clearDecision({ ...file, base: 'origin/release@0123456789ab' }, LANE, { receiptPath: receiptFile(repo, gatedTree) }, repo.work);
expect('refused' in other && other.message).toContain(`pregate.json was recorded against origin/release, not this repository's base origin/main`);
expect('refused' in other && other.fix).toBe('rerun gstack-pregate --base main, then clear again');
// Without origin/HEAD the base falls back to main, so the fix also says how to set the recorded base instead.
repo.git('symbolic-ref', '--delete', 'refs/remotes/origin/HEAD');
const unset = clearDecision({ ...file, base: 'origin/release@0123456789ab' }, LANE, { receiptPath: receiptFile(repo, gatedTree) }, repo.work);
expect('refused' in unset && unset.fix).toBe('rerun gstack-pregate --base main, then clear again; or, since origin/HEAD is unset here, git remote set-head origin release so clear reads that base\'s policy');
repo.git('remote', 'set-head', 'origin', 'main');
// A policy that lists the test lane as stamp-sensitive (gstack's own does) refuses the substitution: that lane never reuses across a stamp.
repo.git('checkout', '-q', 'main');
commit(repo, { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, stamp_sensitive_gates: ['tests'] }, null, 2) + '\n' }, 'tests read the stamps');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('fetch', '-q', 'origin');
const sensitive = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
expect(sensitive.status).toBe(EXIT.fail);
expect(sensitive.err).toContain(`not ${file.tree.slice(0, 12)}, and origin/main@`);
expect(sensitive.err).toContain(':.gstack/ship-policy.json lists the test lane as stamp-sensitive (stamp_sensitive_gates: tests), so a run on another tree never covers this one; pregate.json/remote_gate stays uncleared');
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
// An invalid policy on the base refuses rather than classifying under the defaults.
repo.git('checkout', '-q', 'main');
commit(repo, { '.gstack/ship-policy.json': '{ "stamp_files": "VERSION" }\n' }, 'break the policy');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('fetch', '-q', 'origin');
const broken = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
expect(broken.status).toBe(EXIT.fail);
expect(broken.err).toContain('.gstack/ship-policy.json on origin/main@');
expect(broken.err).toContain('is invalid (');
expect(broken.err).toContain('fix: fix the policy on the base branch (gstack-ship-policy validate)');
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
}, 120_000);
});