diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0916118..2e9fcb41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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= stamp-only=` 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 ; stamp-only: (), …`, `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= blocking_found= deferred=` 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. diff --git a/VERSION b/VERSION index 190a1fdf5..504b49b21 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.256.0 +1.91.257.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3a0fb277e..3cf321296 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 2246ec682..8613150ed 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -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 (the gate's PREGATE_REMOTE: line, checked against the tested tree); a + --receipt (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/ 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; } diff --git a/docs/pregate.md b/docs/pregate.md index fa8eca4bf..72d90a15a 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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 ` 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/ --run-url `, which has no machine-checkable receipt and is recorded as `attested:`; or - `gstack-pregate --stage tests --remote --spend-cap --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 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 `, 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/` ([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= + tests= accepted-for= stamp-only= receipt= + sha256=`, and pregate.json records the substitution beside it: + `substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`, + the policy label (`origin/@:.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 # 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 +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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d08373d82..5c30c0bee 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3122,9 +3122,32 @@ reports failures. The obligation stays uncleared. A run URL alone never clears this obligation; a `test_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 : (: , line +)`, 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 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/, not this repository's base origin/` (rerun `gstack-pregate --base +`, or `git remote set-head origin ` when `origin/HEAD` was unset), and +`.gstack/ship-policy.json on origin/@ 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 - --allow-repo-commands` run and validate it. + --allow-repo-commands` run and validate it. For a gate-ahead receipt: +fetch the gated commit (`git fetch origin `) 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. ### `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 --run-url ` (or -`--bundle-lane