diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 6e70f630f..d682c7342 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -94,7 +94,7 @@ import { resultLine } from "../lib/result-codes"; import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy"; import { versionAtRef } from "../lib/restamp"; import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation"; -import { findCarry, laneMapping, mappingCoverage } from "../lib/ci-carry"; +import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ci-carry"; import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config"; import { buildRunCheck } from "../lib/pregate/run-check"; @@ -135,6 +135,7 @@ interface EvidenceRecord { tests_unit?: "files"; /** Why a carried lane was not owed at all: every job it names was path-skipped by an unchanged gate (record-ci, GST-1190). */ not_owed?: string; + skipped_here?: { label: string; files: string[]; tests_ran: number | "unknown" }; } /** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */ @@ -607,18 +608,19 @@ 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. */ +/** A local row that passed with a counted run within the max age, 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(); + return 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. + * `passed -- ` (GST-1186, read-only): did this exact command pass here at the current working tree under any label? The + * newest local row for the command hash and tree decides, so a newer failure vetoes; it must be a counted pass (countedPass). + * CI rows never count: a CI pass counts only through record-ci's live check against the base. Prints PASSED: label= + * source=local 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("--"); @@ -629,10 +631,10 @@ function cmdPassed(argv: string[]): number { 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; } + const newest = records.findLast((r) => r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined); + if (!newest) { console.log(`NOT_PASSED: no local 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}`); + console.log(`PASSED: label=${newest.label} source=local ts=${newest.reused_from ?? newest.ts}`); return 0; } @@ -664,9 +666,11 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number /** * `record-ci`: carry a lane from CI. Its command must be a key of the base branch's ship_lanes_from; the run is found and - * verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane. + * verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane; + * inventory files every run skipped entirely run here through the entry's per_file runner (label .skipped) first, and the CI + * row is written only when that run passes at the same tree. */ -function cmdRecordCi(argv: string[]): number { +async function cmdRecordCi(argv: string[]): Promise { const sep = argv.indexOf("--"); const opts = sep < 0 ? argv : argv.slice(0, sep); const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; }; @@ -696,6 +700,17 @@ function cmdRecordCi(argv: string[]): number { const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; }; const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) }); if (!v.ok) return none(v.reason, v.detail); + let here: EvidenceRecord["skipped_here"]; + if (v.remainder?.length) { + const hereLabel = `${label}.skipped`; + console.error(`gstack-evidence record-ci: ${v.remainder.length} inventory file(s) skipped every test in CI; running them here (label=${hereLabel})`); + const exit = await cmdRun(["--label", hereLabel, "--", perFileCommand(m.per_file!, v.remainder)]); + if (exit !== 0) return none("skipped_failed", `the per_file run of ${v.remainder.length} skipped file(s) exited ${exit} (label=${hereLabel})`); + if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`); + let rows: EvidenceRecord[] = []; + try { rows = readJsonl(ledgerPath().file); } catch { rows = []; } + here = { label: hereLabel, files: v.remainder, tests_ran: rows.findLast((r) => r.label === hereLabel && r.source === undefined)?.tests_ran ?? "unknown" }; + } let paths: ReturnType; try { paths = ledgerPath(); @@ -707,10 +722,10 @@ function cmdRecordCi(argv: string[]): number { const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`); const rec: EvidenceRecord = { ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0, - commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), + commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}), }; appendJsonl(paths.file, rec, { mode: 0o600 }); - console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}`); + console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}` : ""}`); if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`); return 0; } @@ -952,7 +967,7 @@ try { if (sub === "run") { process.exit(await cmdRun(rest)); } else if (sub === "record-ci") { - process.exit(cmdRecordCi(rest)); + process.exit(await cmdRecordCi(rest)); } else if (sub === "passed") { process.exit(cmdPassed(rest)); } else if (sub === "carried") { diff --git a/changelog.d/gsta258-l6-guard-carry.md b/changelog.d/gsta258-l6-guard-carry.md index 7ac874ea9..4aefc75df 100644 --- a/changelog.d/gsta258-l6-guard-carry.md +++ b/changelog.d/gsta258-l6-guard-carry.md @@ -1,17 +1,17 @@ -- 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. +- 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 local ledger pass of that command at the same working tree stands (CI rows count only through a live `record-ci` check) (`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. - A `ship_lanes_from` entry can name the CI jobs that run a lane instead of an inventory command: `{ "profiles": [...], "jobs": ["verify", "typecheck"] }`, exactly one of the two (GST-1189). It carries only when every named job succeeded in the newest attempt of the verified run at HEAD's tree, through the same `verifyRun` job check. A missing job refuses as `job_missing` and a skipped one as the new `job_skipped`. Lanes with no test files, like gbrain's verify and typecheck (run 41564913688) or harness-evals' `bunx tsc --noEmit`, can now carry, and they record `tests_ran=n/a`. Fake `check:` entries in `tree.json` can move to this form. A repo declaring `jobs` should raise its `min_gstack_version` to this release first, since older gstack rejects the key. See docs/pregate.md#ship-lanes-jobs. - A `ship_lanes_from` entry can declare `aliases`, other exact spellings of the same lane such as `env -u ANTHROPIC_API_KEY bun test tests/` (GST-1190). gstack never strips env prefixes on its own, since an `env -i` run is a different environment. - A carried lane no longer reports the whole run's test count. An inventory lane reports how many of its files the run covered, labeled as a file count (alphaclaw's 46-test guards lane recorded 9677 before). -- `gstack-ship-preflight --tests-only --policy-from head --simulate` proves a new mapping before it lands: every line starts `SIMULATION:`, nothing runs, no evidence row is written and nothing clears. A branch's own map never carries, so the first pull request that adopts a mapping pays its full lanes. +- `gstack-ship-preflight --tests-only --policy-from head --simulate` proves a new mapping before it lands: every line starts `SIMULATION:`, no lane runs, no evidence row is written and nothing clears. An inventory-mapped lane runs the branch's inventory command, so the dry run needs `--allow-repo-commands`. A branch's own map never carries, so the first pull request that adopts a mapping pays its full lanes. - `gstack-evidence carried`, which the ship receipt reads, lists only rows the receipt base's policy mapped, so a run against another base never lands in a receipt. - An unknown `.gstack/pregate.json` key under a `min_gstack_version` above the running gstack now says `needs gstack >= ; upgrade` (`PREGATE_GSTACK_TOO_OLD`). It still refuses. - docs/pregate.md#prove-a-mapping documents the proof route, the version floors, and that `tree.json` writers using Bun's JUnit must take the root `testsuite` count. -- A test file whose every test skipped on the CI runner no longer counts as carried (GST-1190, harness-evals: 75 tests). `tree.json` now lists such files in `skipped_files`, written by gstack's own Free Tests from the per-file JUnit counts; the pregate tests stage runs them here, and a /ship lane or guard whose inventory names one runs here. A partly skipped file still counts. An older `tree.json` without the list is read as before. +- A test file whose every test skipped on the CI runner no longer counts as carried (GST-1190, harness-evals: 75 tests). `tree.json` now lists such files in `skipped_files`, written by gstack's own Free Tests from the per-file JUnit counts; the pregate tests stage runs them here. A /ship lane or guard whose inventory names one carries the covered files and runs only the skipped ones here, through the `per_file` runner its `ship_lanes_from` entry declares (for gstack, `bun test --timeout=30000 {files}`, so gstack runs just its 6 runner-skipped files). The CI row is written only after that run passes (`skipped_failed` otherwise), and an entry without `per_file` refuses as `inventory_skipped`, naming the files. A partly skipped file still counts. An older `tree.json` without the list is read as before. -- A run profile can declare `skippable: { jobs, gate, scripts? }` for jobs a path filter skips (GST-1190, truther #1169). A skipped job counts only when the gate job succeeded and the workflow plus every repo file the gate runs match `origin/` byte for byte; the lane then prints `not owed: CI skipped at this tree (gate job , workflow unchanged)`. If the branch changed any of them, it controlled the skip, so the lane runs here. +- A run profile can declare `skippable: { jobs, gate, scripts }` for jobs a path filter skips (GST-1190, truther #1169). A skipped job counts only in a `pull_request` run whose gate job succeeded, with the workflow, the declared `scripts` (every file the gate reads), the paths the gate job names (also under its `working-directory`) and `package.json` when it runs a package script matching `origin/` byte for byte; the lane then prints `not owed: CI skipped at this tree (gate job , workflow unchanged)`. If the branch changed any of them, it controlled the skip, so the lane runs here. - `gstack-ci-carry find --policy-from head --simulate` is the same dry run for one lane command, every line starting `SIMULATION:`. ### Tests @@ -20,3 +20,5 @@ - `test/ship-ci-carry.test.ts` (GST-1190): an alias carries and an undeclared env prefix does not; a narrow lane reports its covered file count, not 9677; a jobs-mapped `bunx tsc --noEmit` guard carries with `n/a`; the newer-gstack message; `--simulate` writes and clears nothing and the branch's own map never carries; `carried` drops a row another base mapped. Six of these fail without the change. - `test/ci-tree-reuse.test.ts` and `test/ship-ci-carry.test.ts` (GST-1190 skipped files): the writer lists only fully skipped files and leaves the list out without complete counts; a fully skipped inventory file stops the lane carry, an empty list or older `tree.json` carries, and the tests stage runs only the skipped selected file. All three fail without the change. - `test/ship-ci-carry.test.ts` (path-skipped jobs and `find --simulate`): a skip under an unchanged, green gate is not owed; a changed workflow, a changed gate script or a failed gate job runs the lane here; `find --policy-from head --simulate` prints only SIMULATION lines and writes nothing. All five fail without the change. +- `test/ship-ci-carry.test.ts` (review fixes): a gate script behind `${{ github.workspace }}`, under `working-directory` or behind a package script is compared; the gate is the job, not a same-named step; only a `pull_request` run's skip counts; a skippable profile must declare `scripts`; `passed` never counts a CI row; a path-skipped guard says not owed; `--simulate` runs a branch inventory only with `--allow-repo-commands`. Eight fail without the fixes; a newer-failure veto test covers existing behavior. +- `test/ship-ci-carry.test.ts` (per-file lane carry): the covered file carries and only the skipped file runs through `per_file`, a failing remainder records no CI row and runs the lane whole, and `per_file` needs `{files}` and an inventory entry. All three fail without the change. diff --git a/docs/pregate.md b/docs/pregate.md index 136dc54b3..4c81c318e 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -711,12 +711,18 @@ accepts, every job that must have passed (each exactly once), and the artifact holding the run's `tree.json`. `max_age_hours` (1–168, default 24) bounds how old the run may be, and `os` (`Linux`, `macOS`, `Windows`) requires `tree.json` to record that runner OS. `skippable` -(`{ jobs, gate, scripts? }`, GST-1190) lets named jobs be skipped by a path -filter: a skipped job counts only when the `gate` job (the one whose output -decides the skip) succeeded in the same attempt, and the workflow file plus -every repo file the gate job runs are byte-identical to `origin/`. Those -files are the declared `scripts` and every path the gate job's steps name in the -base copy of the workflow. Otherwise the pull request controlled the skip +(`{ jobs, gate, scripts }`, GST-1190) lets named jobs be skipped by a path +filter: a skipped job counts only in a `pull_request` run (whose gate diffs +against the pull request's base; a push or dispatch gate diffs against the last +push, which the branch's push history controls), when the `gate` job (the one +whose output decides the skip) succeeded in the same attempt, and the workflow +file plus the gate's inputs are byte-identical to `origin/`. Those inputs +are the declared `scripts`, which must list every repo file the gate reads, +plus every path the gate job's steps name in the base copy of the workflow +(also read under the job's `working-directory` and after a `${{ … }}` +prefix), plus `package.json` when the gate runs a package script. gstack +cannot see what a gate script imports or what a package script runs, so list +those files in `scripts`. Otherwise the pull request controlled the skip decision, so the job refuses as `job_skipped` and the lane runs here. A lane whose every named job was skipped this way prints `LANE: