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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 07:12:28 +00:00
1 parent 9e95a7e362
commit 864c3ab88a
10 files changed
+273 -81

No files matched your search

+29 -14
View File
@@ -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 -- <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.
* `passed -- <cmd>` (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=<L>
* source=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("--");
@@ -629,10 +631,10 @@ function cmdPassed(argv: string[]): number {
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; }
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 <L>.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<number> {
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<EvidenceRecord>(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<typeof ledgerPath>;
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") {
+6 -4
View File
@@ -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: <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.
- 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 local ledger pass of that command at the same working tree stands (CI rows count only through a live `record-ci` check) (`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.
- 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:<command>` 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 >= <version>; 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/<base>` byte for byte; the lane then prints `not owed: CI skipped at this tree (gate job <name>, 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/<base>` byte for byte; the lane then prints `not owed: CI skipped at this tree (gate job <name>, 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.
+36 -15
View File
@@ -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. <a id="skippable"></a>`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/<base>`. 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/<base>`. 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: <label> not owed: CI
skipped at this tree (gate job <name>, workflow unchanged) run=…`. Each workflow uploads `tree.json` from
@@ -749,8 +755,16 @@ once:
<a id="skipped-files"></a>`skipped_files`, when present, lists the files whose every test skipped on
the runner (a Docker image, a drive path or an installed gstack it lacks;
GST-1190). A carry treats them as uncovered: the pregate tests stage runs
them here, and a /ship lane or guard whose inventory names one runs here
whole. A partly skipped file ran tests and stays covered. gstack's own writer
them here. A /ship lane or guard whose inventory names one carries the
covered files and runs only the skipped ones here, through the runner its
`ship_lanes_from` entry declares as `per_file` (a command with `{files}`,
such as `"per_file": "bun test --timeout=30000 {files}"`, the same form as the
tests stage's `runner`). That run is recorded under `<label>.skipped`, the
`LANE:` line adds `skipped_here=<n> file(s) tests_ran_here=<n>`, and the CI row
is written only after it passes; a failure there is `skipped_failed` and the
whole lane runs here. An entry with no `per_file` refuses as
`inventory_skipped`, naming the files, and the lane runs here whole. A partly
skipped file ran tests and stays covered. gstack's own writer
(`scripts/ci-tree-reuse.ts record`) derives the list from the shards' per-file
JUnit counts and leaves it out when those counts are incomplete. A `tree.json`
without the key (older writers, other repos) is read as before, with every
@@ -1078,10 +1092,13 @@ against the verified CI run at this tree exactly as a carry would, and prints
`SIMULATION: CI_CARRY: none (<reason>)`). It writes no evidence or ledger row
and clears no obligation. To check every /ship lane at once, the same dry run
takes lane flags: `gstack-ship-preflight --tests-only --base main --policy-from
head --simulate --lane 'verify=bun run verify' …` prints `SIMULATION: LANE:
head --simulate --allow-repo-commands --lane 'verify=bun run verify' …` prints `SIMULATION: LANE:
<label> would carry run=…`, `SIMULATION: CI_CARRY: none label=<label> (<reason>)`
or `not mapped` per lane, runs no lane and no pregate, and exits 0 when every
mapped lane would carry. truther's adoption proved its map this way by hand
mapped lane would carry. Both dry runs run the branch's inventory commands to
check coverage, which is running the branch's code, so both need
`--allow-repo-commands`; without it an inventory-mapped lane prints
`inventory_not_allowed`. truther's adoption proved its map this way by hand
before the dry run existed: run 41585332512 accepted 7 of 7 lanes. A branch's own map never carries,
even when the base has no policy at all, because the pull request controls both
the map and the CI that would satisfy it. So the first pull request that adopts
@@ -1200,12 +1217,16 @@ A guard is skipped as passed when its exact command already passed at this tree
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
runs=<id>/<attempt>`, or `GUARD: <id> not owed: CI skipped at this tree …`
when every job it maps was skipped by an unchanged gate
([skippable](#skippable)). 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>`.
2. **The evidence ledger.** When the newest local ledger row for the exact
command at this working tree, under any label, is a counted pass (`tests_ran`
above zero under the runtime the guard would launch with, within 24 hours),
the guard prints `GUARD: <id> reused label=<L> source=local ts=<ts>`. CI rows
never count here: a CI pass counts only through `record-ci`'s live check
against the base, so an old CI row does not outlive a refusal.
`gstack-evidence passed -- <command>` is the same lookup by hand. A newer
failure of the command at that tree vetoes an older pass.
+23 -14
View File
@@ -30,14 +30,15 @@
import { normalPath, runChanged, runInventory, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence';
export type CarryRefusal = 'dirty_tree' | 'no_profile' | 'no_run' | 'no_verified_run' | 'tree_mismatch' | 'no_tests'
| 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'run_changed' | 'tree_changed' | 'api_error';
| 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'inventory_skipped' | 'run_changed' | 'tree_changed' | 'api_error';
export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string };
/**
* `tests` never claims a whole run's count for a narrow lane: with an inventory it is the number of the lane's inventory files
* the runs' tree.json covers (`unit: 'files'`; tree.json has no per-file test counts yet), with named jobs `n/a`, and only
* without either (the whole run carried) the runs' test count.
* without either (the whole run carried) the runs' test count. `remainder`: inventory files every run skipped entirely
* (tree.json `skipped_files`), which the caller must run here with the lane's per_file runner before the lane counts.
*/
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; receipt: string } | CarryRefused;
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; remainder?: string[]; receipt: string } | CarryRefused;
/** How many of HEAD's runs per workflow are tried, newest first. */
const CANDIDATES = 10;
@@ -63,9 +64,10 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
* working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover, or `jobs`
* names the jobs that must have succeeded in each run's newest attempt (a lane with no test files; no test count needed).
*/
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[] }): CarryVerdict {
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean }): CarryVerdict {
const jobs = o.jobs ?? [];
let covered: number | undefined;
let remainder: string[] = [];
const before = o.wtree();
if (before !== o.tree) return no('dirty_tree', `the working tree (${before ? before.slice(0, 12) : 'fingerprint unavailable'}) is not HEAD's committed tree ${o.tree.slice(0, 12)}; only a clean, committed tree carries`);
if (!o.profiles.length) return no('no_profile', 'no run profile is named');
@@ -86,10 +88,12 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
const listed = o.inventory();
if ('error' in listed) return no('inventory_failed', listed.error);
if (!listed.length) return no('inventory_empty', 'the inventory command printed no files');
const missing = uncoveredFiles(listed, runs, 'uncovered');
const skippedThere = missing.filter(f => runs.some(e => e.skipped?.includes(normalPath(f)))).length;
if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json${skippedThere ? ` or skipped every test there (${skippedThere} skipped_files)` : ''}: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`);
covered = new Set(listed.map(normalPath)).size;
const absent = uncoveredFiles(listed, runs, 'covered');
const some = (fs: string[]) => `${fs.slice(0, 5).join(', ')}${fs.length > 5 ? ', …' : ''}`;
if (absent.length) return no('inventory_uncovered', `${absent.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${some(absent)}`);
remainder = uncoveredFiles(listed, runs, 'uncovered');
if (remainder.length && !o.perFile) return no('inventory_skipped', `${remainder.length} of ${listed.length} inventory file(s) skipped every test in CI and the lane declares no per_file runner: ${some(remainder)}`);
covered = new Set(listed.map(normalPath)).size - remainder.length;
}
for (const e of runs) {
const changed = runChanged(src.deps, e.run_id, e.run_attempt);
@@ -106,7 +110,7 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
const notOwed = allSkipped ? `CI skipped at this tree (gate job ${gates.join(', ')}, workflow unchanged)` : undefined;
return { ok: true, runs, tests: 'n/a', ...(notOwed ? { notOwed } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} jobs=${jobs.join(',')}${notOwed ? ` not_owed=${gates.join(',')}` : ''} ${runReceiptFields(runs, src.now)}` };
}
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered} ${runReceiptFields(runs, src.now)}` };
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', ...(remainder.length ? { remainder } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered}${remainder.length ? ` skipped_here=${remainder.length}` : ''} ${runReceiptFields(runs, src.now)}` };
const tests = runs.reduce((n, e) => n + (e.tests ?? e.files.length), 0);
return { ok: true, runs, tests, receipt: `ci-carry tree=${o.tree.slice(0, 12)} tests=${tests} ${runReceiptFields(runs, src.now)}` };
}
@@ -133,7 +137,7 @@ export async function waitForRun(deps: RunDeps, o: { workflow: string; events: s
* `ship_lanes_from` (base branch only): exact lane command → the profiles whose runs carry it and either the command listing
* its test files (`inventory`) or the CI jobs that run it (`jobs`, for a lane with no test files such as a typecheck; GST-1189).
*/
export interface ShipLaneMapping { profiles: string[]; inventory?: string; jobs?: string[]; aliases?: string[] }
export interface ShipLaneMapping { profiles: string[]; inventory?: string; jobs?: string[]; aliases?: string[]; per_file?: string }
/**
* The `ship_lanes_from` entry for an exact lane command: the entry it keys, else the entry that declares it in `aliases`
@@ -145,9 +149,13 @@ export function laneMapping(from: Record<string, ShipLaneMapping>, command: stri
return Object.values(from).find(m => m.aliases?.includes(command));
}
/** findCarry's coverage rule for one mapping: its inventory (a pin-file command run in `cwd`) or its named jobs. */
export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[] } =>
m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd) };
/** findCarry's coverage rule for one mapping: its inventory (a pin-file command run in `cwd`, with a per_file runner or not) or its named jobs. */
export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean } =>
m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd), perFile: !!m.per_file };
/** The per_file runner command for `files`: `{files}` replaced by the paths, each single-quoted for bash. */
export const perFileCommand = (template: string, files: string[]): string =>
template.replaceAll('{files}', files.map(f => `'${f.replace(/'/g, `'\\''`)}'`).join(' '));
/** `<path>: <message>` errors for `ship_lanes_from`; empty when valid. Shared with lib/pregate/config.ts. */
export function shipLanesErrors(raw: unknown, profileNames: readonly string[]): Array<{ path: string; message: string }> {
@@ -161,7 +169,8 @@ export function shipLanesErrors(raw: unknown, profileNames: readonly string[]):
if (!oneLine(command)) errors.push({ path: at, message: 'the key must be the exact lane command: one line under 512 characters' });
if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push({ path: at, message: 'must be { profiles, inventory } or { profiles, jobs }' }); continue; }
const o = m as Record<string, unknown>;
for (const k of Object.keys(o)) if (k !== 'profiles' && k !== 'inventory' && k !== 'jobs' && k !== 'aliases') errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory, jobs, aliases' });
for (const k of Object.keys(o)) if (!['profiles', 'inventory', 'jobs', 'aliases', 'per_file'].includes(k)) errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory, jobs, aliases, per_file' });
if ('per_file' in o && (!oneLine(o.per_file) || !(o.per_file as string).includes('{files}') || !('inventory' in o))) errors.push({ path: `${at}.per_file`, message: 'must be one command line under 512 characters containing {files}, on an entry with an inventory (it runs the inventory files CI skipped entirely)' });
if ('aliases' in o) {
const aliases = o.aliases;
if (!Array.isArray(aliases) || !aliases.length || aliases.some(a => !oneLine(a)) || new Set(aliases).size !== aliases.length) errors.push({ path: `${at}.aliases`, message: 'must be a non-empty array of distinct exact lane commands, each one line under 512 characters' });
+9 -5
View File
@@ -37,14 +37,18 @@ interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stall
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 });
/**
* The pass that already covers this guard's exact command at this tree (GST-1186): a CI carry (or a CI skip the unchanged gate
* decided, printed as not owed), else a same-tree local ledger pass under the runtime the guard would launch with.
*/
function priorPass(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv): { prior?: string; carryNone?: string } {
const evidence = (args: string[]) => runScriptSync(EVIDENCE, args, { cwd: ctx.repoRoot, env, encoding: 'utf8', timeout: 600_000 });
let carryNone: string | undefined;
if (laneMapping(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}` };
const notOwed = /^gstack-evidence: not owed: (.+)$/m.exec(String(ci.stderr ?? ''))?.[1];
if (ci.status === 0 && runs) return { prior: notOwed ? `not owed: ${notOwed} runs=${runs}` : `carried runs=${runs}` };
carryNone = /^CARRY_NONE: (.+)$/m.exec(String(ci.stderr ?? ''))?.[1] ?? `record-ci exit ${ci.status}`;
}
const local = evidence(['passed', '--', guard.command]);
@@ -80,7 +84,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
const dir = checkScratch(ctx.scratch, 'guards');
const runs: GuardRun[] = [];
for (const g of selected) {
const p = priorPass(ctx, g);
const p = priorPass(ctx, g, runtime.env);
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}`];
+11 -6
View File
@@ -20,9 +20,10 @@ import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfi
import type { RunCheck } from './index';
/**
* Null when a skippable profile's workflow and every repo file its gate job runs are byte-identical at HEAD and
* origin/<base> (GST-1190); else the first that differs. The files are the profile's declared `scripts` plus every path the
* gate job's block in the base workflow names that exists on either side, so a branch cannot move the skip decision.
* Null when a skippable profile's workflow, its declared `scripts` and every repo path its gate job names are byte-identical
* at HEAD and origin/<base> (GST-1190); else the first that differs. Named paths are read from the gate job's block in the base
* workflow (after `${{ … }}` prefixes, and also under each working-directory), plus package.json when the job runs a package
* script. Files the gate reaches indirectly (imports, a package script's own files) count only when `scripts` declares them.
*/
export function gateInputsChanged(root: string, base: string, p: RunProfile): string | null {
const ref = `origin/${base.replace(/^origin\//, '')}`;
@@ -39,10 +40,14 @@ export function gateInputsChanged(root: string, base: string, p: RunProfile): st
if (/^ [\w-]+:\s*$/.test(lines[i]!)) blocks.push([lines[i]!]);
else blocks.at(-1)?.push(lines[i]!);
}
const block = blocks.find(b => b[0]!.trim() === `${gate}:` || b.some(l => l.trim() === `name: ${gate}`));
const block = blocks.find(b => b[0]!.trim() === `${gate}:` || b.some(l => /^ name:/.test(l) && l.trim() === `name: ${gate}`));
if (!block) return `${wf} on ${ref} has no job ${gate}`;
const named = [...new Set([...block.join('\n').matchAll(/[\w.\/-]+/g)].map(m => m[0].replace(/^\.\//, '')).filter(t => t.includes('/') || /\.(sh|ts|js|mjs|cjs|py|rb)$/.test(t)))];
for (const f of [wf, ...(p.skippable!.scripts ?? []), ...named]) {
const text = block.join('\n');
const dirs = ['', ...[...text.matchAll(/working-directory:\s*['"]?([\w.\/-]+)/g)].map(m => `${m[1]!.replace(/^\.?\/+|\/+$/g, '')}/`)];
const tokens = [...text.matchAll(/[\w.\/-]+/g)].map(m => m[0]).filter(t => t.includes('/') || /\.(sh|ts|js|mjs|cjs|py|rb)$/.test(t));
const named = [...new Set(tokens.flatMap(t => dirs.map(d => path.posix.normalize(d + t.replace(/^\.?\/+/, ''))).filter(n => !n.startsWith('..'))))];
const pkg = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?[\w:-]+/.test(text) ? dirs.map(d => `${d}package.json`) : [];
for (const f of [wf, ...(p.skippable!.scripts ?? []), ...named, ...pkg]) {
const a = blob(ref, f);
const b = blob('HEAD', f);
if (a === null && b === null && f !== wf && !p.skippable!.scripts?.includes(f)) continue;
+6 -5
View File
@@ -43,10 +43,10 @@ import { sha256Hex, writeReceipt } from './egress-receipt';
import { runBashSync } from './script-invocation';
/**
* `skippable` (GST-1190): named jobs the workflow may skip by path; a skip counts only when the `gate` job succeeded and the
* workflow plus every repo file the gate job runs (`scripts` and any path its steps name) match origin/<base> byte for byte.
* `skippable` (GST-1190): named jobs the workflow may skip by path; a skip counts only in a pull_request run whose `gate` job
* succeeded, with the workflow, the declared `scripts` and any path the gate's steps name matching origin/<base> byte for byte.
*/
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string; skippable?: { jobs: string[]; gate: string; scripts?: string[] } }
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string; skippable?: { jobs: string[]; gate: string; scripts: string[] } }
export interface RemoteGateRuns { profiles: string[]; inventory: string; skipped?: string }
export interface ApiRun {
id: number; run_attempt: number; status: string; conclusion: string | null; event: string; head_sha: string;
@@ -114,8 +114,8 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
const names = (v: unknown) => Array.isArray(v) && v.length > 0 && v.length <= 32 && v.every(x => typeof x === 'string' && !!x.trim() && x.length <= 128);
const repoPath = (x: unknown) => typeof x === 'string' && !!x && !x.startsWith('/') && !x.split('/').includes('..') && !/[\0\r\n]/.test(x);
if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).some(k => !['jobs', 'gate', 'scripts'].includes(k)) || !names(s.jobs) || typeof s.gate !== 'string' || !s.gate.trim()
|| (s.jobs as string[]).includes(s.gate as string) || ('scripts' in s && (!Array.isArray(s.scripts) || !s.scripts.every(repoPath))))
errors.push({ path: `${at}.skippable`, message: 'must be { jobs: [job names], gate: <the job that decides the skip>, scripts?: [repo paths the gate runs] }, the gate not among the jobs' });
|| (s.jobs as string[]).includes(s.gate as string) || !Array.isArray(s.scripts) || !s.scripts.length || !s.scripts.every(repoPath))
errors.push({ path: `${at}.skippable`, message: 'must be { jobs: [job names], gate: <the job that decides the skip>, scripts: [every repo file the gate reads] }, the gate not among the jobs' });
}
}
}
@@ -174,6 +174,7 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
if (hits[0]!.conclusion === 'skipped') {
const skip = p.skippable;
if (!skip?.jobs.includes(name)) return fail('job_skipped', `${at}'s ${name} job was skipped`);
if (run.event !== 'pull_request') return fail('job_skipped', `${at}'s ${name} job was skipped in a ${run.event} run; only a pull_request run's gate diffs against the PR base, so only its skip counts`);
const gate = jobs.filter(j => j.name === skip.gate);
if (gate.length !== 1 || gate[0]!.conclusion !== 'success') return fail('job_skipped', `${at}'s ${name} job was skipped and its gate job ${skip.gate} ${gate.length === 1 ? `concluded ${gate[0]!.conclusion}` : gate.length ? 'is ambiguous' : 'is missing'}`);
const changed = o.unchanged ? o.unchanged(p) : 'no base copy of the workflow was compared';
+4 -3
View File
@@ -172,7 +172,7 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar
return cpus <= 4 ? 1 : Infinity;
}
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; notOwed?: string }
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; notOwed?: string; here?: string }
/** The base branch's `ship_lanes_from` entry for each lane whose command is a key or a declared alias, byte for byte (the ledger binds its sha256). */
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, ShipLaneMapping> {
@@ -192,7 +192,8 @@ export function carryLane(cwd: string, base: string, l: { label: string; command
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000);
const got = / tests_ran=(\d+|n\/a)( unit=files)? log=- source=ci runs=(\S+)/.exec(rec.stderr);
const notOwed = /^gstack-evidence: not owed: (.+)$/m.exec(rec.stderr)?.[1];
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[2] ? `${got[1]} (files)` : got[1]!, log: '-', carried: got[3]!, ...(notOwed ? { notOwed } : {}) };
const here = / skipped_here=(\d+) tests_ran_here=(\S+)$/m.exec(rec.stderr);
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[2] ? `${got[1]} (files)` : got[1]!, log: '-', carried: got[3]!, ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}` } : {}) };
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: /^CARRY_NONE: (.+)$/m.exec(rec.stderr)?.[1] ?? `record_failed: ${firstLine(rec.stderr)}` };
}
@@ -225,7 +226,7 @@ export async function runLanes(cwd: string, list: { label: string; command: stri
/** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone; a carried lane names its CI runs. */
export function laneLine(l: LaneResult): { line: string; ok: boolean } {
if (l.notOwed) return { line: `LANE: ${l.label} not owed: ${l.notOwed} run=${l.carried}`, ok: true };
if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}`, ok: true };
if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}${l.here ? ` ${l.here}` : ''}`, ok: true };
const zero = l.exit === 0 && l.testsRan === '0';
const state = l.exit !== 0 ? 'FAIL' : zero ? 'ZERO-RUN' : l.testsRan === 'unknown' ? 'pass (count unavailable)' : 'pass';
return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}${l.reused ? ` reused=${l.reused}` : ''}`, ok: l.exit === 0 && !zero };
+12 -9
View File
@@ -24,10 +24,11 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
--lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
gstack-ship-preflight --tests-only --base <base> --policy-from head --simulate --lane <label>=<command>...
gstack-ship-preflight --tests-only --base <base> --policy-from head --simulate [--allow-repo-commands] --lane <label>=<command>...
a dry run that proves a new ship_lanes_from mapping: which lanes the branch's own map would carry from a verified
CI run at this tree. Every line starts SIMULATION:; nothing runs, no evidence or ledger row is written and no
obligation clears (a real carry reads origin/<base>'s map only). Exit 0 when every mapped lane would carry, else 1
CI run at this tree. Every line starts SIMULATION:; no lane runs, no evidence or ledger row is written and no
obligation clears (a real carry reads origin/<base>'s map only). An inventory-mapped lane runs the branch's
inventory command, so it needs --allow-repo-commands. Exit 0 when every mapped lane would carry, else 1
gstack-ship-preflight --audit --base <base> --out <dir> the single reads and Steps 7-8's deterministic audits
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
@@ -175,16 +176,17 @@ async function tests(cwd: string, base: string, list: { label: string; command:
/**
* --simulate (GST-1190): HEAD's ship_lanes_from and run_profiles, findCarry against this tree, nothing else. It never calls
* gstack-evidence or gstack-pregate, so it writes no row and clears no obligation: a PR controls both its own map and the
* CI that would satisfy it, so head policy never carries.
* CI that would satisfy it, so head policy never carries. The branch's inventory commands run only with `allow`
* (--allow-repo-commands), as gstack-ci-carry find --simulate requires.
*/
function simulate(cwd: string, base: string, list: { label: string; command: string }[]): { text: string; exit: number } {
function simulate(cwd: string, base: string, list: { label: string; command: string }[], allow: boolean): { text: string; exit: number } {
const lines: string[] = [];
const say = (s: string) => lines.push(`SIMULATION: ${s}`);
const done = (exit: number) => ({ text: lines.join('\n') + '\n', exit });
const loaded = loadConfigFromHead(cwd);
say(`policy ${isConfigUnavailable(loaded) ? 'unavailable' : loaded.label}; a dry run: nothing runs, no evidence or ledger row is written, nothing clears (a carry reads origin/${base}'s map only)`);
say(`policy ${isConfigUnavailable(loaded) ? 'unavailable' : loaded.label}; a dry run: only the branch's inventory commands run, and only with --allow-repo-commands; no evidence or ledger row is written, nothing clears (a carry reads origin/${base}'s map only)`);
if (isConfigUnavailable(loaded) || loaded.errors.length) { say(`refused: ${isConfigUnavailable(loaded) ? loaded.detail : loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`); return done(1); }
const check = buildRunCheck(cwd, null, true, base, 'head');
const check = buildRunCheck(cwd, null, allow, base, 'head');
if ('error' in check) { say(`refused: ${check.error}`); return done(1); }
const head = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
const tree = git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim();
@@ -195,10 +197,11 @@ function simulate(cwd: string, base: string, list: { label: string; command: str
const m = laneMapping(loaded.config.ship_lanes_from, l.command);
if (!m) { say(`LANE: ${l.label} not mapped by the branch's ship_lanes_from; /ship would run it`); continue; }
mapped++;
if (m.inventory && !allow) { say(`CI_CARRY: none label=${l.label} (inventory_not_allowed: the branch's inventory command is a pin-file command; pass --allow-repo-commands)`); continue; }
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) });
if (!v.ok) { say(`CI_CARRY: none label=${l.label} (${v.reason}: ${v.detail})`); continue; }
carried++;
say(`LANE: ${l.label} would carry run=${v.runs.map(e => `${e.run_id}/${e.run_attempt}`).join(',')} tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ''}`);
say(`LANE: ${l.label} would carry run=${v.runs.map(e => `${e.run_id}/${e.run_attempt}`).join(',')} tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ''}${v.remainder ? ` and would run ${v.remainder.length} skipped file(s) here with per_file` : ''}`);
}
say(`${carried} of ${mapped} mapped lane(s) would carry once origin/${base} holds this map`);
return done(mapped > 0 && carried === mapped ? 0 : 1);
@@ -218,7 +221,7 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
if (has('--simulate')) return has('--tests-only') && policyFrom === 'head' && list.length ? simulate(cwd, base, list) : { text: 'gstack-ship-preflight: --simulate needs --tests-only --policy-from head and at least one --lane <label>=<command>\n', exit: 2 };
if (has('--simulate')) return has('--tests-only') && policyFrom === 'head' && list.length ? simulate(cwd, base, list, has('--allow-repo-commands')) : { text: 'gstack-ship-preflight: --simulate needs --tests-only --policy-from head and at least one --lane <label>=<command>\n', exit: 2 };
const mode = flag('--lanes');
const concurrency = has('--lanes') && mode === undefined ? '--lanes needs serial or parallel' : laneConcurrency(mode);
if (typeof concurrency === 'string') return { text: `gstack-ship-preflight: ${concurrency}\n`, exit: 2 };
+137 -6
View File
@@ -35,7 +35,7 @@ function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<s
runs: [run, ...((over.others ?? []).map(o => ({ ...run, ...o })))] as FakeWorld['runs'],
jobs: { 7: (over.jobs ?? [{ name: 'suite', conclusion: 'success' }]).map(j => ({ ...j, completed_at: at(5) })), 9: [{ name: 'suite', conclusion: 'failure', completed_at: at(1) }],
...Object.fromEntries(Object.entries(over.attempts ?? {}).map(([k, js]) => [k, js.map(j => ({ ...j, completed_at: at(5) }))])) },
trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 12, os: 'Linux', ...over.tree } },
trees: { 7: { run_id: 7, run_attempt: 1, event: run.event, sha: head, ...(run.event === 'pull_request' ? { head_sha: head } : {}), tree, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 12, os: 'Linux', ...over.tree } },
};
const file = path.join(f.root, 'world.json');
fs.writeFileSync(file, JSON.stringify(w));
@@ -389,9 +389,9 @@ describe('GST-1190: a file whose every test skipped on CI is not carried', () =>
const f = fx();
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: inventory_uncovered: 1 of 2 inventory file\(s\) are in no run's tree\.json or skipped every test there \(1 skipped_files\): test\/b\.test\.ts$/m)]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: inventory_skipped: 1 of 2 inventory file\(s\) skipped every test in CI and the lane declares no per_file runner: test\/b\.test\.ts$/m)]);
const out = preflight(f, [`free=${FREE}`]);
expect(out.stdout).toContain('CI_CARRY: none label=free (inventory_uncovered');
expect(out.stdout).toContain('CI_CARRY: none label=free (inventory_skipped');
expect(runs(f, 'free-runs')).toBe(1);
world(f, { tree: { skipped_files: [] } });
expect(f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]).status).toBe(0);
@@ -417,7 +417,7 @@ describe('GST-1190: a path-skipped CI job counts only under an unchanged gate',
// workflow and the gate's scripts are byte-identical to origin/<base>; otherwise the branch controlled the skip and the lane runs here;
// fails_when=a branch edits the workflow or the gate script to skip its own typecheck and the lane still passes; why_new=TRUT-7 (truther #1169); seam=fake gh
const TSC = 'bunx tsc --noEmit';
const SKIP_PROFILE = { ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes' } };
const SKIP_PROFILE = { ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', scripts: ['scripts/changes.sh'] } };
const WORKFLOW = 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n changes:\n runs-on: ubuntu-latest\n steps:\n - run: bash scripts/changes.sh\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n';
const SKIP_BASE = {
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': SKIP_PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
@@ -425,9 +425,9 @@ describe('GST-1190: a path-skipped CI job counts only under an unchanged gate',
'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT"\n',
};
const JOBS = (gate: string) => [{ name: 'changes', conclusion: gate }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
const setup = (feat: Record<string, string> = { 'src/b.ts': 'export const b = 2;\n' }, gate = 'success') => {
const setup = (feat: Record<string, string> = { 'src/b.ts': 'export const b = 2;\n' }, gate = 'success', event = 'pull_request') => {
const f = shipFixture(SKIP_BASE, feat); fixtures.push(f);
world(f, { jobs: JOBS(gate), tree: { files: [], tests: 0 } });
world(f, { jobs: JOBS(gate), run: { event }, tree: { files: [], tests: 0, event } });
return f;
};
const lane = (f: ShipFx) => preflight(f, [`tsc=${TSC}`]);
@@ -455,6 +455,11 @@ describe('GST-1190: a path-skipped CI job counts only under an unchanged gate',
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('only a pull_request run\'s skip counts: a push or dispatch gate diffs against the last push, not the PR base', () => {
const f = setup(undefined, 'success', 'workflow_dispatch');
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('gstack-ci-carry find --policy-from head --simulate checks the branch map, says SIMULATION on every line and writes nothing', () => {
const VERIFY = 'bun run verify';
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['suite'] } } }) + '\n';
@@ -471,3 +476,129 @@ describe('GST-1190: a path-skipped CI job counts only under an unchanged gate',
expect(cli(['wait', '--base', 'main', '--policy-from', 'head', '--simulate']).status).toBe(2);
});
});
describe('GST-1190: a lane carries the covered files and runs only the skipped remainder with its per_file runner', () => {
// Value: protects=a fully skipped CI file runs here through the lane's declared per_file runner while the rest carries, a failing remainder
// carries nothing, and a lane with no runner fails closed by name; fails_when=gstack's 6 runner-skipped files make the whole free suite rerun,
// or a remainder failure still records a carried pass; why_new=HARN-7 decision (GSTA-258, per-file lane carry); seam=fake gh, gstack-evidence record-ci
const PER = (exit: number) => `printf '%s\\n' {files} >> ../perfile-args; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2; exit ${exit}`;
const withRunner = (exit: number) => ({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'", per_file: PER(exit) } } }, null, 2) + '\n' });
const args = (f: ShipFx) => { const p = path.join(f.root, 'perfile-args'); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; };
const rows = (f: ShipFx) => findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl').flatMap(p => fs.readFileSync(p, 'utf8').trim().split('\n').map(l => JSON.parse(l)));
test('the covered file carries; only the skipped file runs here, and both rows are recorded', () => {
const f = shipFixture(withRunner(0)); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const out = preflight(f, [`free=${FREE}`]);
expect(out.status, out.stdout + out.stderr).toBe(0);
expect(lane(out.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
expect([args(f), runs(f, 'free-runs')]).toEqual(['test/b.test.ts\n', 0]);
const all = rows(f);
expect(all.find(r => r.source === 'ci')).toMatchObject({ label: 'free', tests_ran: 1, tests_unit: 'files', skipped_here: { label: 'free.skipped', files: ['test/b.test.ts'], tests_ran: 1 } });
expect(all.find(r => r.label === 'free.skipped')).toMatchObject({ exit: 0, tests_ran: 1 });
});
test('a failing remainder carries nothing: no CI row, and the whole lane runs here', () => {
const f = shipFixture(withRunner(1)); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: skipped_failed: the per_file run of 1 skipped file\(s\) exited 1 \(label=free\.skipped\)$/m)]);
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
expect(preflight(f, [`free=${FREE}`]).stdout).toContain('CI_CARRY: none label=free (skipped_failed');
expect(runs(f, 'free-runs')).toBe(1);
});
test('per_file is validated: it needs {files} and an inventory entry', async () => {
const { shipLanesErrors } = await import('../lib/ci-carry');
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test {files}' } }, ['p'])).toEqual([]);
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
expect(shipLanesErrors({ x: { profiles: ['p'], jobs: ['j'], per_file: 'bun test {files}' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
});
});
describe('GSTA-258 L6 review fixes: gate inputs, ledger CI rows, simulate consent', () => {
// Value: protects=a branch cannot move a path skip through a gate file gstack failed to compare, a stale CI ledger row never passes a guard
// after record-ci refused, and --simulate runs a branch's inventory only with --allow-repo-commands; fails_when=the l6c-p1 reproductions pass
// again; why_new=l6c-p1 findings (readers 15 and 16); seam=fake gh, gstack-evidence
const TSC = 'bunx tsc --noEmit';
const flow = (gateJob: string, before = '') => `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n${before} changes:\n runs-on: ubuntu-latest\n${gateJob} suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n`;
const profile = (scripts: string[] | undefined) => ({ ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', ...(scripts ? { scripts } : {}) } });
const base = (workflow: string, extra: Record<string, string>, scripts: string[] | undefined = ['scripts/changes.sh']) => ({
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': profile(scripts) }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
'.github/workflows/free.yml': workflow, 'scripts/changes.sh': 'echo typecheck=false >> "$GITHUB_OUTPUT"\n', ...extra,
});
const JOBS = [{ name: 'changes', conclusion: 'success' }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
const tsc = (b: Record<string, string>, feat: Record<string, string>) => { const f = shipFixture(b, feat); fixtures.push(f); world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } }); return preflight(f, [`tsc=${TSC}`]).stdout; };
const REFUSED = 'CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)';
test('a gate script reached through ${{ github.workspace }} is compared', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash ${{ github.workspace }}/scripts/gate.sh\n'), { 'scripts/gate.sh': 'exit 0\n' });
expect(tsc(b, { 'scripts/gate.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a gate script under the job\'s working-directory is compared', () => {
const b = base(flow(' defaults:\n run:\n working-directory: tools\n steps:\n - run: bash ../scripts/changes.sh && bash gate2.sh\n'), { 'tools/gate2.sh': 'exit 0\n' });
expect(tsc(b, { 'tools/gate2.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a gate that runs a package script compares package.json', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bun run detect\n'), { 'package.json': '{"scripts":{"detect":"true"}}\n' });
expect(tsc(b, { 'package.json': '{"scripts":{"detect":"echo typecheck=false"}}\n' })).toContain(REFUSED);
});
test('the gate is the job, not a same-named step in an earlier job', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash scripts/extra.sh\n', ' lint:\n runs-on: ubuntu-latest\n steps:\n - run: echo lint\n name: changes\n'), { 'scripts/extra.sh': 'exit 0\n' });
expect(tsc(b, { 'scripts/extra.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a guard whose CI job the unchanged gate skipped says not owed, never carried', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh\n'), {});
const conf = JSON.parse(b['.gstack/pregate.json']);
b['.gstack/pregate.json'] = JSON.stringify({ ...conf, guards: [{ id: 'tsc', when: ['src/**'], command: TSC }] }) + '\n';
const f = shipFixture(b, { 'src/b.ts': 'export const b = 2;\n' }); fixtures.push(f);
world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } });
const out = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
expect(out.stdout, out.stdout + out.stderr).toContain('GUARD: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) runs=7/1');
});
test('a skippable profile must declare its gate scripts', async () => {
const { runProfileErrors } = await import('../lib/run-evidence');
expect(runProfileErrors({ p: profile(undefined) }, undefined)).toEqual([expect.objectContaining({ path: 'run_profiles["p"].skippable' })]);
expect(runProfileErrors({ p: profile(['scripts/changes.sh']) }, undefined)).toEqual([]);
});
test('passed never counts a CI row: a carry under another base or after a refusal does not pass a guard', () => {
const f = fx();
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
world(f);
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
expect(ev(['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
const p = ev(['passed', '--', FREE]);
expect([p.status, p.stdout]).toEqual([1, expect.stringMatching(/^NOT_PASSED: /m)]);
});
test('passed: a newer failure of the same command at this tree vetoes the older pass', () => {
const f = fx();
const CMD = "test -e ../fail-flag && exit 1; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2";
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(0);
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^PASSED: label=g source=local /m);
fs.writeFileSync(path.join(f.root, 'fail-flag'), '');
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(1);
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^NOT_PASSED: /m);
});
test('--simulate runs a branch inventory only with --allow-repo-commands', () => {
const INV = "printf x >> ../inv-runs; printf 'test/a.test.ts\\ntest/b.test.ts\\n'";
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: INV } } }) + '\n';
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
world(f);
const sim = (extra: string[]) => f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `free=${FREE}`, ...extra]);
const no = sim([]);
expect([no.status, runs(f, 'inv-runs')]).toEqual([1, 0]);
expect(no.stdout).toContain('pass --allow-repo-commands');
const yes = sim(['--allow-repo-commands']);
expect([yes.status, runs(f, 'inv-runs')], yes.stdout).toEqual([0, 1]);
expect(yes.stdout).toContain('SIMULATION: LANE: free would carry run=7/1 tests_ran=2 unit=files');
});
});