mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
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:
1 parent
9e95a7e362
commit
864c3ab88a
10 files changed
+273
-81
No files matched your search
+29
-14
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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}`];
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user