mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
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>
This commit is contained in:
1 parent
7caca3322e
commit
208d0c8fcc
14 files changed
+271
-17
No files matched your search
@@ -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"] }
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
-7
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* gstack-pregate — cheap checks before anything paid or remote (plan C8).
|
||||
*
|
||||
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
|
||||
* gstack-pregate [run] [--stage preflight|tests|guards|all] [--only <check,..>] [--base <branch>]
|
||||
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
|
||||
* gstack-pregate verify [--out <pregate.json>] [--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 <head>\` 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 <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
|
||||
--base <branch> 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<number> {
|
||||
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<numb
|
||||
});
|
||||
}
|
||||
const file = await runPregate(ctx, { stage, only, run: flag(rest, '--run') });
|
||||
const out = outPath(root, rest);
|
||||
const out = stage === 'guards' && !flag(rest, '--out') ? path.join(root, PREGATE_GUARDS_OUT_REL) : outPath(root, rest);
|
||||
let written: string;
|
||||
try { written = writePregateFile(root, out, file); }
|
||||
catch (e: any) { process.stderr.write(resultLine('gstack-pregate', 'ARTIFACT_SCHEMA', e.message) + '\n'); return EXIT.fail; }
|
||||
|
||||
+38
-1
@@ -173,7 +173,7 @@ The list lives in its own file, not in `pregate.json`, because older pregate rea
|
||||
## `gstack-pregate`
|
||||
|
||||
```
|
||||
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
|
||||
gstack-pregate [--stage preflight|tests|guards|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
|
||||
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
|
||||
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <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).
|
||||
|
||||
<a id="prepush"></a>
|
||||
### 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: <verdict> (<detail>)`. 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 id="guard-carry"></a>
|
||||
### A guard that already passed at this tree does not run again
|
||||
|
||||
|
||||
@@ -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<PregateFile> {
|
||||
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<Stage, number> = { preflight: 0, tests: 0 };
|
||||
@@ -99,7 +104,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
|
||||
const file: PregateFile = {
|
||||
schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(),
|
||||
policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run, scratch: ctx.scratch,
|
||||
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests,
|
||||
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' || o.stage === 'guards' ? undefined : clocks.tests,
|
||||
checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }),
|
||||
requires_remote: remote,
|
||||
};
|
||||
|
||||
+38
-1
@@ -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 <branch-name> [--gate-open <n>] [--gate-open-approved] [--early [--base <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 <base> --lane <label>=<command>... [--wait <s>]
|
||||
after push: final-tree CI acceptance. Waits up to --wait seconds (default 170) for the CI run of each mapped profile at
|
||||
@@ -503,6 +505,38 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
|
||||
return r.next('Step 18: the receipt records ci_carried from the ledger');
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-push housekeeping guards (docs/pregate.md#prepush): `gstack-pregate --stage guards` before every push, early or
|
||||
* final, so a stale golden, a size ratchet or a type error fails here instead of a CI round. A pass recorded at HEAD's
|
||||
* exact tree by this step or by finish's preflight (both under .git, which no commit can carry) is reused, never rerun.
|
||||
*/
|
||||
function prepushGuards(cwd: string, branch: string, base: string | undefined): { line: string; stop?: string } {
|
||||
const gitPath = (name: string) => path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', `gstack/${name}`]).stdout.trim() || `.git/gstack/${name}`);
|
||||
const tree = git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim();
|
||||
const out = gitPath('pregate-guards.json');
|
||||
for (const file of [out, gitPath('pregate-finish-preflight.json')]) {
|
||||
let rec: { tree?: string; checks?: { id: string; status: string; detail?: string }[] };
|
||||
try { rec = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
|
||||
const rows = PREPUSH_CHECK_IDS.map(id => rec.checks?.find(c => c.id === id));
|
||||
if (tree && rec.tree === tree && rows.every(row => row?.status === 'pass')) return { line: `GUARDS: pass (reused from ${path.basename(file)} at tree ${tree.slice(0, 12)}: ${rows.map(row => `${row!.id} ${row!.detail ?? 'pass'}`).join('; ')})` };
|
||||
}
|
||||
fs.rmSync(out, { force: true });
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--stage', 'guards', '--allow-repo-commands', '--json', '--out', out, ...(base ? ['--base', base] : [])], 3_600_000);
|
||||
let res: { status?: string; verdict?: string; tree?: string; code?: string; detail?: string; fix?: string; checks?: { id: string; status: string; detail?: string; fix?: string }[] } = {};
|
||||
try { res = JSON.parse(pg.stdout.trim().split('\n').pop() ?? ''); } catch { /* unreadable: stops below */ }
|
||||
const heads = res.status === 'refused' && res.code === 'POLICY_SOURCE_UNAVAILABLE' ? git(cwd, ['ls-remote', '--heads', 'origin'], 120_000) : null;
|
||||
if (heads && heads.status !== 0) return { line: 'GUARDS: not run (origin unreachable)', stop: `cannot verify the remote branch (${firstLine(heads.stderr)}): restore access before pushing` };
|
||||
if (heads && heads.stdout.split('\n').every(l => !l.trim() || l.trim().endsWith(`\trefs/heads/${branch}`))) return { line: `GUARDS: not run (origin has no branch but ${branch}, so no base to read guards from)` };
|
||||
if (res.status === 'refused') return { line: `GUARDS: refused ${res.code}`, stop: `the pre-push guards could not run (${res.detail}; fix: ${res.fix}): repair it, then rerun push` };
|
||||
const bad = (res.checks ?? []).filter(c => c.status !== 'pass' && c.status !== 'warn').map(c => `${c.id}: ${c.fix ?? c.detail ?? c.status}`).join('; ');
|
||||
if (res.tree && res.tree !== tree) return { line: `GUARDS: not certified (they checked the working tree ${res.tree.slice(0, 12)}, not HEAD's tree ${tree.slice(0, 12)})`, stop: `the working tree differs from HEAD (uncommitted or untracked files): commit or stash them, then rerun push${bad ? `; the guards also reported ${bad}` : ''}` };
|
||||
if (git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim() !== tree) return { line: 'GUARDS: not certified (HEAD moved while the guards ran)', stop: 'HEAD changed while the guards ran: rerun push' };
|
||||
const verdict = res.verdict ?? `unreadable (exit ${pg.status}: ${firstLine(pg.stderr)})`;
|
||||
const line = `GUARDS: ${verdict}${res.checks ? ` (${res.checks.map(c => `${c.id} ${c.detail ?? c.status}`).join('; ')})` : ''}`;
|
||||
if (pg.status === 0 && /^(pass|warn)/.test(verdict)) return { line };
|
||||
return { line, stop: `the pre-push guards did not pass (${bad || firstLine(pg.stderr || pg.stdout)}): fix it, commit, then rerun push` };
|
||||
}
|
||||
|
||||
export function push(argv: readonly string[], cwd: string): Report {
|
||||
const { flag, has } = flagReader(argv);
|
||||
const r = new Report();
|
||||
@@ -532,6 +566,9 @@ export function push(argv: readonly string[], cwd: string): Report {
|
||||
r.add(`SECRETS: ${verdict} (early push)`);
|
||||
if (scan.status !== 0 || !/^(pass|warn)/.test(verdict)) return r.stop(`the secrets scan did not pass before the early push (${firstLine(scan.stderr) || verdict}): remove the finding from the commits, then rerun push --early`);
|
||||
}
|
||||
const guards = prepushGuards(cwd, branch, flag('--base'));
|
||||
r.add(guards.line);
|
||||
if (guards.stop) return r.stop(guards.stop);
|
||||
const local = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
|
||||
if (remote.status !== 0) return r.stop(`cannot verify the remote branch (${firstLine(remote.stderr)}): restore access before pushing`);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.94.26",
|
||||
"version": "1.94.27",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ One re-audit at most.
|
||||
Commit in bisectable chunks; `gstack-ship-release finish --base <base> --reviewed-tree <t> --reviewed-base <b>
|
||||
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
|
||||
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
|
||||
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
|
||||
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `GUARDS:`, `PUSH:`
|
||||
(pushed, ALREADY_PUSHED). `EVIDENCE: DEFERRED`: the `accept` its `NEXT:` names; final-head
|
||||
repo gates run beside CI. Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
|
||||
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ One re-audit at most.
|
||||
Commit in bisectable chunks; `gstack-ship-release finish --base <base> --reviewed-tree <t> --reviewed-base <b>
|
||||
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
|
||||
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
|
||||
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
|
||||
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `GUARDS:`, `PUSH:`
|
||||
(pushed, ALREADY_PUSHED). `EVIDENCE: DEFERRED`: the `accept` its `NEXT:` names; final-head
|
||||
repo gates run beside CI. Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const KEPT: Record<string, string> = {
|
||||
'restamp-staging.json': 'gstack-restamp files still to stage, read by its noop rerun (bin/gstack-restamp)',
|
||||
'gate-ahead*': 'gate-ahead record and worktree (lib/restamp.ts)',
|
||||
'pregate.json': 'pregate result (lib/pregate/index.ts)',
|
||||
'pregate-guards.json': 'gstack-pregate --stage guards result, the pre-push housekeeping run (lib/pregate/index.ts PREGATE_GUARDS_OUT_REL)',
|
||||
'ship-queue': 'ship-queue state (lib/ship-queue-gate.ts)',
|
||||
'ship-receipt.md': '/ship handoff receipt',
|
||||
'ship-gate.json': '/ship handoff gate record',
|
||||
|
||||
@@ -240,3 +240,59 @@ describe('lanes runtime and serial files (CD1)', () => {
|
||||
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*2 tests ran in 2 file\(s\) \(1 serial\), exit 0/m);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GST-1138 (ship-90-90 L6): the pre-push housekeeping run and gstack's own guard list
|
||||
// ---------------------------------------------------------------------------
|
||||
import { matchesAny } from '../lib/ship-policy';
|
||||
|
||||
describe('--stage guards: the pre-push housekeeping run (GST-1138)', () => {
|
||||
// Value: protects=--stage guards runs only regen and guards for the touched files, writes its own record and never replaces the full run's
|
||||
// pregate.json; fails_when=it runs the secrets, strays or test checks, overwrites .gstack/tmp/pregate.json, or accepts --only/--from-run;
|
||||
// why_new=gbrain: 64 of 93 red heads were housekeeping (FOUN-64), #3908 went red on the context-budget ratchet; seam=gstack-pregate run; no_claim=CI
|
||||
test('runs only regen and guards, writes pregate-guards.json, leaves pregate.json alone and refuses --only and --from-run', () => {
|
||||
const repo = guardedFixture({ ...BASE_PREGATE, guards: [SERIAL_GUARD] });
|
||||
commit(repo, { 'test/widget-env.serial.test.js': SERIAL_TEST });
|
||||
const r = pregate(repo, ['--allow-repo-commands', '--stage', 'guards']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out).toMatch(/^preflight {2}guards {4}fail .*serial-manifest.*\(PREGATE_GUARD_FAILED\)/m);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/pregate.json'))).toBe(false);
|
||||
const rec = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate-guards.json'), 'utf8'));
|
||||
expect(rec.checks.map((c: any) => c.id)).toEqual(['regen', 'guards']);
|
||||
expect(rec.stage2_ms).toBeUndefined();
|
||||
commit(repo, { 'scripts/serial-files.tsv': 'path\treason\ntest/widget-env.serial.test.js\tglobal-state\n' });
|
||||
const ok = pregate(repo, ['--allow-repo-commands', '--stage', 'guards']);
|
||||
expect(ok.status, ok.out + ok.err).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain('1 guard(s) passed: serial-manifest');
|
||||
expect(pregate(repo, ['--stage', 'guards', '--only', 'secrets']).status).toBe(EXIT.usage);
|
||||
expect(pregate(repo, ['--stage', 'guards', '--from-run', '123']).status).toBe(EXIT.usage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack's own pre-push guards (GST-1138)", () => {
|
||||
// Value: protects=gstack declares the housekeeping guards that went red after push (typecheck, skill size, parity, context budget,
|
||||
// module size, goldens, doc caps), each runnable and selected only by its inputs; fails_when=a guard is dropped, names a missing test file,
|
||||
// fails validation, or fires on a docs-only change; why_new=GST-1138; seam=.gstack/pregate.json; no_claim=the guards' own verdicts
|
||||
const GSTACK = path.resolve(import.meta.dir, '..');
|
||||
const policy = JSON.parse(fs.readFileSync(path.join(GSTACK, '.gstack/pregate.json'), 'utf8'));
|
||||
const guards: { id: string; when: string[]; command: string; fix?: string }[] = policy.guards ?? [];
|
||||
const selected = (file: string) => guards.filter(g => matchesAny(g.when, file)).map(g => g.id).sort();
|
||||
|
||||
test('the list validates, every guard has a fix, and every test file it names exists', () => {
|
||||
expect(codes(policy)).toEqual([]);
|
||||
expect(guards.map(g => g.id).sort()).toEqual(['context-budget', 'doc-caps', 'goldens', 'module-size', 'parity-suite', 'skill-size', 'typecheck']);
|
||||
for (const g of guards) {
|
||||
expect(g.fix, g.id).toBeTruthy();
|
||||
for (const f of g.command.match(/test\/[\w./-]+\.test\.ts/g) ?? []) expect(fs.existsSync(path.join(GSTACK, f)), `${g.id}: ${f}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('each guard fires on its own inputs and none fires on a docs-only change', () => {
|
||||
expect(selected('ship/SKILL.md.tmpl')).toEqual(['context-budget', 'goldens', 'parity-suite', 'skill-size']);
|
||||
expect(selected('lib/pregate/index.ts')).toEqual(['module-size', 'typecheck']);
|
||||
expect(selected('test/fixtures/golden/claude-ship-SKILL.md')).toEqual(['goldens']);
|
||||
expect(selected('CLAUDE.md')).toEqual(['doc-caps']);
|
||||
expect(selected('docs/pregate.md')).toEqual([]);
|
||||
expect(selected('changelog.d/x.md')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -352,3 +352,99 @@ describe('GSTA-167 part 4a: the post-review route stays path-only', () => {
|
||||
expect(r.stdout).toContain('STOP: behavior, tests or build inputs changed since review (wiki/post/data.json)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('push: the pre-push housekeeping guards (GST-1138)', () => {
|
||||
// Value: protects=push runs gstack-pregate --stage guards before every push and stops on a failing guard with its fix, pushing nothing;
|
||||
// a pass recorded at HEAD's exact tree is reused instead of rerun; fails_when=a housekeeping red reaches CI, a failed guard still pushes,
|
||||
// or a guard reruns on a tree it already passed; why_new=gbrain 64 of 93 red heads were housekeeping (FOUN-64), #3908's context-budget red;
|
||||
// seam=gstack-pregate --stage guards; no_claim=CI's own verdict
|
||||
const policy = (command: string) => JSON.stringify({ guards: [{ id: 'housekeeping', when: ['src/**'], command, fix: 'regenerate the golden' }] }) + '\n';
|
||||
const ready = (f: ShipFx) => { fs.mkdirSync(f.state, { recursive: true }); fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), ''); };
|
||||
const pushedHead = (f: ShipFx) => f.git('ls-remote', '--heads', 'origin', 'refs/heads/feat').stdout.trim();
|
||||
|
||||
test('a failing guard stops the push with its fix and nothing is pushed', () => {
|
||||
const f = fx({ '.gstack/pregate.json': policy('echo golden is stale; exit 4') });
|
||||
ready(f);
|
||||
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(lineOf(r.stdout, 'GUARDS')).toMatch(/^GUARDS: fail .*housekeeping/);
|
||||
expect(r.stdout).toContain('STOP: the pre-push guards did not pass');
|
||||
expect(r.stdout).toContain('regenerate the golden');
|
||||
expect(pushedHead(f)).toBe('');
|
||||
});
|
||||
|
||||
test('a passing guard pushes; the next push at the same tree reuses the pass instead of rerunning it', () => {
|
||||
const f = fx({ '.gstack/pregate.json': policy('echo ran >> ../guard-runs') });
|
||||
ready(f);
|
||||
const first = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(first.status, first.stdout + first.stderr).toBe(0);
|
||||
expect(lineOf(first.stdout, 'GUARDS')).toMatch(/^GUARDS: pass .*1 guard\(s\) passed: housekeeping/);
|
||||
expect(lineOf(first.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
|
||||
const again = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(lineOf(again.stdout, 'GUARDS')).toMatch(/^GUARDS: pass \(reused from pregate-guards\.json at tree [0-9a-f]{12}: /);
|
||||
expect(fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8')).toBe('ran\n');
|
||||
f.write({ 'src/c.ts': 'export const c = 3;\n' }); f.commit('more');
|
||||
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'GUARDS')).not.toContain('reused');
|
||||
expect(fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8')).toBe('ran\nran\n');
|
||||
});
|
||||
|
||||
test('an uncommitted or untracked file the guards saw stops the push: the verdict must describe the pushed tree', () => {
|
||||
const f = fx({ '.gstack/pregate.json': policy('test -f src/needed.ts') });
|
||||
ready(f);
|
||||
f.write({ 'src/needed.ts': 'export const n = 1;\n' });
|
||||
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(lineOf(r.stdout, 'GUARDS')).toContain('not certified');
|
||||
expect(r.stdout).toContain('STOP: the working tree differs from HEAD');
|
||||
expect(pushedHead(f)).toBe('');
|
||||
});
|
||||
|
||||
test('a base that exists on origin but is not fetched, a mistyped --base, a dangling origin/HEAD and an unreadable policy stop the push', () => {
|
||||
const f = fx({ '.gstack/pregate.json': policy('exit 4') });
|
||||
ready(f);
|
||||
f.git('update-ref', '-d', 'refs/remotes/origin/main');
|
||||
const unfetched = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(unfetched.status).toBe(3);
|
||||
expect(lineOf(unfetched.stdout, 'GUARDS')).toMatch(/^GUARDS: refused POLICY_SOURCE_UNAVAILABLE/);
|
||||
expect(unfetched.stdout).toContain('STOP: the pre-push guards could not run');
|
||||
f.git('fetch', '-q', 'origin', 'main:refs/remotes/origin/main');
|
||||
const typo = f.run(RELEASE, ['push', '--branch', 'feat', '--base', 'mian']);
|
||||
expect(typo.status).toBe(3);
|
||||
expect(lineOf(typo.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
|
||||
f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/retired-default');
|
||||
const dangling = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(dangling.status).toBe(3);
|
||||
expect(lineOf(dangling.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
|
||||
expect(pushedHead(f)).toBe('');
|
||||
const broken = fx({ '.gstack/pregate.json': '{not json\n' });
|
||||
ready(broken);
|
||||
const refused = broken.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(refused.status).toBe(3);
|
||||
expect(lineOf(refused.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
|
||||
});
|
||||
|
||||
test("finish's preflight record is reused only at HEAD's tree with passing regen and guards rows", () => {
|
||||
const f = fx({ '.gstack/pregate.json': policy('echo ran >> ../guard-runs') });
|
||||
ready(f);
|
||||
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
const record = (at: string, guards: string) => JSON.stringify({ tree: at, checks: [{ id: 'regen', status: 'pass' }, { id: 'guards', status: guards }] });
|
||||
const finishRecord = path.join(f.work, '.git', 'gstack', 'pregate-finish-preflight.json');
|
||||
fs.mkdirSync(path.dirname(finishRecord), { recursive: true });
|
||||
const runs = () => (fs.existsSync(path.join(f.root, 'guard-runs')) ? fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8') : '');
|
||||
for (const [at, guards] of [[tree, 'fail'], ['0'.repeat(40), 'pass']]) {
|
||||
fs.rmSync(path.join(f.work, '.git', 'gstack', 'pregate-guards.json'), { force: true });
|
||||
fs.writeFileSync(finishRecord, record(at, guards));
|
||||
fs.rmSync(path.join(f.root, 'guard-runs'), { force: true });
|
||||
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'GUARDS')).not.toContain('reused');
|
||||
expect(runs()).toBe('ran\n');
|
||||
f.git('push', '-q', 'origin', '--delete', 'feat');
|
||||
}
|
||||
fs.rmSync(path.join(f.work, '.git', 'gstack', 'pregate-guards.json'), { force: true });
|
||||
fs.writeFileSync(finishRecord, record(tree, 'pass'));
|
||||
fs.rmSync(path.join(f.root, 'guard-runs'), { force: true });
|
||||
const reused = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||||
expect(lineOf(reused.stdout, 'GUARDS')).toMatch(/^GUARDS: pass \(reused from pregate-finish-preflight\.json at tree /);
|
||||
expect(runs()).toBe('');
|
||||
expect(lineOf(reused.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user