From 208d0c8fcc04e3024c880bd04ea9abd3eaf4de0c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 08:45:58 -0800 Subject: [PATCH] v1.94.27.0 feat(pregate): housekeeping guards before every push (GST-1138) (#3923) * feat(pregate): pre-push housekeeping guards (--stage guards, push runs them, gstack's guard list) (GST-1138) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: no version string the secrets scan reads as an IP address Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-release): push guards certify only HEAD's tree and skip a missing base; classify pregate-guards.json; cover finish-record reuse Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-release): push guards skip only when origin has no base branch; a refused run or unreachable origin stops Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-release): skip push guards only when origin holds no other branch; HEAD must not move during the run; a dirty-tree stop names failing guards 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> --- .gstack/pregate.json | 9 +++ CHANGELOG.md | 11 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-pregate | 16 ++--- docs/pregate.md | 39 +++++++++++- lib/pregate/index.ts | 11 +++- lib/ship-release.ts | 39 +++++++++++- package.json | 2 +- ship/DRIVER.md | 2 +- ship/DRIVER.md.tmpl | 2 +- test/gstack-clean-producers.test.ts | 1 + test/pregate-guards.test.ts | 56 +++++++++++++++++ test/ship-release.test.ts | 96 +++++++++++++++++++++++++++++ 14 files changed, 271 insertions(+), 17 deletions(-) diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 1dded7b5f..8b61aafb9 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -31,5 +31,14 @@ "bun run test": { "profiles": ["ci-reuse/free-tests"], "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'" } }, "warn": [], + "guards": [ + { "id": "typecheck", "when": ["**/*.ts", "tsconfig.json", "package.json", "bun.lock"], "command": "bun run typecheck", "fix": "fix the type errors bun run typecheck prints" }, + { "id": "skill-size", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/skill-md-size-guard.test.ts", "test/skill-size-budget.test.ts", "test/catalog-budget.test.ts", "test/fixtures/skill-md-size-allowlist.json", "test/fixtures/parity-baseline-*.json", "test/helpers/skill-census.ts", "test/helpers/skill-startup.ts"], "command": "bun test test/skill-md-size-guard.test.ts test/skill-size-budget.test.ts test/catalog-budget.test.ts", "fix": "shorten the skill text the failure names; move a pin only when the growth is wanted, with the reason in the PR body" }, + { "id": "parity-suite", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/parity-suite.test.ts", "test/fixtures/parity-baseline-*.json", "test/helpers/parity-harness.ts"], "command": "bun test test/parity-suite.test.ts", "fix": "restore the missing phrase or heading, or shorten the skill back under its size ratio" }, + { "id": "context-budget", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/context-budget-ratchet.test.ts", "test/fixtures/context-budget*.json"], "command": "bun test test/context-budget-ratchet.test.ts", "fix": "shorten the skill under its token ceiling, or lower the ceiling when the skill shrank" }, + { "id": "module-size", "when": ["lib/**/*.ts", "bin/**", "scripts/**/*.ts", "browse/src/**/*.ts", "test/helpers/**/*.ts", "test/module-size-ratchet.test.ts", "test/fixtures/module-size-ratchet.json"], "command": "bun test test/module-size-ratchet.test.ts", "fix": "split the module or function the failure names; it may not grow past its recorded size" }, + { "id": "goldens", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "ship/**", "scripts/gen-ship-goldens.ts", "test/host-config.test.ts", "test/fixtures/golden/**"], "command": "bun test test/host-config.test.ts", "fix": "when the render change is intended, run bun scripts/gen-ship-goldens.ts and commit the updated goldens" }, + { "id": "doc-caps", "when": ["CLAUDE.md", "test/claude-md-size.test.ts"], "command": "bun test test/claude-md-size.test.ts", "fix": "move contributor-only detail from CLAUDE.md into docs/ and leave a one-line pointer" } + ], "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index ed15030c9..49a8b1784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.94.27.0] - 2026-11-03 + +- Housekeeping checks now run before every push instead of after a CI round (GST-1138, ship-90-90 plan). `gstack-pregate --stage guards --allow-repo-commands` runs only generated-file freshness (`regen`) and the repo's `guards`, each only for the files the branch touched, and writes `.gstack/tmp/pregate-guards.json` so it never replaces the full run's record. `gstack-ship-release push` runs it before every push, early or final, prints `GUARDS:`, and stops with the guard's fix when one fails, pushing nothing. A pass already recorded at HEAD's exact tree, by push itself or by `finish`'s preflight, is reused instead of rerun. See docs/pregate.md#prepush. +- gstack declares its own guards in `.gstack/pregate.json`: typecheck, the skill size guards, the parity suite, the context-budget ratchet, the module-size ratchet, the /ship goldens and the CLAUDE.md size cap. gbrain had 64 of 93 red heads from checks like these, and #3908 went red on the context-budget ratchet. Every gstack at or above this repo's version floor already reads the `guards` key, so the floor does not move. + +### Tests +- `test/pregate-guards.test.ts`: `--stage guards` runs only `regen` and `guards`, writes its own record and refuses `--only` and `--from-run`; gstack's guard list validates, names test files that exist and fires only on its own inputs. +- `test/ship-release.test.ts`: a failing guard stops the push with its fix and pushes nothing; a pass at the same tree is reused, and a new commit runs the guards again. + +On main, all five new tests fail. + ## [1.94.26.0] - 2026-11-03 - A lane whose command runs several `bun test --shard=i/N` processes now records the sum of the shards' counts, not the last shard's (GBEV-3, gbrain-evals#317: 3336 recorded against 6,095 run across 2 shards). The sum needs shards 1/N to N/N, in order, at the end of the log; any other set records the last summary as before, so a command that runs a suite twice, or reruns one shard, still reports its last run. The shard lines and count lines that scroll out of the 64 KiB output tail are kept for this, so a long shard run still sums (a real 2-shard, 2000-test bun run recorded 1000 before and 2000 after). diff --git a/VERSION b/VERSION index c101bdb3d..ef0f04c4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.26.0 +1.94.27.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d55a7ffa6..3e8a4f3f9 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.26.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.27.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 c14279033..3fdf66be3 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -2,7 +2,7 @@ /** * gstack-pregate — cheap checks before anything paid or remote (plan C8). * - * gstack-pregate [run] [--stage preflight|tests|all] [--only ] [--base ] + * gstack-pregate [run] [--stage preflight|tests|guards|all] [--only ] [--base ] * [--allow-repo-commands] [--policy-from base|head] [--out ] * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--from-run ] [--no-ci-carry] [--explain] [--json] * gstack-pregate verify [--out ] [--json] publication check: current tree, no fail/incomplete, obligations cleared @@ -33,7 +33,7 @@ import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/p import { describeDraft, draftConfig } from '../lib/pregate/init-draft'; import { buildRunCheck } from '../lib/pregate/run-check'; import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context'; -import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index'; +import { CHECKS, CHECK_IDS, PREGATE_GUARDS_OUT_REL, PREGATE_OUT_REL, PREGATE_TIER2, PREPUSH_CHECK_IDS, 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'; @@ -75,7 +75,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do a fresh tree receipt, each lane's reuse verdict, migration collisions (base branch and open PRs), then REGATE: needed|not-needed (docs/pregate.md#regate); \`gstack-pregate --against \` is the same verb; exit 1 = needed - --stage preflight|tests|all default all + --stage preflight|tests|guards|all default all; guards is the pre-push housekeeping run (docs/pregate.md#prepush): + only ${PREPUSH_CHECK_IDS.join(' and ')}, each for the files the diff touched, written to ${PREGATE_GUARDS_OUT_REL} --only restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')}) --base a branch on origin, not a commit (default: origin/HEAD's branch unless it is the checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts) @@ -151,10 +152,11 @@ function repoRoot(): string | null { function outPath(root: string, rest: string[]): string { const o = flag(rest, '--out'); return o ? path.resolve(o) : path.join(root, PREGATE_OUT_REL); } async function cmdRun(root: string, rest: string[], json: boolean): Promise { - const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'all'; - if (!['preflight', 'tests', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests or all\n'); return EXIT.usage; } + const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'guards' | 'all'; + if (!['preflight', 'tests', 'guards', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests, guards or all\n'); return EXIT.usage; } + if (stage === 'guards' && (flag(rest, '--only') !== undefined || rest.includes('--remote'))) { process.stderr.write(`gstack-pregate: --stage guards runs only ${PREPUSH_CHECK_IDS.join(' and ')}; drop --only and --remote\n`); return EXIT.usage; } const fromRun = flag(rest, '--from-run'); - if (fromRun !== undefined && stage === 'preflight') { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; } + if (fromRun !== undefined && (stage === 'preflight' || stage === 'guards')) { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; } const only = flag(rest, '--only')?.split(',').map(s => s.trim()).filter(Boolean); const unknown = (only ?? []).filter(id => !CHECK_IDS.includes(id)); if (unknown.length) { process.stderr.write(`gstack-pregate: unknown check(s) ${unknown.join(', ')}${unknown.some(u => (PREGATE_TIER2 as readonly string[]).includes(u)) ? ' (tier 2, not in this gstack)' : ''}; known: ${CHECK_IDS.join(', ')}\n`); return EXIT.usage; } @@ -197,7 +199,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise] [--allow-repo-commands] [--policy-from base|head] +gstack-pregate [--stage preflight|tests|guards|all] [--only ] [--allow-repo-commands] [--policy-from base|head] [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--from-run ] [--no-ci-carry] [--explain] [--json] gstack-pregate verify | clear --remote --run-url | lanes | validate | init ``` @@ -1329,6 +1329,43 @@ guard is a *command* with its own fix line. Older gstack refuses unknown keys that reads them ships. The worked gbrain configuration is in [gbrain worked configs](#gbrain-worked-configs). + +### Before every push: `--stage guards` + +Housekeeping checks cause a large share of red CI runs: a stale render or +golden, a size ratchet, a token budget, a type error. gbrain had 64 of 93 red +heads from these (FOUN-64), and gstack's #3908 went red on the context-budget +ratchet. Each one is quick to run locally, so run them before the push instead +of waiting a CI round to find out: + +``` +gstack-pregate --stage guards --allow-repo-commands +``` + +`--stage guards` runs only two checks: `regen` (generated-file freshness) and +`guards`. Each runs only for the files the branch touched, so a docs-only +change runs nothing. It writes `.gstack/tmp/pregate-guards.json`, so it never +replaces the full run's `.gstack/tmp/pregate.json`. It refuses `--only`, +`--remote` and `--from-run`. + +`gstack-ship-release push` runs it before every push, early or final, and +prints `GUARDS: ()`. A failing or unfinished guard stops the +push with the guard's fix, and nothing is pushed. A pass already recorded at +HEAD's exact tree is reused instead of rerun: either push's own record or +`finish`'s preflight record. Both live under `.git`, where no commit can +place one. The guards must have checked the tree being pushed, so uncommitted +or untracked files that change what they saw stop the push until you commit or +stash them. A refused run (an unreadable policy, a base that is on origin but +not fetched, a mistyped `--base`) stops the push too; only a remote that has no branch except the one +being pushed skips the guards, as `GUARDS: not run`. Outside /ship, run the +command above yourself before `git push`. + +gstack declares its own list in `.gstack/pregate.json`: `typecheck`, +`skill-size`, `parity-suite`, `context-budget`, `module-size`, `goldens` and +`doc-caps`. Generated SKILL.md freshness is the `regen` check's job. Other +repos add their own `guards` the same way; the key needs no newer gstack than +the one that already reads `guards`. + ### A guard that already passed at this tree does not run again diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 73ab1ba2f..7ca5ae6a4 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -38,9 +38,14 @@ import { displayNamePattern, workflowCallers } from './workflows'; export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck]; export const CHECK_IDS = CHECKS.map(c => c.id); export const PREGATE_OUT_REL = '.gstack/tmp/pregate.json'; +/** Where `--stage guards` (the pre-push housekeeping guards) writes by default, so it never replaces the full run's file. */ +export const PREGATE_GUARDS_OUT_REL = '.gstack/tmp/pregate-guards.json'; +/** The checks `--stage guards` runs: generated-file freshness and the repo's guards, each only for the files the diff touched. */ +export const PREPUSH_CHECK_IDS = ['regen', 'guards'] as const; export const PREGATE_TIER2 = ['ratchets', 'lint', 'links', 'hermetic', 'patches', 'integrator'] as const; -export interface RunOptions { stage: Stage | 'all'; only?: string[]; run?: string } +/** `guards` selects PREPUSH_CHECK_IDS (preflight checks): the pre-push run, docs/pregate.md#prepush. */ +export interface RunOptions { stage: Stage | 'guards' | 'all'; only?: string[]; run?: string } /** Apply the repo's `warn` downgrades: never `secrets`. `lanes` applies its own (a touched file with no lane, after the selected tests ran), so nothing it fails is downgraded here. */ export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: CheckDef): CheckResult { @@ -83,7 +88,7 @@ const git = (cwd: string, args: string[]): string | null => { }; export async function runPregate(ctx: PregateContext, o: RunOptions): Promise { - const selected = CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id))); + const selected = o.stage === 'guards' ? CHECKS.filter(c => (PREPUSH_CHECK_IDS as readonly string[]).includes(c.id)) : CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id))); if (selected.some(c => c.id === 'lanes')) { const line = autoCarry(ctx); if (line) ctx.progress(line); } const checks: CheckResult[] = []; const clocks: Record = { preflight: 0, tests: 0 }; @@ -99,7 +104,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise { const { remote: _r, ...rest } = c; return rest; }), requires_remote: remote, }; diff --git a/lib/ship-release.ts b/lib/ship-release.ts index 8a4575684..c64a0e04c 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -22,6 +22,7 @@ import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs'; import { resolveStateRoot } from './state-root'; import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source'; import { waitForRun } from './ci-carry'; +import { PREPUSH_CHECK_IDS } from './pregate/index'; import { buildRunCheck } from './pregate/run-check'; import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common'; @@ -40,7 +41,8 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s A lane the base branch maps in .gstack/pregate.json ship_lanes_from is carried from a verified CI run at this exact tree, else deferred (EVIDENCE: DEFERRED): the pregate then runs its preflight stage here and its tests stage in accept push --branch [--gate-open ] [--gate-open-approved] [--early [--base ]] - Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u; + Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the pre-push housekeeping + guards (gstack-pregate --stage guards; a pass at HEAD's tree is reused), the idempotency check, git push -u; --early (the Phase 2 push before review, so CI runs beside it) also runs pregate's secrets scan on the commits first accept --base --lane