From 010df26ac60e806bb0e22b65ddac855c28100d80 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 06:26:58 +0000 Subject: [PATCH] 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> --- bin/gstack-ci-carry | 20 ++++++--- bin/gstack-evidence | 5 ++- changelog.d/gsta258-l6-guard-carry.md | 4 ++ docs/pregate.md | 32 ++++++++++---- lib/ci-carry.ts | 11 +++-- lib/pregate/index.ts | 2 +- lib/pregate/run-check.ts | 35 +++++++++++++++- lib/run-evidence.ts | 37 +++++++++++++---- lib/ship-bin-common.ts | 6 ++- test/ship-ci-carry.test.ts | 60 +++++++++++++++++++++++++++ 10 files changed, 182 insertions(+), 30 deletions(-) diff --git a/bin/gstack-ci-carry b/bin/gstack-ci-carry index 398517753..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 @@ -15,7 +16,7 @@ */ import { spawnSync } from 'node:child_process'; import { findCarry, laneMapping, mappingCoverage, waitForRun } from '../lib/ci-carry'; -import { isConfigUnavailable, loadConfigFromBase } from '../lib/pregate/config'; +import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config'; import { buildRunCheck } from '../lib/pregate/run-check'; import { wtreeFingerprint } from '../lib/script-invocation'; import { resolveBaseBranch } from '../lib/ship-policy'; @@ -24,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.`; @@ -36,23 +39,28 @@ 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 ? 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}']); diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 99999da9d..6e70f630f 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -133,6 +133,8 @@ interface EvidenceRecord { 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; } /** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */ @@ -705,10 +707,11 @@ 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 } : {}), + 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 } : {}), }; 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}`); + if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`); return 0; } diff --git a/changelog.d/gsta258-l6-guard-carry.md b/changelog.d/gsta258-l6-guard-carry.md index 934efe46b..7ac874ea9 100644 --- a/changelog.d/gsta258-l6-guard-carry.md +++ b/changelog.d/gsta258-l6-guard-carry.md @@ -11,8 +11,12 @@ - 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 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. +- `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/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. diff --git a/docs/pregate.md b/docs/pregate.md index 63a537434..136dc54b3 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -88,7 +88,7 @@ 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 }` or `{ profiles, jobs }`, with optional `aliases`: 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)) | @@ -710,7 +710,16 @@ 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 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 +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: