From 8ce5f29e6f001589c92b5877d81cf116d15bbf63 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 05:06:03 +0000 Subject: [PATCH] feat(pregate): a guard that already passed at this tree carries from CI or the ledger (GST-1186) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-evidence | 39 +++++++++++++++++++++++---- changelog.d/gsta258-l6-guard-carry.md | 5 ++++ docs/pregate.md | 28 +++++++++++++++++++ lib/pregate/checks/guards.ts | 37 ++++++++++++++++++++++--- test/ship-ci-carry.test.ts | 39 +++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 changelog.d/gsta258-l6-guard-carry.md diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 8fc3e692b..9d5f80ef6 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -5,6 +5,7 @@ * * gstack-evidence run [--reuse [--max-age ]] --label -- * gstack-evidence record-ci --label --base -- + * gstack-evidence passed -- * gstack-evidence carried [--json] * gstack-evidence check [--label [--expect-cmd ]]... | --all * [--max-age ] [--allow-paths ] @@ -598,6 +599,35 @@ function identityDigest(): string { return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null })); } +/** A row that passed with a counted run within the max age: a CI row (record-ci verified its run) or a local run under this machine's identity. */ +function countedPass(r: EvidenceRecord, maxAgeHours: number): boolean { + if (r.exit !== 0 || typeof r.tests_ran !== "number" || r.tests_ran <= 0) return false; + const age = Date.now() - Date.parse(r.ts); + if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false; + return r.source === "ci" || r.identity === identityDigest(); +} + +/** + * `passed -- ` (GST-1186, read-only): did this exact command pass at the current working tree under any label? The + * newest row for the command hash and tree decides, so a newer failure vetoes; it must be a counted pass (countedPass). Prints + * PASSED: label= source= ts= and exits 0, else NOT_PASSED: and exits 1. gstack-pregate's guards read it. + */ +function cmdPassed(argv: string[]): number { + const sep = argv.indexOf("--"); + const command = sep < 0 ? "" : argv.slice(sep + 1).join(" "); + if (!command || sep !== 0) { console.error("usage: gstack-evidence passed -- "); return 2; } + const wtree = currentWtree(); + if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; } + let records: EvidenceRecord[] = []; + try { records = readJsonl(ledgerPath().file); } catch { records = []; } + const cmdSha = sha256(command); + const newest = records.findLast((r) => r.cmd_sha256 === cmdSha && r.wtree === wtree); + if (!newest) { console.log(`NOT_PASSED: no ledger row for this command at tree ${wtree.slice(0, 12)}`); return 1; } + if (!countedPass(newest, 24)) { console.log(`NOT_PASSED: the newest row (label=${newest.label} ts=${newest.ts}) is not a counted pass within 24 h under this runtime`); return 1; } + console.log(`PASSED: label=${newest.label} source=${newest.source ?? "local"} ts=${newest.reused_from ?? newest.ts}`); + return 0; +} + /** * `run --reuse`: the newest local row for this label, command and working tree decides. A counted pass under the same * identity within the max age is copied (original ts kept) and printed as a run would be; anything else runs the command. @@ -612,10 +642,7 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number return false; } const newest = records.findLast((r) => r.label === label && r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined); - if (!newest || newest.exit !== 0 || typeof newest.tests_ran !== "number" || newest.tests_ran <= 0) return false; - const age = Date.now() - Date.parse(newest.ts); - if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false; - if (newest.identity !== identityDigest()) return false; + if (!newest || !countedPass(newest, maxAgeHours)) return false; try { appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 }); } catch (e: any) { @@ -912,6 +939,8 @@ try { process.exit(await cmdRun(rest)); } else if (sub === "record-ci") { process.exit(cmdRecordCi(rest)); + } else if (sub === "passed") { + process.exit(cmdPassed(rest)); } else if (sub === "carried") { process.exit(cmdCarried(rest)); } else if (sub === "check") { @@ -923,7 +952,7 @@ try { } else if (sub === "verify") { process.exit(cmdVerify(rest)); } else { - console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ..."); + console.error("usage: gstack-evidence run|record-ci|passed|carried|check|bundle|ancestor|verify ..."); process.exit(2); } } catch (e: any) { diff --git a/changelog.d/gsta258-l6-guard-carry.md b/changelog.d/gsta258-l6-guard-carry.md new file mode 100644 index 000000000..7afc6e0ab --- /dev/null +++ b/changelog.d/gsta258-l6-guard-carry.md @@ -0,0 +1,5 @@ +- A pregate guard whose exact command already passed at this tree no longer runs again (GST-1186). When the base branch's `ship_lanes_from` maps the command, it is carried from the verified CI run through the same `gstack-evidence record-ci` path /ship lanes use (`GUARD: carried runs=…`); otherwise a counted ledger pass of that command at the same working tree stands (`GUARD: reused …`, and the new read-only `gstack-evidence passed -- `). Repos whose suites are guards, like founderstar, stop rerunning about six minutes of suites per /ship. See docs/pregate.md#guard-carry. +- Known limit: `ship_lanes_from` is still read from `origin/` only, so the PR that adds a mapping cannot carry from it. + +### Tests +- `test/ship-ci-carry.test.ts`: in founderstar's shape (guards only, no lanes), `check-quick` carries from CI, `play-through` reuses a same-tree ledger pass, `assets` runs, and a dirty tree carries nothing. It fails without the guard carry. diff --git a/docs/pregate.md b/docs/pregate.md index 2a5ca0943..357f498d8 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -1080,6 +1080,34 @@ 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 guard that already passed at this tree does not run again + +A guard is skipped as passed when its exact command already passed at this tree +(GST-1186). Two sources count, checked in this order: + +1. **CI.** When the base branch's `ship_lanes_from` maps the guard's exact + command, the guard is carried from the verified CI run at HEAD's clean tree, + through the same `gstack-evidence record-ci` and `findCarry` path a /ship + lane uses ([ci-carry](#ci-carry)). It prints `GUARD: carried + runs=/`. When no run carries, it prints `CI_CARRY: none + guard= ()` and goes on to the ledger. +2. **The evidence ledger.** When the newest ledger row for the exact command at + this working tree, under any label, is a counted pass (a CI row, or a local + run with `tests_ran` above zero under this runtime, within 24 hours), the + guard prints `GUARD: reused label= source= ts=`. + `gstack-evidence passed -- ` is the same lookup by hand. A newer + failure of the command at that tree vetoes an older pass. + +Anything else runs the guard as before, with the same timeout, stall rule and +fix line. This matters for repos whose suites are guards rather than lanes +(founderstar declares `check-quick`, `play-through` and `assets` as guards), +which otherwise reran every suite /ship had just carried from CI. + +Known limit: `ship_lanes_from` is read from `origin/` only, so the pull +request that adds a mapping cannot carry from it. Prove it with the dry run in +[proving a new mapping](#prove-a-mapping) until it lands. + ## Allowlist: benign findings the secrets check accepts diff --git a/lib/pregate/checks/guards.ts b/lib/pregate/checks/guards.ts index 1a67ff05a..f7f5d99cd 100644 --- a/lib/pregate/checks/guards.ts +++ b/lib/pregate/checks/guards.ts @@ -11,6 +11,13 @@ * incident: a gbrain serial test landed without its manifest row and * passed both the isolation check and pregate; `test/cli-goldens.test.ts` * spawns the CLI as a subprocess, so no import graph selects it. + * + * GST-1186: a guard whose exact command already passed at this tree is not run + * again. #3901's carry (gstack-evidence record-ci) carries it from a verified + * CI run when the base's ship_lanes_from maps the command, else a counted + * ledger pass of the same command at the same working tree stands + * (gstack-evidence passed). ship_lanes_from is read from origin/ only, so + * the PR that adds a mapping cannot carry from it yet. */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -23,8 +30,26 @@ import { checkScratch } from '../run-scratch'; import { readRange, STALL_IDLE, supervise } from '../supervise'; import type { EnvironmentCause } from '../types'; import type { CheckDef, CheckResult, PregateContext } from '../types'; +import { runScriptSync } from '../../script-invocation'; -interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null } +interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null; prior?: string; carryNone?: string } + +const EVIDENCE = path.resolve(import.meta.dir, '..', '..', '..', 'bin', 'gstack-evidence'); + +/** The pass that already covers this guard's exact command at this tree (GST-1186): a CI carry, else a same-tree ledger pass. */ +function priorPass(ctx: PregateContext, guard: Guard): { prior?: string; carryNone?: string } { + const evidence = (args: string[]) => runScriptSync(EVIDENCE, args, { cwd: ctx.repoRoot, env: ctx.env, encoding: 'utf8', timeout: 600_000 }); + let carryNone: string | undefined; + if (Object.prototype.hasOwnProperty.call(ctx.config.ship_lanes_from, guard.command)) { + const ci = evidence(['record-ci', '--label', `guard-${guard.id}`, '--base', ctx.base, '--', guard.command]); + const runs = / source=ci runs=(\S+)/.exec(String(ci.stderr ?? ''))?.[1]; + if (ci.status === 0 && runs) return { prior: `carried runs=${runs}` }; + carryNone = /^CARRY_NONE: (.+)$/m.exec(String(ci.stderr ?? ''))?.[1] ?? `record-ci exit ${ci.status}`; + } + const local = evidence(['passed', '--', guard.command]); + const hit = /^PASSED: (.+)$/m.exec(String(local.stdout ?? ''))?.[1]; + return local.status === 0 && hit ? { prior: `reused ${hit}`, carryNone } : { carryNone }; +} async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): Promise { const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs; @@ -53,9 +78,14 @@ async function run(ctx: PregateContext): Promise { if (runtime.refusal) return { ...base, ...runtime.refusal, cause: 'environment', environment: launchCause('runtime', runtime.refusal.detail), lines: [`runtime: ${runtime.label}`, environmentLine(launchCause('runtime', runtime.refusal.detail))] }; const dir = checkScratch(ctx.scratch, 'guards'); const runs: GuardRun[] = []; - for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir)); + for (const g of selected) { + const p = priorPass(ctx, g); + runs.push(p.prior ? { guard: g, exit: 0, timedOut: false, stalled: false, tail: [], log: '-', environment: null, ...p } : { ...(await runGuard(ctx, g, runtime.env, dir)), carryNone: p.carryNone }); + } const lines = [`runtime: ${runtime.label}`]; for (const r of runs) { + if (r.carryNone) lines.push(`CI_CARRY: none guard=${r.guard.id} (${r.carryNone})`); + if (r.prior) { lines.push(`GUARD: ${r.guard.id} ${r.prior}`); continue; } lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`); if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`)); if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`); @@ -68,7 +98,8 @@ async function run(ctx: PregateContext): Promise { if (failed.length) return { ...withRuns, status: 'fail', cause: 'diff', code: 'PREGATE_GUARD_FAILED', detail: `${failed.length} guard(s) failed: ${failed.map(r => r.guard.id).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}${blocked.length ? `; blocked by the environment: ${blocked.map(r => r.guard.id).join(', ')}` : ''}`, fix: fixOf(failed) }; if (blocked.length) return environmentResult(withRuns, blocked[0]!.environment!, `${blocked.length} guard(s) could not run on this machine: ${blocked.map(r => `${r.guard.id} (${r.environment!.kind})`).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}`); if (timedOut.length) return { ...withRuns, status: 'incomplete', code: timedOut.some(r => r.stalled) ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) did not finish: ${timedOut.map(r => `${r.guard.id} (${r.stalled ? 'stalled' : 'timed out'})`).join(', ')}`, fix: 'fix the hang, or raise the guard\'s timeout_s, --timeout or --stall-timeout; an unfinished guard never passes' }; - return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}` }; + const prior = runs.filter(r => r.prior).length; + return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}${prior ? ` (${prior} from an earlier pass at this tree)` : ''}` }; } export const guardsCheck: CheckDef = { id: 'guards', stage: 'preflight', tier: 2, downgradable: false, needsRepoCommands: true, run }; diff --git a/test/ship-ci-carry.test.ts b/test/ship-ci-carry.test.ts index 60e494dca..327a00c9f 100644 --- a/test/ship-ci-carry.test.ts +++ b/test/ship-ci-carry.test.ts @@ -192,3 +192,42 @@ describe('gstack-pregate tests stage and gstack-ci-carry', () => { expect(cli(['bogus']).status).toBe(2); }); }); + +describe('GST-1186: pregate guards carry like lanes (founderstar shape: guards only, no lanes)', () => { + // Value: protects=a guard whose exact command the base's ship_lanes_from maps is carried from the verified CI run at this tree, and a guard + // whose exact command already passed at this tree in the ledger is reused, so guard-only repos stop rerunning suites CI or /ship already ran; + // fails_when=check-quick reruns beside a green CI run, play-through reruns after a same-tree ledger pass, or a dirty tree still carries; + // why_new=FOUN-74 (founderstar GST-1184): about 6 minutes of guard suites rerun per /ship; seam=gstack-evidence record-ci and passed; no_claim=CI + const script = (name: string, tests: number) => `printf x >> ../${name}-runs\nprintf ' ${tests} pass\\n 0 fail\\nRan ${tests} tests across 1 file. [1.00ms]\\n' >&2\n`; + const CHECK = 'bash tests/check.sh --quick'; + const PLAY = 'bash tests/play-through.sh'; + const guard = (id: string, command: string) => ({ id, when: ['src/**'], command, fix: `rerun ${id}` }); + const FOUNDERSTAR = { + ...BASE, + '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [CHECK]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } }, + guards: [guard('check-quick', CHECK), guard('play-through', PLAY), guard('assets', 'bash tests/assets.sh')] }, null, 2) + '\n', + 'tests/check.sh': script('check', 4), 'tests/play-through.sh': script('play', 2), 'tests/assets.sh': script('assets', 1), + }; + const guards = (f: ShipFx) => f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']); + const counts = (f: ShipFx) => [runs(f, 'check-runs'), runs(f, 'play-runs'), runs(f, 'assets-runs')]; + + test('check-quick carries from CI, play-through reuses a same-tree ledger pass, assets runs; a dirty tree carries nothing', () => { + const f = shipFixture(FOUNDERSTAR); fixtures.push(f); + world(f); + const first = guards(f); + expect(first.status, first.stdout + first.stderr).toBe(0); + expect(first.stdout).toContain('GUARD: check-quick carried runs=7/1'); + expect(first.stdout).toContain('3 guard(s) passed: check-quick, play-through, assets (1 from an earlier pass at this tree)'); + expect(counts(f)).toEqual([0, 1, 1]); + expect(f.run(path.join(BIN, 'gstack-evidence'), ['run', '--label', 'play', '--', PLAY]).status).toBe(0); + const second = guards(f); + expect(second.status).toBe(0); + expect(second.stdout).toMatch(/GUARD: play-through reused label=play source=local ts=\S+/); + expect(counts(f)).toEqual([0, 2, 2]); + f.write({ 'src/b.ts': 'export const b = 3;\n' }); + const dirty = guards(f); + expect(dirty.stdout).toMatch(/CI_CARRY: none guard=check-quick \(dirty_tree/); + expect(dirty.stdout).not.toContain('reused'); + expect(counts(f)).toEqual([1, 3, 3]); + }); +});