From d2e63181d7df5870370ef68f161a8e2bd294b166 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 06:14:50 -0800 Subject: [PATCH] v1.94.17.0 ci-carry: guard carry, jobs-mapped lanes, aliases, per-file carry of runner-skipped files, path-skipped jobs (GST-1186, GST-1189, GST-1190) (#3929) * 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> * feat(ci-carry): jobs-mapped lanes, aliases, narrow-lane counts, --simulate proof, policy-base check on carried (GST-1189, GST-1190) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci-carry): a file whose every test skipped on CI is not carried; tree.json records skipped_files (GST-1190) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ci-carry): path-skipped jobs count only under an unchanged gate; find --policy-from head --simulate (GST-1190) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: name the pre-push guards in Step 17 and the early push (moved from #3923 per GSTA-258) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci-carry: per-file lane carry for runner-skipped files; review fixes (gate inputs, passed ignores CI rows, simulate consent, pull_request-only skips, guard not-owed) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: Step 17 names the pre-push guards without a code this branch cannot print Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * pregate: gstack's bun run test lane runs its runner-skipped files with per_file Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci-carry: a per_file remainder must run tests here and the carried runs must still stand (l6c-p2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci-carry: read record-ci from its own last record line; the remainder's count must come from this run's row (l6c-p2 red-team) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ci-tree-reuse): type the skipped_files fixture for the test typecheck ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Revert "pregate: gstack's bun run test lane runs its runner-skipped files with per_file" This reverts commit fafbae5b9bae1a07065bd0706bc6bf5f41a6ef09. * ci-carry: ship the skipped_files reader inert; gstack's writer and per_file move to the adoption PR; Step 17 keeps its refs/heads marker Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: guard carry, per_file and the too-old message in pregate and troubleshooting (docs audit l6cdocs2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * gstack-evidence record-ci: recheck the working tree after the post-remainder run recheck (#3929 second fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * windows-curation: exclude test/ship-ci-carry.test.ts like its ship-bin-fixture siblings (the path-scoped Windows check runs it at its first spawn) 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> --- CHANGELOG.md | 27 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-ci-carry | 29 +- bin/gstack-doctor-components.sh | 1 + bin/gstack-evidence | 113 ++++- bin/gstack-ship-receipt | 8 +- docs/pregate.md | 191 +++++++- docs/troubleshooting.md | 17 + lib/ci-carry.ts | 111 ++++- lib/pregate/checks/guards.ts | 41 +- lib/pregate/checks/lanes.ts | 7 +- lib/pregate/config.ts | 37 +- lib/pregate/index.ts | 2 +- lib/pregate/run-check.ts | 51 +- lib/result-codes-release.ts | 5 + lib/run-evidence.ts | 57 ++- lib/ship-bin-common.ts | 22 +- lib/ship-preflight.ts | 44 ++ package.json | 2 +- scripts/lib/windows-curation.ts | 4 + ship/SKILL.md | 2 +- ship/SKILL.md.tmpl | 2 +- ship/sections/tests.md | 2 +- ship/sections/tests.md.tmpl | 2 +- test/ci-carry.test.ts | 6 +- test/evidence-reuse.test.ts | 5 +- test/fixtures/golden/agy-ship-SKILL.md | 2 +- test/fixtures/golden/claude-ship-SKILL.md | 2 +- test/fixtures/golden/codex-ship-SKILL.md | 2 +- test/fixtures/golden/factory-ship-SKILL.md | 2 +- test/helpers/fake-gh-runs.ts | 9 +- test/run-evidence.test.ts | 3 +- test/ship-ci-carry.test.ts | 462 +++++++++++++++++- .../ship/gsta258-l6-guard-carry.json | 4 + 35 files changed, 1144 insertions(+), 134 deletions(-) create mode 100644 test/size-ledger/ship/gsta258-l6-guard-carry.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee6eb368..17e5ed851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [1.94.17.0] - 2026-11-03 + +- 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:`, 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). A `tree.json` that lists such files in `skipped_files` makes the pregate tests stage run 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. The CI row is written only after that run passes with a test count above zero and the carried runs still stand (`skipped_failed`, `skipped_zero_run` or `run_changed` 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. gstack itself changes nothing yet: its own writer and a `per_file` entry for its `bun run test` lane switch on together in a follow-up pull request, after the pull requests now queued on older gstack have landed, because an older gstack rejects the new key in main's policy. + +- 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 +- `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. +- `test/ship-ci-carry.test.ts`: a lane mapped by `jobs` carries when every named job passed at the same tree with no test files, and refuses for a missing job, a skipped job, a rerun whose newest attempt skipped the job, and another tree's run. Those five fail without the change. +- `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/ship-ci-carry.test.ts` (GST-1190 skipped files): 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. Both 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 or zero-test remainder, or a run that moved to a new attempt meanwhile, records no CI row and runs the lane whole, and `per_file` needs `{files}` and an inventory entry. All five fail without the change. + ## [1.94.16.0] - 2026-11-03 **A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1). diff --git a/VERSION b/VERSION index e1438391e..54c4aa075 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.16.0 +1.94.17.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 18e81912e..be1ec0e3d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.16.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.17.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-ci-carry b/bin/gstack-ci-carry index c29156107..c5292f5eb 100755 --- a/bin/gstack-ci-carry +++ b/bin/gstack-ci-carry @@ -3,6 +3,7 @@ * gstack-ci-carry — CI as the one full test run (GSTA-258; docs/pregate.md#ci-carry). * * gstack-ci-carry find [--base ] [--profile ]... [--command --allow-repo-commands] [--json] + * [--policy-from head --simulate] * gstack-ci-carry wait [--base ] [--profile ] [--timeout ] [--interval ] * * find: the newest verified run of each profile (default: every profile the base branch's @@ -14,10 +15,9 @@ * carried, 3 still running or no run. Logic: lib/ci-carry.ts; the run checks are lib/run-evidence.ts. */ import { spawnSync } from 'node:child_process'; -import { findCarry, waitForRun } from '../lib/ci-carry'; -import { isConfigUnavailable, loadConfigFromBase } from '../lib/pregate/config'; +import { findCarry, laneMapping, mappingCoverage, waitForRun } from '../lib/ci-carry'; +import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config'; import { buildRunCheck } from '../lib/pregate/run-check'; -import { runInventory } from '../lib/run-evidence'; import { wtreeFingerprint } from '../lib/script-invocation'; import { resolveBaseBranch } from '../lib/ship-policy'; @@ -25,11 +25,13 @@ const HELP = `gstack-ci-carry — use a verified CI run at HEAD's exact tree ins find [--base ] [--profile ]... [--command --allow-repo-commands] [--json] CI_CARRY: accepted (exit 0) or CI_CARRY: none () (exit 1) + --policy-from head --simulate: the same check under the branch's own map and profiles, every line starting + SIMULATION:; a dry run that proves a new mapping before it lands (find never writes evidence; nothing clears) wait [--base ] [--profile ] [--timeout ] [--interval ] poll the newest run at HEAD (default 1800 s, every 20 s): CI_WAIT: green|red|running|none, then find on green; exit 0 carried, 1 red or not carried, 3 still running or no run -Profiles and ship_lanes_from come from origin/'s .gstack/pregate.json only. Only a clean, committed tree +Profiles and ship_lanes_from come from origin/'s .gstack/pregate.json only (a simulation reads the branch's). Only a clean, committed tree carries; fork runs, stale runs, a newer red or running run on HEAD and a tree.json that misses an inventory file never do. Exit 2 is usage.`; @@ -37,30 +39,35 @@ const argv = process.argv.slice(2); const cmd = argv[0]; const all = (n: string) => argv.flatMap((a, i) => (a === n && argv[i + 1] !== undefined ? [argv[i + 1]!] : [])); const flag = (n: string) => all(n).at(-1); -const out = (s: string) => process.stdout.write(`${s}\n`); +const simulate = argv.includes('--simulate'); +const out = (s: string) => process.stdout.write(`${simulate ? 'SIMULATION: ' : ''}${s}\n`); const usage = (m: string) => { process.stderr.write(`gstack-ci-carry: ${m}\n`); process.exit(2); }; if (!cmd || argv.includes('--help') || argv.includes('-h')) { out(HELP); process.exit(cmd ? 0 : 2); } if (cmd !== 'find' && cmd !== 'wait') usage(`unknown command ${cmd}; run gstack-ci-carry --help`); +const policyFrom = flag('--policy-from') ?? 'base'; +if (policyFrom !== 'base' && policyFrom !== 'head') usage('--policy-from needs base or head'); +if (simulate !== (policyFrom === 'head') || (simulate && cmd !== 'find')) usage('find --policy-from head --simulate go together: a branch\'s own map is only ever simulated, never carried'); const git = (args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : ''; }; const root = git(['rev-parse', '--show-toplevel']); if (!root) usage('not inside a git repository'); const base = resolveBaseBranch(root, flag('--base')); -const loaded = loadConfigFromBase(root, base); -if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: origin/${base}'s .gstack/pregate.json is unreadable or invalid)`); process.exit(1); } +const loaded = simulate ? loadConfigFromHead(root) : loadConfigFromBase(root, base); +if (simulate) out(`policy ${isConfigUnavailable(loaded) ? 'unavailable' : loaded.label}; a dry run: nothing is written and nothing clears (a carry reads origin/${base}'s map only)`); +if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: ${simulate ? 'the branch\'s' : `origin/${base}'s`} .gstack/pregate.json is unreadable or invalid)`); process.exit(1); } const command = flag('--command'); -const mapping = command !== undefined ? loaded.config.ship_lanes_from[command] : undefined; -if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is not a key of origin/${base}'s ship_lanes_from)`); process.exit(1); } +const mapping = command !== undefined ? laneMapping(loaded.config.ship_lanes_from, command) : undefined; +if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is neither a key nor a declared alias in origin/${base}'s ship_lanes_from)`); process.exit(1); } if (mapping && !argv.includes('--allow-repo-commands')) usage('--command runs the mapping\'s inventory, a pin-file command: pass --allow-repo-commands'); const profiles = all('--profile').length ? all('--profile') : mapping ? mapping.profiles : [...new Set(Object.values(loaded.config.ship_lanes_from).flatMap(m => m.profiles))]; if (!profiles.length) { out(`CI_CARRY: none (no_profile: origin/${base} maps no lane in ship_lanes_from; pass --profile )`); process.exit(1); } -const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base); +const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base, simulate ? 'head' : 'base'); if ('error' in check) { out(`CI_CARRY: none (profiles_unavailable: ${check.error})`); process.exit(1); } const head = git(['rev-parse', 'HEAD']); const tree = git(['rev-parse', 'HEAD^{tree}']); function find(): number { const wtree = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; }; - const v = findCarry(check as Exclude, { profiles, head, tree, wtree, ...(mapping ? { inventory: () => runInventory(mapping.inventory, root) } : {}) }); + const v = findCarry(check as Exclude, { profiles, head, tree, wtree, ...(mapping ? mappingCoverage(mapping, root) : {}) }); if (argv.includes('--json')) out(JSON.stringify(v)); else out(v.ok ? `CI_CARRY: accepted ${v.receipt}` : `CI_CARRY: none (${v.reason}: ${v.detail})`); return v.ok ? 0 : 1; diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 23db8f80d..4ec7b9a61 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -258,6 +258,7 @@ gstack_result_anchor() { DX_CAPTURE_REDACTION_FAILED) printf '%s' 'dx-capture-redaction-failed' ;; DX_CAPTURE_INTERNAL) printf '%s' 'dx-capture-internal' ;; POLICY_GSTACK_TOO_OLD) printf '%s' 'policy-gstack-too-old' ;; + PREGATE_GSTACK_TOO_OLD) printf '%s' 'pregate-gstack-too-old' ;; QUEUE_NOT_READY) printf '%s' 'queue-not-ready' ;; QUEUE_PROOF_MISSING) printf '%s' 'queue-proof-missing' ;; QUEUE_STAMP_NOT_STAMP_ONLY) printf '%s' 'queue-stamp-not-stamp-only' ;; diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 8fc3e692b..e3d2bbab1 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -5,7 +5,8 @@ * * gstack-evidence run [--reuse [--max-age ]] --label -- * gstack-evidence record-ci --label --base -- - * gstack-evidence carried [--json] + * gstack-evidence passed -- + * gstack-evidence carried [--base ] [--json] * gstack-evidence check [--label [--expect-cmd ]]... | --all * [--max-age ] [--allow-paths ] * gstack-evidence bundle --out [--label ]... [--require ] [--selection L=]... @@ -65,8 +66,11 @@ * `record-ci` writes the explicit CI-sourced row for a lane CI carried: it * finds and verifies the run itself (lib/ci-carry.ts) for a command the base's * ship_lanes_from maps, then records source "ci", the runs and profiles, the - * run's finish time as ts, bound to HEAD's clean tree; `carried` lists those - * rows at the current tree for the ship receipt. + * run's finish time as ts, bound to HEAD's clean tree, and the policy that + * mapped it (`origin/@:…`); `carried` lists those rows at the + * current tree for the ship receipt, only when that policy is origin/ (GST-1190: a row a run against another base wrote never + * counts). * * MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced. * A synced record citing an unsynced log would grade FRESH on a machine where @@ -90,10 +94,10 @@ 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 } from "../lib/ci-carry"; +import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ci-carry"; +import { runChanged } from "../lib/run-evidence"; import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config"; import { buildRunCheck } from "../lib/pregate/run-check"; -import { runInventory } from "../lib/run-evidence"; const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url)); const LOG_MAX_BYTES = 2 * 1024 * 1024; @@ -122,10 +126,17 @@ interface EvidenceRecord { source?: "ci"; runs?: string[]; profiles?: string[]; + /** The CI jobs a jobs-mapped lane carried from (record-ci, GST-1189); tests_ran is then "n/a". */ + jobs?: string[]; /** The base policy that mapped a carried lane (record-ci), as `origin/@:.gstack/pregate.json`. */ policy?: string; /** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */ - tests_ran?: number | "unknown"; + tests_ran?: number | "unknown" | "n/a"; + /** "files" when a carried narrow lane's tests_ran counts its inventory files the run covered (record-ci), not tests. */ + 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). */ @@ -598,6 +609,36 @@ 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 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.identity === identityDigest(); +} + +/** + * `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("--"); + 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 && 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=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 +653,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) { @@ -629,9 +667,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; }; @@ -651,17 +691,33 @@ function cmdRecordCi(argv: string[]): number { const loaded = loadConfigFromBase(cwd, base); if (isConfigUnavailable(loaded)) return none("config_unavailable", loaded.detail); if (loaded.errors.length) return none("config_invalid", loaded.errors.map((x) => `${x.path}: ${x.message}`).join("; ")); - const from = loaded.config.ship_lanes_from; - if (!Object.prototype.hasOwnProperty.call(from, command)) return none("not_mapped", `the command is not a key of origin/${base}'s ship_lanes_from`); - const m = from[command]!; + const m = laneMapping(loaded.config.ship_lanes_from, command); + if (!m) return none("not_mapped", `the command is neither a key nor a declared alias in origin/${base}'s ship_lanes_from`); const check = buildRunCheck(cwd, null, true, base); if ("error" in check) return none("profiles_unavailable", check.error); const head = git(["rev-parse", "HEAD"]); const tree = git(["rev-parse", "HEAD^{tree}"]); if (!head || !tree) return none("no_head", "HEAD is unreadable"); 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, inventory: () => runInventory(m.inventory, cwd) }); + 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 hereCommand = perFileCommand(m.per_file!, v.remainder); + const exit = await cmdRun(["--label", hereLabel, "--", hereCommand]); + if (exit !== 0) return none("skipped_failed", `the per_file run of ${v.remainder.length} skipped file(s) exited ${exit} (label=${hereLabel})`); + 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 && r.cmd_sha256 === sha256(hereCommand) && r.wtree === tree)?.tests_ran ?? "unknown" }; + if (typeof here.tests_ran !== "number" || here.tests_ran <= 0) return none("skipped_zero_run", `the per_file run of ${v.remainder.length} skipped file(s) ran ${here.tests_ran} tests here (label=${hereLabel}); a lane passes on its runner count, never exit 0 alone`); + for (const e of v.runs) { + const changed = runChanged(check.deps, e.run_id, e.run_attempt); + if (changed) return none("run_changed", changed); + } + if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`); + } let paths: ReturnType; try { paths = ledgerPath(); @@ -673,15 +729,22 @@ 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, + 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} 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; } -/** `carried`: each label's newest row when it is a CI-sourced row at the current working tree, as the receipt's ci_carried entries. */ +/** + * `carried`: each label's newest row when it is a CI-sourced row at the current working tree whose mapping policy is + * origin/ (the receipt's base; default the resolved base branch), as the receipt's ci_carried entries. + */ function cmdCarried(argv: string[]): number { + const at = argv.indexOf("--base"); + if (at >= 0 && !argv[at + 1]) { console.error("usage: gstack-evidence carried [--base ] [--json]"); return 2; } + const policyRef = `origin/${(at >= 0 ? argv[at + 1]! : resolveBaseBranch(git(["rev-parse", "--show-toplevel"]) ?? process.cwd())).replace(/^origin\//, "")}@`; let records: EvidenceRecord[] = []; try { records = readJsonl(ledgerPath().file); @@ -691,7 +754,7 @@ function cmdCarried(argv: string[]): number { const wtree = currentWtree(); const latest = new Map(); for (const r of records) latest.set(r.label, r); - const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs)) + const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef)) .flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; })); if (argv.includes("--json")) console.log(JSON.stringify(carried)); else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`); @@ -756,10 +819,10 @@ function cmdCheck(argv: string[]): number { allFresh = false; continue; } - const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown"; + const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown"; const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : []; const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : ""; - const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`; + const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan}${latest.tests_unit === "files" ? " unit=files" : ""} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`; let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH"; let reason = ""; if (latest.exit !== 0) { @@ -911,7 +974,9 @@ 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") { process.exit(cmdCarried(rest)); } else if (sub === "check") { @@ -923,7 +988,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/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index e6c01c637..15f1a15f3 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -175,9 +175,9 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree }; } -/** The lanes CI carried at the checked-out tree, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */ -function ciCarried(): ShipReceipt['ci_carried'] { - const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--json'], { encoding: 'utf8', timeout: 60_000 }); +/** The lanes CI carried at the checked-out tree under origin/'s mapping, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */ +function ciCarried(base: string): ShipReceipt['ci_carried'] { + const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--base', base, '--json'], { encoding: 'utf8', timeout: 60_000 }); try { const list = JSON.parse(r.stdout); return Array.isArray(list) && list.length ? list : undefined; } catch { return undefined; } } @@ -271,7 +271,7 @@ function cmdWrite(rest: string[], json: boolean): number { version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base, gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined, tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined, - gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(), + gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base), spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined, session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), diff --git a/docs/pregate.md b/docs/pregate.md index a6b692927..f0f46da40 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -88,9 +88,9 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) | | `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs | | `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) | -| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) | +| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os?, skippable? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) | | `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) | -| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }`: the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and the command listing its test files, which their runs must cover (see [CI carry](#ci-carry)); read from the base branch only | +| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }` or `{ profiles, jobs }`, with optional `aliases` (and, on an inventory entry, an optional `per_file` runner for files CI skipped entirely, see [skipped files](#skipped-files)): the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and either the command listing its test files, which their runs must cover, or the CI jobs that must have succeeded (see [CI carry](#ci-carry)); read from the base branch only | | `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) | Only the `origin/` copy or the user-owned file below is honoured, so a @@ -259,7 +259,8 @@ Two stages with separate clocks, both printed: `--allow-repo-commands` the selected guards are listed and the check is `incomplete`. Guards are read from the base branch's copy (or the user-owned file when the base carries none) and cannot be - downgraded to warn. See [guards](#guards). + downgraded to warn. A guard whose exact command already passed at this tree + is not run again ([guard carry](#guard-carry)). See [guards](#guards). **Stage 2, test execution:** @@ -710,7 +711,22 @@ other gate change. Each profile names the workflow file, the events it 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. Each workflow uploads `tree.json` from +`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 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: