mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 10:40:32 +01:00
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>
This commit is contained in:
1 parent
5f705f367a
commit
8ce5f29e6f
5 files changed
+140
-8
No files matched your search
+34
-5
@@ -5,6 +5,7 @@
|
||||
*
|
||||
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
|
||||
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
|
||||
* gstack-evidence passed -- <cmd>
|
||||
* gstack-evidence carried [--json]
|
||||
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
|
||||
* [--max-age <hours>] [--allow-paths <csv>]
|
||||
@@ -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 -- <cmd>` (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=<L> source=<ci|local> ts=<ts> and exits 0, else NOT_PASSED: <reason> 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 -- <command>"); return 2; }
|
||||
const wtree = currentWtree();
|
||||
if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; }
|
||||
let records: EvidenceRecord[] = [];
|
||||
try { records = readJsonl<EvidenceRecord>(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) {
|
||||
|
||||
@@ -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: <id> carried runs=…`); otherwise a counted ledger pass of that command at the same working tree stands (`GUARD: <id> reused …`, and the new read-only `gstack-evidence passed -- <command>`). 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/<base>` 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.
|
||||
@@ -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 id="guard-carry"></a>
|
||||
### 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: <id> carried
|
||||
runs=<id>/<attempt>`. When no run carries, it prints `CI_CARRY: none
|
||||
guard=<id> (<reason>)` 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: <id> reused label=<L> source=<ci|local> ts=<ts>`.
|
||||
`gstack-evidence passed -- <command>` 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/<base>` 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.
|
||||
|
||||
<a id="allowlist"></a>
|
||||
## Allowlist: benign findings the secrets check accepts
|
||||
|
||||
|
||||
@@ -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/<base> 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<GuardRun> {
|
||||
const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs;
|
||||
@@ -53,9 +78,14 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
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<CheckResult> {
|
||||
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 };
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user