mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
v1.94.17.0 ci-carry: guard carry, jobs-mapped lanes, aliases, per-file carry of runner-skipped files, path-skipped jobs (GST-1186, GST-1189, GST-1190) (#3929)
* feat(pregate): a guard that already passed at this tree carries from CI or the ledger (GST-1186)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ci-carry): jobs-mapped lanes, aliases, narrow-lane counts, --simulate proof, policy-base check on carried (GST-1189, GST-1190)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ci-carry): a file whose every test skipped on CI is not carried; tree.json records skipped_files (GST-1190)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ci-carry): path-skipped jobs count only under an unchanged gate; find --policy-from head --simulate (GST-1190)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship: name the pre-push guards in Step 17 and the early push (moved from #3923 per GSTA-258)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci-carry: per-file lane carry for runner-skipped files; review fixes (gate inputs, passed ignores CI rows, simulate consent, pull_request-only skips, guard not-owed)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ship: Step 17 names the pre-push guards without a code this branch cannot print
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* pregate: gstack's bun run test lane runs its runner-skipped files with per_file
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci-carry: a per_file remainder must run tests here and the carried runs must still stand (l6c-p2)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci-carry: read record-ci from its own last record line; the remainder's count must come from this run's row (l6c-p2 red-team)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ci-tree-reuse): type the skipped_files fixture for the test typecheck ratchet
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Revert "pregate: gstack's bun run test lane runs its runner-skipped files with per_file"
This reverts commit fafbae5b9b.
* ci-carry: ship the skipped_files reader inert; gstack's writer and per_file move to the adoption PR; Step 17 keeps its refs/heads marker
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: guard carry, per_file and the too-old message in pregate and troubleshooting (docs audit l6cdocs2)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* gstack-evidence record-ci: recheck the working tree after the post-remainder run recheck (#3929 second fix-check)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* windows-curation: exclude test/ship-ci-carry.test.ts like its ship-bin-fixture siblings (the path-scoped Windows check runs it at its first spawn)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
94771d927f
commit
d2e63181d7
35 files changed
+1144
-134
No files matched your search
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [1.94.17.0] - 2026-11-03
|
||||
|
||||
- A pregate guard whose exact command already passed at this tree no longer runs again (GST-1186). When the base branch's `ship_lanes_from` maps the command, it is carried from the verified CI run through the same `gstack-evidence record-ci` path /ship lanes use (`GUARD: <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:`, 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). A `tree.json` that lists such files in `skipped_files` makes the pregate tests stage run them here. A /ship lane or guard whose inventory names one carries the covered files and runs only the skipped ones here, through the `per_file` runner its `ship_lanes_from` entry declares. The CI row is written only after that run passes with a test count above zero and the carried runs still stand (`skipped_failed`, `skipped_zero_run` or `run_changed` otherwise), and an entry without `per_file` refuses as `inventory_skipped`, naming the files. A partly skipped file still counts. An older `tree.json` without the list is read as before. gstack itself changes nothing yet: its own writer and a `per_file` entry for its `bun run test` lane switch on together in a follow-up pull request, after the pull requests now queued on older gstack have landed, because an older gstack rejects the new key in main's policy.
|
||||
|
||||
- A run profile can declare `skippable: { jobs, gate, scripts }` for jobs a path filter skips (GST-1190, truther #1169). A skipped job counts only in a `pull_request` run whose gate job succeeded, with the workflow, the declared `scripts` (every file the gate reads), the paths the gate job names (also under its `working-directory`) and `package.json` when it runs a package script matching `origin/<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
|
||||
- `test/ship-ci-carry.test.ts`: in founderstar's shape (guards only, no lanes), `check-quick` carries from CI, `play-through` reuses a same-tree ledger pass, `assets` runs, and a dirty tree carries nothing. It fails without the guard carry.
|
||||
- `test/ship-ci-carry.test.ts`: a lane mapped by `jobs` carries when every named job passed at the same tree with no test files, and refuses for a missing job, a skipped job, a rerun whose newest attempt skipped the job, and another tree's run. Those five fail without the change.
|
||||
- `test/ship-ci-carry.test.ts` (GST-1190): an alias carries and an undeclared env prefix does not; a narrow lane reports its covered file count, not 9677; a jobs-mapped `bunx tsc --noEmit` guard carries with `n/a`; the newer-gstack message; `--simulate` writes and clears nothing and the branch's own map never carries; `carried` drops a row another base mapped. Six of these fail without the change.
|
||||
- `test/ship-ci-carry.test.ts` (GST-1190 skipped files): a fully skipped inventory file stops the lane carry, an empty list or older `tree.json` carries, and the tests stage runs only the skipped selected file. Both fail without the change.
|
||||
- `test/ship-ci-carry.test.ts` (path-skipped jobs and `find --simulate`): a skip under an unchanged, green gate is not owed; a changed workflow, a changed gate script or a failed gate job runs the lane here; `find --policy-from head --simulate` prints only SIMULATION lines and writes nothing. All five fail without the change.
|
||||
- `test/ship-ci-carry.test.ts` (review fixes): a gate script behind `${{ github.workspace }}`, under `working-directory` or behind a package script is compared; the gate is the job, not a same-named step; only a `pull_request` run's skip counts; a skippable profile must declare `scripts`; `passed` never counts a CI row; a path-skipped guard says not owed; `--simulate` runs a branch inventory only with `--allow-repo-commands`. Eight fail without the fixes; a newer-failure veto test covers existing behavior.
|
||||
- `test/ship-ci-carry.test.ts` (per-file lane carry): the covered file carries and only the skipped file runs through `per_file`, a failing or zero-test remainder, or a run that moved to a new attempt meanwhile, records no CI row and runs the lane whole, and `per_file` needs `{files}` and an inventory entry. All five fail without the change.
|
||||
|
||||
## [1.94.16.0] - 2026-11-03
|
||||
|
||||
**A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.94.16.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.94.17.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+18
-11
@@ -3,6 +3,7 @@
|
||||
* gstack-ci-carry — CI as the one full test run (GSTA-258; docs/pregate.md#ci-carry).
|
||||
*
|
||||
* gstack-ci-carry find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
|
||||
* [--policy-from head --simulate]
|
||||
* gstack-ci-carry wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
|
||||
*
|
||||
* find: the newest verified run of each profile (default: every profile the base branch's
|
||||
@@ -14,10 +15,9 @@
|
||||
* carried, 3 still running or no run. Logic: lib/ci-carry.ts; the run checks are lib/run-evidence.ts.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { findCarry, waitForRun } from '../lib/ci-carry';
|
||||
import { isConfigUnavailable, loadConfigFromBase } from '../lib/pregate/config';
|
||||
import { findCarry, laneMapping, mappingCoverage, waitForRun } from '../lib/ci-carry';
|
||||
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { buildRunCheck } from '../lib/pregate/run-check';
|
||||
import { runInventory } from '../lib/run-evidence';
|
||||
import { wtreeFingerprint } from '../lib/script-invocation';
|
||||
import { resolveBaseBranch } from '../lib/ship-policy';
|
||||
|
||||
@@ -25,11 +25,13 @@ const HELP = `gstack-ci-carry — use a verified CI run at HEAD's exact tree ins
|
||||
|
||||
find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
|
||||
CI_CARRY: accepted <receipt> (exit 0) or CI_CARRY: none (<reason>) (exit 1)
|
||||
--policy-from head --simulate: the same check under the branch's own map and profiles, every line starting
|
||||
SIMULATION:; a dry run that proves a new mapping before it lands (find never writes evidence; nothing clears)
|
||||
wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
|
||||
poll the newest run at HEAD (default 1800 s, every 20 s): CI_WAIT: green|red|running|none, then find on green;
|
||||
exit 0 carried, 1 red or not carried, 3 still running or no run
|
||||
|
||||
Profiles and ship_lanes_from come from origin/<base>'s .gstack/pregate.json only. Only a clean, committed tree
|
||||
Profiles and ship_lanes_from come from origin/<base>'s .gstack/pregate.json only (a simulation reads the branch's). Only a clean, committed tree
|
||||
carries; fork runs, stale runs, a newer red or running run on HEAD and a tree.json that misses an inventory
|
||||
file never do. Exit 2 is usage.`;
|
||||
|
||||
@@ -37,30 +39,35 @@ const argv = process.argv.slice(2);
|
||||
const cmd = argv[0];
|
||||
const all = (n: string) => argv.flatMap((a, i) => (a === n && argv[i + 1] !== undefined ? [argv[i + 1]!] : []));
|
||||
const flag = (n: string) => all(n).at(-1);
|
||||
const out = (s: string) => process.stdout.write(`${s}\n`);
|
||||
const simulate = argv.includes('--simulate');
|
||||
const out = (s: string) => process.stdout.write(`${simulate ? 'SIMULATION: ' : ''}${s}\n`);
|
||||
const usage = (m: string) => { process.stderr.write(`gstack-ci-carry: ${m}\n`); process.exit(2); };
|
||||
if (!cmd || argv.includes('--help') || argv.includes('-h')) { out(HELP); process.exit(cmd ? 0 : 2); }
|
||||
if (cmd !== 'find' && cmd !== 'wait') usage(`unknown command ${cmd}; run gstack-ci-carry --help`);
|
||||
const policyFrom = flag('--policy-from') ?? 'base';
|
||||
if (policyFrom !== 'base' && policyFrom !== 'head') usage('--policy-from needs base or head');
|
||||
if (simulate !== (policyFrom === 'head') || (simulate && cmd !== 'find')) usage('find --policy-from head --simulate go together: a branch\'s own map is only ever simulated, never carried');
|
||||
const git = (args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : ''; };
|
||||
const root = git(['rev-parse', '--show-toplevel']);
|
||||
if (!root) usage('not inside a git repository');
|
||||
const base = resolveBaseBranch(root, flag('--base'));
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: origin/${base}'s .gstack/pregate.json is unreadable or invalid)`); process.exit(1); }
|
||||
const loaded = simulate ? loadConfigFromHead(root) : loadConfigFromBase(root, base);
|
||||
if (simulate) out(`policy ${isConfigUnavailable(loaded) ? 'unavailable' : loaded.label}; a dry run: nothing is written and nothing clears (a carry reads origin/${base}'s map only)`);
|
||||
if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: ${simulate ? 'the branch\'s' : `origin/${base}'s`} .gstack/pregate.json is unreadable or invalid)`); process.exit(1); }
|
||||
const command = flag('--command');
|
||||
const mapping = command !== undefined ? loaded.config.ship_lanes_from[command] : undefined;
|
||||
if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is not a key of origin/${base}'s ship_lanes_from)`); process.exit(1); }
|
||||
const mapping = command !== undefined ? laneMapping(loaded.config.ship_lanes_from, command) : undefined;
|
||||
if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is neither a key nor a declared alias in origin/${base}'s ship_lanes_from)`); process.exit(1); }
|
||||
if (mapping && !argv.includes('--allow-repo-commands')) usage('--command runs the mapping\'s inventory, a pin-file command: pass --allow-repo-commands');
|
||||
const profiles = all('--profile').length ? all('--profile') : mapping ? mapping.profiles : [...new Set(Object.values(loaded.config.ship_lanes_from).flatMap(m => m.profiles))];
|
||||
if (!profiles.length) { out(`CI_CARRY: none (no_profile: origin/${base} maps no lane in ship_lanes_from; pass --profile <name>)`); process.exit(1); }
|
||||
const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base);
|
||||
const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base, simulate ? 'head' : 'base');
|
||||
if ('error' in check) { out(`CI_CARRY: none (profiles_unavailable: ${check.error})`); process.exit(1); }
|
||||
const head = git(['rev-parse', 'HEAD']);
|
||||
const tree = git(['rev-parse', 'HEAD^{tree}']);
|
||||
|
||||
function find(): number {
|
||||
const wtree = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
const v = findCarry(check as Exclude<typeof check, { error: string }>, { profiles, head, tree, wtree, ...(mapping ? { inventory: () => runInventory(mapping.inventory, root) } : {}) });
|
||||
const v = findCarry(check as Exclude<typeof check, { error: string }>, { profiles, head, tree, wtree, ...(mapping ? mappingCoverage(mapping, root) : {}) });
|
||||
if (argv.includes('--json')) out(JSON.stringify(v));
|
||||
else out(v.ok ? `CI_CARRY: accepted ${v.receipt}` : `CI_CARRY: none (${v.reason}: ${v.detail})`);
|
||||
return v.ok ? 0 : 1;
|
||||
|
||||
@@ -258,6 +258,7 @@ gstack_result_anchor() {
|
||||
DX_CAPTURE_REDACTION_FAILED) printf '%s' 'dx-capture-redaction-failed' ;;
|
||||
DX_CAPTURE_INTERNAL) printf '%s' 'dx-capture-internal' ;;
|
||||
POLICY_GSTACK_TOO_OLD) printf '%s' 'policy-gstack-too-old' ;;
|
||||
PREGATE_GSTACK_TOO_OLD) printf '%s' 'pregate-gstack-too-old' ;;
|
||||
QUEUE_NOT_READY) printf '%s' 'queue-not-ready' ;;
|
||||
QUEUE_PROOF_MISSING) printf '%s' 'queue-proof-missing' ;;
|
||||
QUEUE_STAMP_NOT_STAMP_ONLY) printf '%s' 'queue-stamp-not-stamp-only' ;;
|
||||
|
||||
+89
-24
@@ -5,7 +5,8 @@
|
||||
*
|
||||
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
|
||||
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
|
||||
* gstack-evidence carried [--json]
|
||||
* gstack-evidence passed -- <cmd>
|
||||
* gstack-evidence carried [--base <branch>] [--json]
|
||||
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
|
||||
* [--max-age <hours>] [--allow-paths <csv>]
|
||||
* gstack-evidence bundle --out <file> [--label <L>]... [--require <csv>] [--selection L=<text>]...
|
||||
@@ -65,8 +66,11 @@
|
||||
* `record-ci` writes the explicit CI-sourced row for a lane CI carried: it
|
||||
* finds and verifies the run itself (lib/ci-carry.ts) for a command the base's
|
||||
* ship_lanes_from maps, then records source "ci", the runs and profiles, the
|
||||
* run's finish time as ts, bound to HEAD's clean tree; `carried` lists those
|
||||
* rows at the current tree for the ship receipt.
|
||||
* run's finish time as ts, bound to HEAD's clean tree, and the policy that
|
||||
* mapped it (`origin/<base>@<sha>:…`); `carried` lists those rows at the
|
||||
* current tree for the ship receipt, only when that policy is origin/<the
|
||||
* receipt's base> (GST-1190: a row a run against another base wrote never
|
||||
* counts).
|
||||
*
|
||||
* MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced.
|
||||
* A synced record citing an unsynced log would grade FRESH on a machine where
|
||||
@@ -90,10 +94,10 @@ import { resultLine } from "../lib/result-codes";
|
||||
import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy";
|
||||
import { versionAtRef } from "../lib/restamp";
|
||||
import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation";
|
||||
import { findCarry } from "../lib/ci-carry";
|
||||
import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ci-carry";
|
||||
import { runChanged } from "../lib/run-evidence";
|
||||
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
|
||||
import { buildRunCheck } from "../lib/pregate/run-check";
|
||||
import { runInventory } from "../lib/run-evidence";
|
||||
|
||||
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
|
||||
const LOG_MAX_BYTES = 2 * 1024 * 1024;
|
||||
@@ -122,10 +126,17 @@ interface EvidenceRecord {
|
||||
source?: "ci";
|
||||
runs?: string[];
|
||||
profiles?: string[];
|
||||
/** The CI jobs a jobs-mapped lane carried from (record-ci, GST-1189); tests_ran is then "n/a". */
|
||||
jobs?: string[];
|
||||
/** The base policy that mapped a carried lane (record-ci), as `origin/<base>@<sha>:.gstack/pregate.json`. */
|
||||
policy?: string;
|
||||
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
|
||||
tests_ran?: number | "unknown";
|
||||
tests_ran?: number | "unknown" | "n/a";
|
||||
/** "files" when a carried narrow lane's tests_ran counts its inventory files the run covered (record-ci), not tests. */
|
||||
tests_unit?: "files";
|
||||
/** Why a carried lane was not owed at all: every job it names was path-skipped by an unchanged gate (record-ci, GST-1190). */
|
||||
not_owed?: string;
|
||||
skipped_here?: { label: string; files: string[]; tests_ran: number | "unknown" };
|
||||
}
|
||||
|
||||
/** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */
|
||||
@@ -598,6 +609,36 @@ function identityDigest(): string {
|
||||
return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null }));
|
||||
}
|
||||
|
||||
/** A local row that passed with a counted run within the max age, under this machine's identity. */
|
||||
function countedPass(r: EvidenceRecord, maxAgeHours: number): boolean {
|
||||
if (r.exit !== 0 || typeof r.tests_ran !== "number" || r.tests_ran <= 0) return false;
|
||||
const age = Date.now() - Date.parse(r.ts);
|
||||
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
|
||||
return r.identity === identityDigest();
|
||||
}
|
||||
|
||||
/**
|
||||
* `passed -- <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("--");
|
||||
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
|
||||
if (!command || sep !== 0) { console.error("usage: gstack-evidence passed -- <command>"); return 2; }
|
||||
const wtree = currentWtree();
|
||||
if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; }
|
||||
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 && r.source === undefined);
|
||||
if (!newest) { console.log(`NOT_PASSED: no local ledger row for this command at tree ${wtree.slice(0, 12)}`); return 1; }
|
||||
if (!countedPass(newest, 24)) { console.log(`NOT_PASSED: the newest row (label=${newest.label} ts=${newest.ts}) is not a counted pass within 24 h under this runtime`); return 1; }
|
||||
console.log(`PASSED: label=${newest.label} source=local ts=${newest.reused_from ?? newest.ts}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `run --reuse`: the newest local row for this label, command and working tree decides. A counted pass under the same
|
||||
* identity within the max age is copied (original ts kept) and printed as a run would be; anything else runs the command.
|
||||
@@ -612,10 +653,7 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
|
||||
return false;
|
||||
}
|
||||
const newest = records.findLast((r) => r.label === label && r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
|
||||
if (!newest || newest.exit !== 0 || typeof newest.tests_ran !== "number" || newest.tests_ran <= 0) return false;
|
||||
const age = Date.now() - Date.parse(newest.ts);
|
||||
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
|
||||
if (newest.identity !== identityDigest()) return false;
|
||||
if (!newest || !countedPass(newest, maxAgeHours)) return false;
|
||||
try {
|
||||
appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 });
|
||||
} catch (e: any) {
|
||||
@@ -629,9 +667,11 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
|
||||
|
||||
/**
|
||||
* `record-ci`: carry a lane from CI. Its command must be a key of the base branch's ship_lanes_from; the run is found and
|
||||
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane.
|
||||
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane;
|
||||
* inventory files every run skipped entirely run here through the entry's per_file runner (label <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; };
|
||||
@@ -651,17 +691,33 @@ function cmdRecordCi(argv: string[]): number {
|
||||
const loaded = loadConfigFromBase(cwd, base);
|
||||
if (isConfigUnavailable(loaded)) return none("config_unavailable", loaded.detail);
|
||||
if (loaded.errors.length) return none("config_invalid", loaded.errors.map((x) => `${x.path}: ${x.message}`).join("; "));
|
||||
const from = loaded.config.ship_lanes_from;
|
||||
if (!Object.prototype.hasOwnProperty.call(from, command)) return none("not_mapped", `the command is not a key of origin/${base}'s ship_lanes_from`);
|
||||
const m = from[command]!;
|
||||
const m = laneMapping(loaded.config.ship_lanes_from, command);
|
||||
if (!m) return none("not_mapped", `the command is neither a key nor a declared alias in origin/${base}'s ship_lanes_from`);
|
||||
const check = buildRunCheck(cwd, null, true, base);
|
||||
if ("error" in check) return none("profiles_unavailable", check.error);
|
||||
const head = git(["rev-parse", "HEAD"]);
|
||||
const tree = git(["rev-parse", "HEAD^{tree}"]);
|
||||
if (!head || !tree) return none("no_head", "HEAD is unreadable");
|
||||
const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; };
|
||||
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, inventory: () => runInventory(m.inventory, cwd) });
|
||||
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) });
|
||||
if (!v.ok) return none(v.reason, v.detail);
|
||||
let here: EvidenceRecord["skipped_here"];
|
||||
if (v.remainder?.length) {
|
||||
const hereLabel = `${label}.skipped`;
|
||||
console.error(`gstack-evidence record-ci: ${v.remainder.length} inventory file(s) skipped every test in CI; running them here (label=${hereLabel})`);
|
||||
const hereCommand = perFileCommand(m.per_file!, v.remainder);
|
||||
const exit = await cmdRun(["--label", hereLabel, "--", hereCommand]);
|
||||
if (exit !== 0) return none("skipped_failed", `the per_file run of ${v.remainder.length} skipped file(s) exited ${exit} (label=${hereLabel})`);
|
||||
let rows: EvidenceRecord[] = [];
|
||||
try { rows = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { rows = []; }
|
||||
here = { label: hereLabel, files: v.remainder, tests_ran: rows.findLast((r) => r.label === hereLabel && r.source === undefined && r.cmd_sha256 === sha256(hereCommand) && r.wtree === tree)?.tests_ran ?? "unknown" };
|
||||
if (typeof here.tests_ran !== "number" || here.tests_ran <= 0) return none("skipped_zero_run", `the per_file run of ${v.remainder.length} skipped file(s) ran ${here.tests_ran} tests here (label=${hereLabel}); a lane passes on its runner count, never exit 0 alone`);
|
||||
for (const e of v.runs) {
|
||||
const changed = runChanged(check.deps, e.run_id, e.run_attempt);
|
||||
if (changed) return none("run_changed", changed);
|
||||
}
|
||||
if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`);
|
||||
}
|
||||
let paths: ReturnType<typeof ledgerPath>;
|
||||
try {
|
||||
paths = ledgerPath();
|
||||
@@ -673,15 +729,22 @@ function cmdRecordCi(argv: string[]): number {
|
||||
const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`);
|
||||
const rec: EvidenceRecord = {
|
||||
ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0,
|
||||
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label,
|
||||
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}),
|
||||
};
|
||||
appendJsonl(paths.file, rec, { mode: 0o600 });
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}`);
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}` : ""}`);
|
||||
if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** `carried`: each label's newest row when it is a CI-sourced row at the current working tree, as the receipt's ci_carried entries. */
|
||||
/**
|
||||
* `carried`: each label's newest row when it is a CI-sourced row at the current working tree whose mapping policy is
|
||||
* origin/<base> (the receipt's base; default the resolved base branch), as the receipt's ci_carried entries.
|
||||
*/
|
||||
function cmdCarried(argv: string[]): number {
|
||||
const at = argv.indexOf("--base");
|
||||
if (at >= 0 && !argv[at + 1]) { console.error("usage: gstack-evidence carried [--base <branch>] [--json]"); return 2; }
|
||||
const policyRef = `origin/${(at >= 0 ? argv[at + 1]! : resolveBaseBranch(git(["rev-parse", "--show-toplevel"]) ?? process.cwd())).replace(/^origin\//, "")}@`;
|
||||
let records: EvidenceRecord[] = [];
|
||||
try {
|
||||
records = readJsonl<EvidenceRecord>(ledgerPath().file);
|
||||
@@ -691,7 +754,7 @@ function cmdCarried(argv: string[]): number {
|
||||
const wtree = currentWtree();
|
||||
const latest = new Map<string, EvidenceRecord>();
|
||||
for (const r of records) latest.set(r.label, r);
|
||||
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs))
|
||||
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef))
|
||||
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
|
||||
if (argv.includes("--json")) console.log(JSON.stringify(carried));
|
||||
else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`);
|
||||
@@ -756,10 +819,10 @@ function cmdCheck(argv: string[]): number {
|
||||
allFresh = false;
|
||||
continue;
|
||||
}
|
||||
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
|
||||
const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown";
|
||||
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
|
||||
const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : "";
|
||||
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
|
||||
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan}${latest.tests_unit === "files" ? " unit=files" : ""} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
|
||||
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
|
||||
let reason = "";
|
||||
if (latest.exit !== 0) {
|
||||
@@ -911,7 +974,9 @@ try {
|
||||
if (sub === "run") {
|
||||
process.exit(await cmdRun(rest));
|
||||
} else if (sub === "record-ci") {
|
||||
process.exit(cmdRecordCi(rest));
|
||||
process.exit(await cmdRecordCi(rest));
|
||||
} else if (sub === "passed") {
|
||||
process.exit(cmdPassed(rest));
|
||||
} else if (sub === "carried") {
|
||||
process.exit(cmdCarried(rest));
|
||||
} else if (sub === "check") {
|
||||
@@ -923,7 +988,7 @@ try {
|
||||
} else if (sub === "verify") {
|
||||
process.exit(cmdVerify(rest));
|
||||
} else {
|
||||
console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ...");
|
||||
console.error("usage: gstack-evidence run|record-ci|passed|carried|check|bundle|ancestor|verify ...");
|
||||
process.exit(2);
|
||||
}
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -175,9 +175,9 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi
|
||||
return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree };
|
||||
}
|
||||
|
||||
/** The lanes CI carried at the checked-out tree, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
|
||||
function ciCarried(): ShipReceipt['ci_carried'] {
|
||||
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--json'], { encoding: 'utf8', timeout: 60_000 });
|
||||
/** The lanes CI carried at the checked-out tree under origin/<base>'s mapping, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
|
||||
function ciCarried(base: string): ShipReceipt['ci_carried'] {
|
||||
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--base', base, '--json'], { encoding: 'utf8', timeout: 60_000 });
|
||||
try { const list = JSON.parse(r.stdout); return Array.isArray(list) && list.length ? list : undefined; } catch { return undefined; }
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base,
|
||||
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
|
||||
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
|
||||
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(),
|
||||
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base),
|
||||
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
|
||||
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
|
||||
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
|
||||
|
||||
+180
-11
@@ -88,9 +88,9 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
|
||||
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
|
||||
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
|
||||
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
|
||||
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os?, skippable? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
|
||||
| `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) |
|
||||
| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }`: the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and the command listing its test files, which their runs must cover (see [CI carry](#ci-carry)); read from the base branch only |
|
||||
| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }` or `{ profiles, jobs }`, with optional `aliases` (and, on an inventory entry, an optional `per_file` runner for files CI skipped entirely, see [skipped files](#skipped-files)): the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and either the command listing its test files, which their runs must cover, or the CI jobs that must have succeeded (see [CI carry](#ci-carry)); read from the base branch only |
|
||||
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
|
||||
|
||||
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
|
||||
@@ -259,7 +259,8 @@ Two stages with separate clocks, both printed:
|
||||
`--allow-repo-commands` the selected guards are listed and the check is
|
||||
`incomplete`. Guards are read from the base branch's copy (or the
|
||||
user-owned file when the base carries none) and cannot be
|
||||
downgraded to warn. See [guards](#guards).
|
||||
downgraded to warn. A guard whose exact command already passed at this tree
|
||||
is not run again ([guard carry](#guard-carry)). See [guards](#guards).
|
||||
|
||||
**Stage 2, test execution:**
|
||||
|
||||
@@ -710,7 +711,22 @@ other gate change. Each profile names the workflow file, the events it
|
||||
accepts, every job that must have passed (each exactly once), and the artifact
|
||||
holding the run's `tree.json`. `max_age_hours` (1–168, default 24) bounds how
|
||||
old the run may be, and `os` (`Linux`, `macOS`, `Windows`) requires
|
||||
`tree.json` to record that runner OS. Each workflow uploads `tree.json` from
|
||||
`tree.json` to record that runner OS. <a id="skippable"></a>`skippable`
|
||||
(`{ jobs, gate, scripts }`, GST-1190) lets named jobs be skipped by a path
|
||||
filter: a skipped job counts only in a `pull_request` run (whose gate diffs
|
||||
against the pull request's base; a push or dispatch gate diffs against the last
|
||||
push, which the branch's push history controls), when the `gate` job (the one
|
||||
whose output decides the skip) succeeded in the same attempt, and the workflow
|
||||
file plus the gate's inputs are byte-identical to `origin/<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
|
||||
its own job: `run_id`, `run_attempt`, `event`, `sha` (or `head_sha`),
|
||||
`tree` (`git rev-parse HEAD^{tree}` as tested), `files` (every test file the
|
||||
run executed), `tests` (the count) and, with `os`, the runner OS or a list of
|
||||
@@ -737,6 +753,28 @@ once:
|
||||
record (a reuse is never evidence). Its tree equals the tree git computes for
|
||||
the run's head commit. `files` is a list of paths and `tests` a
|
||||
non-negative integer when present; without `tests` the file count stands in.
|
||||
<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. 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 with a test count above zero and every carried
|
||||
run still stands (`run_changed` otherwise). A failure there is
|
||||
`skipped_failed` and a run of zero tests is `skipped_zero_run`; either way 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 Free Tests writer does
|
||||
not emit the list yet: it starts in the same follow-up pull request that
|
||||
declares `per_file` for gstack's `bun run test` lane, so gstack's carry does not
|
||||
change until both switch on together. A `tree.json`
|
||||
without the key (older writers, other repos) is read as before, with every
|
||||
listed file counted as run; add the list to your writer to close that gap.
|
||||
The remote gate's clearance does not use it: that gate runs on a Linux runner
|
||||
like CI, where the same files skip.
|
||||
- No run of the same workflow on that head, or on pregate.json's own head,
|
||||
whose newest attempt started at or after the second this run was created
|
||||
failed, was cancelled or is still running (`run_started_at`, so a red re-run
|
||||
@@ -785,7 +823,7 @@ Then, for the lane:
|
||||
Any API or git error refuses. Each refusal prints `REUSE: refused
|
||||
reason=<code> — …` as `PREGATE_RECEIPT_INVALID`, and a run that fails
|
||||
verification is named with its own reason (`fork_run`, `event_mismatch`,
|
||||
`run_not_success`, `job_missing`, `artifact_mismatch`, `artifact_reused`,
|
||||
`run_not_success`, `job_missing`, `job_skipped`, `artifact_mismatch`, `artifact_reused`,
|
||||
`tree_mismatch`, `newer_run_vetoes`, `run_changed`, `run_stale`,
|
||||
`os_mismatch`, `api_error`, …); the clear-level codes are `base_mismatch`,
|
||||
`no_verified_run`, `tree_mismatch` (runs on different trees),
|
||||
@@ -895,7 +933,8 @@ whose workflow holds a lane this machine runs (gstack's `ci-reuse/free-tests`
|
||||
on Linux), with pregate.json's own head as a further veto head. Its tree must be this tree exactly (no
|
||||
stamp-only substitution). Then:
|
||||
|
||||
- The selected local test files the run's `tree.json` lists count as passed.
|
||||
- The selected local test files the run's `tree.json` lists count as passed,
|
||||
except those in its `skipped_files` ([above](#skipped-files)), which run here.
|
||||
The lanes check records them under `from_run` with the run receipt
|
||||
(`run tree=… tests=… runs=<id>/<attempt> profiles=… heads=… verified=…`) and
|
||||
prints `REUSE: accepted <n> of <m> selected local test file(s) from <receipt>;
|
||||
@@ -933,11 +972,14 @@ it accepts a run only when all of these hold:
|
||||
tree. A `pull_request` run counts only when the pull request already
|
||||
contained its base.
|
||||
- Every run executed tests, and with an inventory command, the runs'
|
||||
`tree.json` files cover every file it prints. Each run is read once more
|
||||
afterward (`run_changed`).
|
||||
`tree.json` files cover every file it prints, none of them a file whose every
|
||||
test skipped there (`skipped_files`). A lane mapped by `jobs`
|
||||
instead needs no test count: each named job succeeded in the run's newest
|
||||
attempt. Each run is read once more afterward (`run_changed`).
|
||||
|
||||
Security and advisory scans, the sealed guard, guards and every preflight-stage
|
||||
check always run; only test lanes carry.
|
||||
Security and advisory scans, the sealed guard and every other preflight-stage
|
||||
check always run; only test lanes carry, plus guards whose exact command
|
||||
already passed at this tree ([guard carry](#guard-carry)).
|
||||
|
||||
**The tests stage.** Without `--from-run`, when the base branch declares
|
||||
`run_profiles`, `gstack-pregate` looks up the newest verified run at HEAD under
|
||||
@@ -986,8 +1028,101 @@ it and the command that lists its test files:
|
||||
}
|
||||
```
|
||||
|
||||
<a id="ship-lanes-jobs"></a>A lane with no test files to list, such as a typecheck or a verify script,
|
||||
names the CI jobs that run it instead (GST-1189). Declare exactly one of
|
||||
`inventory` or `jobs`:
|
||||
|
||||
```json
|
||||
"ship_lanes_from": {
|
||||
"bun run verify": { "profiles": ["ci/test"], "jobs": ["verify", "typecheck"] }
|
||||
}
|
||||
```
|
||||
|
||||
It carries only when every named job concluded `success` in the newest attempt
|
||||
of the verified run at HEAD's tree, read by the same `verifyRun` job check the
|
||||
profile's own `jobs` use. A job that is missing refuses as `job_missing`, a
|
||||
skipped one as `job_skipped`, any other conclusion as `job_not_success`, and a
|
||||
run of another tree as `tree_mismatch`. The receipt reads `ci-carry tree=…
|
||||
jobs=verify,typecheck runs=…`, and the ledger row records the jobs. gbrain run
|
||||
41564913688 (tree `13570abc`) is the case: its verify and typecheck jobs passed
|
||||
at the tree, but no inventory could name their files, so they ran again. The
|
||||
same form replaces fake `check:<command>` entries some repos wrote into
|
||||
`tree.json` to give a typecheck, a doc-freshness check or a lint an inventory
|
||||
(gstack-evals #180): name the job instead. A gstack older than the version that
|
||||
adds `jobs` rejects the key as invalid config, so a repo declaring it should
|
||||
first raise its `min_gstack_version` to that version.
|
||||
|
||||
<a id="ship-lanes-aliases"></a>**Aliases.** A lane is keyed on its exact command, so
|
||||
`env -u ANTHROPIC_API_KEY bun test tests/` never matches a `bun test tests/` key.
|
||||
Declare the other spellings on the entry, read from the base like the rest of
|
||||
the map (GST-1190):
|
||||
|
||||
```json
|
||||
"bun test tests/": { "profiles": ["ci/test"], "inventory": "…", "aliases": ["env -u ANTHROPIC_API_KEY bun test tests/"] }
|
||||
```
|
||||
|
||||
An alias is a second exact command, never a pattern: gstack does not strip env
|
||||
prefixes on its own, because `env -i HOME=… PATH=… bun test tests/` runs in a
|
||||
different environment than the key. An alias that repeats a key or another
|
||||
entry's alias is invalid config. The ledger row binds the command the lane
|
||||
actually ran.
|
||||
|
||||
**What `tests_ran` means on a carried lane.** No carried lane reports the whole
|
||||
run's count. A lane mapped by `inventory` reports how many of its inventory
|
||||
files the runs' `tree.json` covers, labeled as a file count (`tests_ran=14
|
||||
unit=files` on the ledger line, `tests_ran=14 (files)` on the `LANE:` line);
|
||||
alphaclaw's 14-file, 46-test guards lane used to record the run's 9677.
|
||||
`tree.json` has no per-file test counts yet, so the lane's own test count is
|
||||
not available. A lane mapped by `jobs` reports `tests_ran=n/a`. Only the
|
||||
pregate tests stage and `gstack-ci-carry find` without `--command`, which carry
|
||||
the whole run, report its test count.
|
||||
|
||||
**`tree.json` counts from Bun's JUnit.** A writer that builds `tree.json` from
|
||||
Bun's JUnit report must take the root `testsuite` count. Nested `describe`
|
||||
suites repeat the file attribute, so summing every `testsuite` counts tests
|
||||
twice. harness-evals #240's `scripts/ci-tree-json.ts` does it right.
|
||||
|
||||
<a id="prove-a-mapping"></a>**Proving a new mapping.** `ship_lanes_from` is read from `origin/<base>`
|
||||
only, so a branch that adds a mapping cannot carry from it, and running
|
||||
against `--base <PR branch>` fails (`BASE_CONTAINS_HEAD`). Prove it with the dry
|
||||
run instead, once per lane command:
|
||||
|
||||
```bash
|
||||
gstack-ci-carry find --base main --policy-from head --simulate --command 'bun run verify' --allow-repo-commands
|
||||
```
|
||||
|
||||
It reads the branch's own `ship_lanes_from` and `run_profiles`, checks the lane
|
||||
against the verified CI run at this tree exactly as a carry would, and prints
|
||||
`SIMULATION:` at the start of every line (`SIMULATION: CI_CARRY: accepted …` or
|
||||
`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 --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. 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 `gstack-ci-carry find --command` stops
|
||||
with a usage error (exit 2), and the preflight dry run prints
|
||||
`inventory_not_allowed` for an inventory-mapped lane. 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
|
||||
a mapping pays its full lanes; the carry starts on the pull requests after it
|
||||
merges. `ship_lanes_from` itself needs `min_gstack_version` 1.94.0.0 or later in
|
||||
`.gstack/ship-policy.json`, and `jobs` and `aliases` need the release that ships
|
||||
them. With that floor set, a gstack that has this check but not the key prints
|
||||
`needs gstack >= <version>; upgrade` (`PREGATE_GSTACK_TOO_OLD`) instead of a
|
||||
generic invalid-config error. A gstack released before the check (1.94.0.0
|
||||
included) still reports the unknown key as `PREGATE_CONFIG_INVALID`.
|
||||
|
||||
As a second guard, every `record-ci` row names the policy that mapped it
|
||||
(`origin/<base>@<sha>:.gstack/pregate.json`), and `gstack-evidence carried`, which
|
||||
the ship receipt reads, lists only rows whose policy is `origin/<the receipt's
|
||||
base>`. A row written by a run against another base never lands in a receipt.
|
||||
|
||||
A carried lane prints `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`
|
||||
and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
|
||||
(see above for what `<n>` counts) and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
|
||||
--label <label> --base <base> -- <command>`, which finds and verifies the run
|
||||
itself, so no caller hands it run ids or a test count: `source: "ci"`, its runs
|
||||
and profiles, the run's finish time as `ts`, bound to the clean tree), never a
|
||||
@@ -1021,6 +1156,8 @@ them (`ci_carried=<lane>:<run>/<attempt>`).
|
||||
--allow-repo-commands]` prints the verdict for HEAD; `wait [--timeout <s>]`
|
||||
polls the newest run at HEAD until it completes (run it in the background) and
|
||||
prints `CI_WAIT: green|red|running|none`, then `find`'s verdict on green.
|
||||
`find --policy-from head --simulate` is the dry run in
|
||||
[proving a new mapping](#prove-a-mapping).
|
||||
|
||||
<a id="ci-reuse"></a>
|
||||
### Main pushes reuse the merged pull request's run
|
||||
@@ -1165,6 +1302,38 @@ guard is a *command* with its own fix line. Older gstack refuses unknown keys
|
||||
that reads them ships. The worked gbrain configuration is in
|
||||
[gbrain worked configs](#gbrain-worked-configs).
|
||||
|
||||
<a id="guard-carry"></a>
|
||||
### A guard that already passed at this tree does not run again
|
||||
|
||||
A guard is skipped as passed when its exact command already passed at this tree
|
||||
(GST-1186). Two sources count, checked in this order:
|
||||
|
||||
1. **CI.** When the base branch's `ship_lanes_from` maps the guard's exact
|
||||
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>`, 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 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.
|
||||
|
||||
Anything else runs the guard as before, with the same timeout, stall rule and
|
||||
fix line. This matters for repos whose suites are guards rather than lanes
|
||||
(founderstar declares `check-quick`, `play-through` and `assets` as guards),
|
||||
which otherwise reran every suite /ship had just carried from CI.
|
||||
|
||||
Known limit: `ship_lanes_from` is read from `origin/<base>` only, so the pull
|
||||
request that adds a mapping cannot carry from it. Prove it with the dry run in
|
||||
[proving a new mapping](#prove-a-mapping) until it lands.
|
||||
|
||||
<a id="allowlist"></a>
|
||||
## Allowlist: benign findings the secrets check accepts
|
||||
|
||||
|
||||
@@ -3733,6 +3733,23 @@ the entry is reported as unverified, not `REGEN_STALE`.
|
||||
**Fix.** Run the printed install command (`bun install`, or the one matching the
|
||||
repository's lockfile), then rerun `gstack-regen check` or the pre-gate.
|
||||
|
||||
<a id="pregate-gstack-too-old"></a>
|
||||
### `PREGATE_GSTACK_TOO_OLD`
|
||||
|
||||
**Meaning.** `.gstack/pregate.json` uses a key this gstack does not know, and
|
||||
the same tree's `.gstack/ship-policy.json` sets `min_gstack_version` above this
|
||||
gstack. The key belongs to a newer gstack (one a later release adds), so the
|
||||
line reads `needs gstack >= <version>; upgrade`. The policy still refuses,
|
||||
exactly as `PREGATE_CONFIG_INVALID` would. Only a gstack that has this check
|
||||
prints it; one released before it (1.94.0.0 included) reports the same key as
|
||||
`PREGATE_CONFIG_INVALID`.
|
||||
|
||||
**Fix.** Upgrade gstack on this machine to at least the version named
|
||||
(`bin/gstack-upgrade`, or the session install with `--for <skill>`), then
|
||||
rerun. A repo adopting a new key raises `min_gstack_version` first, so an older
|
||||
gstack that has this check prints this instead of a generic invalid-config
|
||||
error.
|
||||
|
||||
<a id="pregate-config-invalid"></a>
|
||||
### `PREGATE_CONFIG_INVALID`
|
||||
|
||||
|
||||
+93
-18
@@ -14,7 +14,9 @@
|
||||
* max age, the run's own tree.json naming HEAD's tree);
|
||||
* every run executed tests;
|
||||
* with an inventory command, the runs' tree.json files cover every file it
|
||||
* prints (a lane carries only what CI provably ran);
|
||||
* prints (a lane carries only what CI provably ran); with named jobs instead
|
||||
* (a lane with no test files, GST-1189), each of those jobs succeeded in the
|
||||
* run's newest attempt, read by verifyRun like the profile's own jobs;
|
||||
* every run still stands after the inventory ran (runChanged).
|
||||
*
|
||||
* Anything else returns a refusal with a reason code, and the caller runs the
|
||||
@@ -25,12 +27,18 @@
|
||||
* bounded by a timeout, and returns green, red with the failing jobs, still
|
||||
* running, or none.
|
||||
*/
|
||||
import { runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence';
|
||||
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 };
|
||||
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number; receipt: string } | CarryRefused;
|
||||
/**
|
||||
* `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. `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; remainder?: string[]; receipt: string } | CarryRefused;
|
||||
|
||||
/** How many of HEAD's runs per workflow are tried, newest first. */
|
||||
const CANDIDATES = 10;
|
||||
@@ -38,12 +46,13 @@ const no = (reason: CarryRefusal, detail: string): CarryRefused => ({ ok: false,
|
||||
const newestFirst = (a: ApiRun, b: ApiRun) => (b.created_at ?? '').localeCompare(a.created_at ?? '') || b.id - a.id;
|
||||
|
||||
/** The newest run of `profile` on `head` that verifies; a refusal lists each candidate's reason, newest first. */
|
||||
function newestVerified(src: RunSource, name: string, profile: RunProfile, head: string): { ok: true; evidence: RunEvidence } | CarryRefused {
|
||||
function newestVerified(src: RunSource, name: string, profile: RunProfile, head: string, jobs: string[]): { ok: true; evidence: RunEvidence } | CarryRefused {
|
||||
if (jobs.length) profile = { ...profile, jobs: [...new Set([...profile.jobs, ...jobs])] };
|
||||
const listed = src.deps.runsForHead(profile.workflow, head).filter(r => profile.events.includes(r.event)).sort(newestFirst).slice(0, CANDIDATES);
|
||||
if (!listed.length) return no('no_run', `no ${profile.workflow} run (${profile.events.join(', ')}) on ${head.slice(0, 12)} yet`);
|
||||
const refusals: string[] = [];
|
||||
for (const run of listed) {
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now });
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged });
|
||||
if (v.ok) return v;
|
||||
refusals.push(`run ${run.id}: ${v.reason}`);
|
||||
}
|
||||
@@ -52,9 +61,13 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
|
||||
|
||||
/**
|
||||
* The CI evidence for HEAD's tree under `profiles` (one verified run each), or why there is none. `wtree` reads the
|
||||
* working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover.
|
||||
* 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 } }): 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');
|
||||
@@ -63,20 +76,24 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
|
||||
for (const name of o.profiles) {
|
||||
const profile = src.profiles[name];
|
||||
if (!profile) return no('no_profile', `${name} is not a run_profiles name on the base branch`);
|
||||
const v = newestVerified(src, name, profile, o.head);
|
||||
const v = newestVerified(src, name, profile, o.head, jobs);
|
||||
if (!v.ok) return v;
|
||||
runs.push(v.evidence);
|
||||
}
|
||||
const other = runs.find(e => e.tree !== o.tree);
|
||||
if (other) return no('tree_mismatch', `run ${other.run_id} tested tree ${other.tree.slice(0, 12)}, not ${o.tree.slice(0, 12)}`);
|
||||
const empty = runs.find(e => !e.files.length || e.tests === 0);
|
||||
const empty = jobs.length ? undefined : runs.find(e => !e.files.length || e.tests === 0);
|
||||
if (empty) return no('no_tests', `run ${empty.run_id}'s tree.json ${empty.files.length ? 'records 0 tests' : 'lists no test files'}`);
|
||||
if (o.inventory) {
|
||||
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);
|
||||
if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`);
|
||||
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);
|
||||
@@ -87,6 +104,13 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
|
||||
}
|
||||
const after = o.wtree();
|
||||
if (after !== o.tree) return no('tree_changed', `the working tree changed while the runs were verified (now ${after ? after.slice(0, 12) : 'unreadable'})`);
|
||||
if (jobs.length) {
|
||||
const gates = [...new Set(o.profiles.map(n => src.profiles[n]?.skippable?.gate).filter(Boolean))];
|
||||
const allSkipped = runs.every(e => jobs.every(j => e.skipped_jobs?.includes(j)));
|
||||
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', ...(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)}` };
|
||||
}
|
||||
@@ -109,24 +133,75 @@ 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 the command listing its test files. */
|
||||
export interface ShipLaneMapping { profiles: string[]; inventory: string }
|
||||
/**
|
||||
* `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[]; 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`
|
||||
* (GST-1190: the same lane run under another exact spelling, such as an env prefix). Never normalized: an alias is a
|
||||
* second exact command the base's owner declared, not a pattern.
|
||||
*/
|
||||
export function laneMapping(from: Record<string, ShipLaneMapping>, command: string): ShipLaneMapping | undefined {
|
||||
if (Object.prototype.hasOwnProperty.call(from, command)) return from[command];
|
||||
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`, 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 };
|
||||
|
||||
/**
|
||||
* What `gstack-evidence record-ci` reported, read from its own last record line onward (the per_file remainder's output comes
|
||||
* first, so a test printing look-alike lines cannot change the reading): the carried runs, count, unit and remainder, any
|
||||
* not-owed reason, or the last CARRY_NONE reason.
|
||||
*/
|
||||
export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; none?: string } {
|
||||
const lines = stderr.split('\n');
|
||||
const at = lines.findLastIndex(x => x.startsWith('gstack-evidence: recorded label=') && x.includes(' log=- source=ci runs='));
|
||||
const none = lines.findLast(x => x.startsWith('CARRY_NONE: '))?.slice('CARRY_NONE: '.length);
|
||||
if (at < 0) return none ? { none } : {};
|
||||
const got = / tests_ran=(\d+|n\/a)( unit=files)? log=- source=ci runs=(\S+)/.exec(lines[at]!);
|
||||
const here = / skipped_here=(\d+) tests_ran_here=(\S+)$/.exec(lines[at]!);
|
||||
const notOwed = lines.slice(at + 1).find(x => x.startsWith('gstack-evidence: not owed: '))?.slice('gstack-evidence: not owed: '.length);
|
||||
return { runs: got?.[3], tests: got?.[1], files: !!got?.[2], ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}` } : {}) };
|
||||
}
|
||||
|
||||
/** 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 }> {
|
||||
if (raw === undefined) return [];
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'ship_lanes_from', message: 'must be an object of exact lane command → { profiles: [run_profiles names], inventory: command }' }];
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'ship_lanes_from', message: 'must be an object of exact lane command → { profiles: [run_profiles names], inventory: command } or { profiles, jobs: [job names] }' }];
|
||||
const errors: Array<{ path: string; message: string }> = [];
|
||||
const oneLine = (s: unknown) => typeof s === 'string' && !!s.trim() && s.length <= 512 && !/[\0\r\n]/.test(s);
|
||||
const declared = new Map<string, string>(Object.keys(raw as object).map(k => [k, 'a key']));
|
||||
for (const [command, m] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const at = `ship_lanes_from[${JSON.stringify(command)}]`;
|
||||
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 }' }); continue; }
|
||||
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') errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory' });
|
||||
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' });
|
||||
else for (const a of aliases as string[]) {
|
||||
if (declared.has(a)) errors.push({ path: `${at}.aliases`, message: `${JSON.stringify(a)} is already ${declared.get(a)}; each exact command maps to one entry` });
|
||||
else declared.set(a, `an alias of ${JSON.stringify(command)}`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(o.profiles) || !o.profiles.length || o.profiles.some(p => typeof p !== 'string')) errors.push({ path: `${at}.profiles`, message: 'must be a non-empty array of run_profiles names' });
|
||||
else for (const p of o.profiles as string[]) if (!profileNames.includes(p)) errors.push({ path: `${at}.profiles`, message: `${JSON.stringify(p)} is not a run_profiles name` });
|
||||
if (!oneLine(o.inventory)) errors.push({ path: `${at}.inventory`, message: 'must be one command line under 512 characters that prints the lane\'s test files, one per line' });
|
||||
if (('inventory' in o) === ('jobs' in o)) errors.push({ path: at, message: 'declare exactly one of inventory (a command listing the lane\'s test files) or jobs (the CI jobs that run it)' });
|
||||
else if ('jobs' in o) {
|
||||
const jobs = o.jobs;
|
||||
if (!Array.isArray(jobs) || !jobs.length || jobs.some(j => typeof j !== 'string' || !oneLine(j) || j.length > 128) || new Set(jobs).size !== jobs.length) errors.push({ path: `${at}.jobs`, message: 'must be a non-empty array of distinct CI job names, each one line under 128 characters' });
|
||||
} else if (!oneLine(o.inventory)) errors.push({ path: `${at}.inventory`, message: 'must be one command line under 512 characters that prints the lane\'s test files, one per line' });
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -11,9 +11,17 @@
|
||||
* incident: a gbrain serial test landed without its manifest row and
|
||||
* passed both the isolation check and pregate; `test/cli-goldens.test.ts`
|
||||
* spawns the CLI as a subprocess, so no import graph selects it.
|
||||
*
|
||||
* GST-1186: a guard whose exact command already passed at this tree is not run
|
||||
* again. #3901's carry (gstack-evidence record-ci) carries it from a verified
|
||||
* CI run when the base's ship_lanes_from maps the command, else a counted
|
||||
* ledger pass of the same command at the same working tree stands
|
||||
* (gstack-evidence passed). ship_lanes_from is read from origin/<base> only, so
|
||||
* the PR that adds a mapping cannot carry from it yet.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { laneMapping, recordCiResult } from '../../ci-carry';
|
||||
import { matchesAny } from '../../ship-policy';
|
||||
import type { Guard } from '../config';
|
||||
import { baseInputs, sha256 } from '../context';
|
||||
@@ -23,8 +31,29 @@ import { checkScratch } from '../run-scratch';
|
||||
import { readRange, STALL_IDLE, supervise } from '../supervise';
|
||||
import type { EnvironmentCause } from '../types';
|
||||
import type { CheckDef, CheckResult, PregateContext } from '../types';
|
||||
import { runScriptSync } from '../../script-invocation';
|
||||
|
||||
interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null }
|
||||
interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null; prior?: string; carryNone?: string }
|
||||
|
||||
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 (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 got = recordCiResult(String(ci.stderr ?? ''));
|
||||
if (ci.status === 0 && got.runs) return { prior: got.notOwed ? `not owed: ${got.notOwed} runs=${got.runs}` : `carried runs=${got.runs}` };
|
||||
carryNone = got.none ?? `record-ci exit ${ci.status}`;
|
||||
}
|
||||
const local = evidence(['passed', '--', guard.command]);
|
||||
const hit = /^PASSED: (.+)$/m.exec(String(local.stdout ?? ''))?.[1];
|
||||
return local.status === 0 && hit ? { prior: `reused ${hit}`, carryNone } : { carryNone };
|
||||
}
|
||||
|
||||
async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): Promise<GuardRun> {
|
||||
const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs;
|
||||
@@ -53,9 +82,14 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
if (runtime.refusal) return { ...base, ...runtime.refusal, cause: 'environment', environment: launchCause('runtime', runtime.refusal.detail), lines: [`runtime: ${runtime.label}`, environmentLine(launchCause('runtime', runtime.refusal.detail))] };
|
||||
const dir = checkScratch(ctx.scratch, 'guards');
|
||||
const runs: GuardRun[] = [];
|
||||
for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir));
|
||||
for (const g of selected) {
|
||||
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}`];
|
||||
for (const r of runs) {
|
||||
if (r.carryNone) lines.push(`CI_CARRY: none guard=${r.guard.id} (${r.carryNone})`);
|
||||
if (r.prior) { lines.push(`GUARD: ${r.guard.id} ${r.prior}`); continue; }
|
||||
lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
|
||||
if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`));
|
||||
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
|
||||
@@ -68,7 +102,8 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
if (failed.length) return { ...withRuns, status: 'fail', cause: 'diff', code: 'PREGATE_GUARD_FAILED', detail: `${failed.length} guard(s) failed: ${failed.map(r => r.guard.id).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}${blocked.length ? `; blocked by the environment: ${blocked.map(r => r.guard.id).join(', ')}` : ''}`, fix: fixOf(failed) };
|
||||
if (blocked.length) return environmentResult(withRuns, blocked[0]!.environment!, `${blocked.length} guard(s) could not run on this machine: ${blocked.map(r => `${r.guard.id} (${r.environment!.kind})`).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}`);
|
||||
if (timedOut.length) return { ...withRuns, status: 'incomplete', code: timedOut.some(r => r.stalled) ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) did not finish: ${timedOut.map(r => `${r.guard.id} (${r.stalled ? 'stalled' : 'timed out'})`).join(', ')}`, fix: 'fix the hang, or raise the guard\'s timeout_s, --timeout or --stall-timeout; an unfinished guard never passes' };
|
||||
return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}` };
|
||||
const prior = runs.filter(r => r.prior).length;
|
||||
return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}${prior ? ` (${prior} from an earlier pass at this tree)` : ''}` };
|
||||
}
|
||||
|
||||
export const guardsCheck: CheckDef = { id: 'guards', stage: 'preflight', tier: 2, downgradable: false, needsRepoCommands: true, run };
|
||||
@@ -40,7 +40,7 @@ import { selectLanes, type Selection } from '../lane-selection';
|
||||
import { launchRuntime } from '../runtime';
|
||||
import { cargoModel, type CargoModel } from '../rust';
|
||||
import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise';
|
||||
import { verifyFromRun, type RunProfile } from '../../run-evidence';
|
||||
import { ranFiles, verifyFromRun, type RunProfile } from '../../run-evidence';
|
||||
import { matchesAny } from '../../ship-policy';
|
||||
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
|
||||
|
||||
@@ -643,9 +643,10 @@ function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: s
|
||||
const names = 'error' in check ? [] : fromRunProfiles(ctx, check.profiles);
|
||||
const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head);
|
||||
if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] };
|
||||
const ran = new Set(v.evidence.files);
|
||||
const ran = new Set(ranFiles(v.evidence));
|
||||
const passed = sel.localTests.filter(t => ran.has(t));
|
||||
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run`, passed, receipt: v.receipt };
|
||||
const skipped = sel.localTests.filter(t => v.evidence.skipped?.includes(t)).length;
|
||||
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run${skipped ? ` (${skipped} skipped every test in the run)` : ''}`, passed, receipt: v.receipt };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+30
-7
@@ -46,9 +46,13 @@
|
||||
* tree, the command listing the gate's test files they must
|
||||
* cover, and the command naming the selected files the gate
|
||||
* itself never runs (gstack: paid files)
|
||||
* ship_lanes_from exact /ship lane command → `{ profiles, inventory }`: the
|
||||
* run profiles whose verified runs at HEAD's tree carry that
|
||||
* lane, and the command listing its test files (lib/ci-carry.ts)
|
||||
* ship_lanes_from exact /ship lane command → `{ profiles, inventory }` or
|
||||
* `{ profiles, jobs }`: the run profiles whose verified runs
|
||||
* at HEAD's tree carry that lane, and either the command
|
||||
* listing its test files or the CI jobs that run it (a lane
|
||||
* with no test files, GST-1189), plus optional `aliases`:
|
||||
* other exact commands for the same lane (GST-1190)
|
||||
* (lib/ci-carry.ts)
|
||||
* allowlist `{ finding, paths?, pattern?, reason }` entries the `secrets`
|
||||
* check accepts as benign: a gstack-redact finding id or a
|
||||
* non-secret category, scoped by path globs and/or a regex
|
||||
@@ -74,7 +78,8 @@ import { shipLanesErrors, type ShipLaneMapping } from '../ci-carry';
|
||||
import type { ResultCodeName } from '../result-codes';
|
||||
import { runProfileErrors, type RemoteGateRuns, type RunProfile } from '../run-evidence';
|
||||
import { sealedErrors } from '../sealed-guard';
|
||||
import { containedRelative, globToRegExp } from '../ship-policy';
|
||||
import { containedRelative, globToRegExp, runningGstackVersion, SHIP_POLICY_PATH } from '../ship-policy';
|
||||
import { cmpVersion, parseVersion } from '../version-source';
|
||||
|
||||
export const PREGATE_CONFIG_PATH = '.gstack/pregate.json';
|
||||
export const PREGATE_CONFIG_SCHEMA = 'https://github.com/garrytan/gstack/docs/pregate.md#pregate-v1';
|
||||
@@ -291,6 +296,22 @@ function git(cwd: string, args: string[]): { ok: boolean; out: string } {
|
||||
return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* GST-1190: an unknown key in a policy whose project floor (min_gstack_version in the same tree's ship-policy.json) is
|
||||
* above this gstack belongs to a newer gstack. It still refuses, but as an upgrade, not as a typo.
|
||||
*/
|
||||
function newerGstackErrors(parsed: { config: PregateConfig; errors: ConfigError[]; declaresLanes: boolean }, policyText: string | null): typeof parsed {
|
||||
const unknown = (e: ConfigError) => e.message.startsWith('unknown key');
|
||||
if (!policyText || !parsed.errors.some(unknown)) return parsed;
|
||||
let min: unknown;
|
||||
try { min = (JSON.parse(policyText) as Record<string, unknown> | null)?.min_gstack_version; } catch { return parsed; }
|
||||
const running = runningGstackVersion();
|
||||
const want = typeof min === 'string' ? parseVersion(min) : null;
|
||||
const have = parseVersion(running);
|
||||
if (!want || !have || cmpVersion(want, have) <= 0) return parsed;
|
||||
return { ...parsed, errors: parsed.errors.map(e => unknown(e) ? { ...e, code: 'PREGATE_GSTACK_TOO_OLD' as const, message: `needs gstack >= ${min as string}; upgrade (this gstack is ${running} and does not know this key)` } : e) };
|
||||
}
|
||||
|
||||
export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad {
|
||||
const ref = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
||||
@@ -302,13 +323,15 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks:
|
||||
if (!entry.ok || entry.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} could not be read and git cannot confirm it is absent` };
|
||||
return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
|
||||
}
|
||||
return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' };
|
||||
const policy = git(repoRoot, ['show', `${sha.out}:${SHIP_POLICY_PATH}`]);
|
||||
return { ...newerGstackErrors(parseConfig(show.out, knownChecks), policy.ok ? policy.out : null), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' };
|
||||
}
|
||||
|
||||
export function loadConfigFromHead(repoRoot: string, knownChecks: readonly string[] = []): ConfigLoad {
|
||||
const file = path.join(repoRoot, PREGATE_CONFIG_PATH);
|
||||
if (!fs.existsSync(file)) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `head (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
|
||||
return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' };
|
||||
const policy = path.join(repoRoot, SHIP_POLICY_PATH);
|
||||
return { ...newerGstackErrors(parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), fs.existsSync(policy) ? fs.readFileSync(policy, 'utf8') : null), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' };
|
||||
}
|
||||
|
||||
/** A symlink to nothing on the way to `file` (a dotfiles link not yet cloned): a policy the user meant to have, not an absent one. */
|
||||
@@ -365,7 +388,7 @@ export function explainConfig(): string {
|
||||
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
|
||||
'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)',
|
||||
'remote_gate_runs: { profiles, inventory, skipped? }; the run profiles that together clear the remote gate obligation, the command listing the gate\'s test files they must cover, and an optional command printing which selected files (PREGATE_FILES_FILE) the gate itself does not run, which then need no run (docs/pregate.md#run-evidence)',
|
||||
'ship_lanes_from: exact /ship lane command → { profiles, inventory }; a verified run of each profile at HEAD\'s clean tree whose tree.json covers the inventory carries the lane instead of running it again (docs/pregate.md#ci-carry)',
|
||||
'ship_lanes_from: exact /ship lane command → { profiles, inventory } or { profiles, jobs }, with optional aliases (other exact commands for the same lane); a verified run of each profile at HEAD\'s clean tree whose tree.json covers the inventory, or whose newest attempt ran every named job to success, carries the lane instead of running it again (docs/pregate.md#ci-carry)',
|
||||
`allowlist: [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted. finding is a gstack-redact id (env.kv, google.api_key, pii.phone.e164, …) or a category (${ALLOWLISTABLE_CATEGORIES.join(', ')}); paths are globs; pattern is a regex matched at the finding's position in the added line; one of the two is required, reason always. Never ${NON_ALLOWLISTABLE.join(' or ')} or engine.* (docs/pregate.md#allowlist)`,
|
||||
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
|
||||
].join('\n') + '\n';
|
||||
|
||||
@@ -267,7 +267,7 @@ function userPolicyChange(file: PregateFile): string | null {
|
||||
|
||||
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck; flaky?: FlakyInput }
|
||||
/** What a run clear needs: the base branch's run profiles, the verifier's dependencies, the clock, and the remote gate's inventory. */
|
||||
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string }; failures?: FailureDeps }
|
||||
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; unchanged?: (p: RunProfile) => string | null; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string }; failures?: FailureDeps }
|
||||
export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution; exception?: PregateException } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,20 +11,58 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ghArtifactDeps, ghRunDeps, runInventory } from '../run-evidence';
|
||||
import { ghArtifactDeps, ghRunDeps, runInventory, type RunProfile } from '../run-evidence';
|
||||
import { wtreeFingerprint } from '../script-invocation';
|
||||
import { resolveBaseBranch } from '../ship-policy';
|
||||
import { normalizeRemoteUrl } from '../ship-queue-train-record';
|
||||
import { REMOTE_GATE_LANE } from './checks/lanes';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase } from './config';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from './config';
|
||||
import type { RunCheck } from './index';
|
||||
|
||||
/**
|
||||
* `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is
|
||||
* recorded unverified only once the base shows it has none.
|
||||
* 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 buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root)): RunCheck | { error: string; profiled?: boolean } {
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
export function gateInputsChanged(root: string, base: string, p: RunProfile): string | null {
|
||||
const ref = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const wf = `.github/workflows/${p.workflow}`;
|
||||
const gate = p.skippable?.gate;
|
||||
if (!gate) return 'the profile names no gate job';
|
||||
const blob = (rev: string, f: string) => { const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${rev}:${f}`], { cwd: root, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : null; };
|
||||
const show = spawnSync('git', ['show', `${ref}:${wf}`], { cwd: root, encoding: 'utf8', timeout: 30_000, maxBuffer: 8 * 1024 * 1024 });
|
||||
if (show.status !== 0) return `${wf} is not on ${ref}`;
|
||||
const lines = show.stdout.split('\n');
|
||||
const jobsAt = lines.findIndex(l => /^jobs:\s*$/.test(l));
|
||||
const blocks: string[][] = [];
|
||||
for (let i = jobsAt + 1; jobsAt >= 0 && i < lines.length && !/^\S/.test(lines[i]!); i++) {
|
||||
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 => /^ name:/.test(l) && l.trim() === `name: ${gate}`));
|
||||
if (!block) return `${wf} on ${ref} has no job ${gate}`;
|
||||
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;
|
||||
if (a === null || a !== b) return `${f} differs from ${ref}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is
|
||||
* recorded unverified only once the base shows it has none. `policy: 'head'` reads the branch's profiles instead, only
|
||||
* for gstack-ship-preflight --simulate, a dry run that records and clears nothing.
|
||||
*/
|
||||
export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root), policy: 'base' | 'head' = 'base'): RunCheck | { error: string; profiled?: boolean } {
|
||||
const loaded = policy === 'head' ? loadConfigFromHead(root) : loadConfigFromBase(root, base);
|
||||
if (isConfigUnavailable(loaded)) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is unreadable (${loaded.detail}); run profiles come only from the base`, profiled: true };
|
||||
if (loaded.errors.length) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is invalid: ${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, profiled: true };
|
||||
const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
@@ -32,6 +70,7 @@ export function buildRunCheck(root: string, lane: string | null, allowRepoComman
|
||||
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === null ? false : lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
|
||||
return {
|
||||
repo, deps: ghRunDeps(repo, root), failures: ghArtifactDeps(repo), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
|
||||
unchanged: p => gateInputsChanged(root, base, p),
|
||||
inventory: (command, tree, selected) => {
|
||||
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
|
||||
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
|
||||
@@ -16,6 +16,11 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'the ship policy sets min_gstack_version above this gstack, so this gstack refuses to read it',
|
||||
fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for <skill>) to the version named, then rerun',
|
||||
},
|
||||
PREGATE_GSTACK_TOO_OLD: {
|
||||
anchor: 'pregate-gstack-too-old',
|
||||
summary: 'the pregate policy uses a key this gstack does not know, and the project floor (min_gstack_version in .gstack/ship-policy.json) is above this gstack, so the key belongs to a newer gstack; the policy stays refused',
|
||||
fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for <skill>) to at least the version named, then rerun',
|
||||
},
|
||||
QUEUE_NOT_READY: {
|
||||
anchor: 'queue-not-ready',
|
||||
summary: 'the lane is not ready to report: the working tree, HEAD or CI state does not match the pushed PR head',
|
||||
|
||||
+46
-11
@@ -42,7 +42,11 @@ import * as path from 'node:path';
|
||||
import { sha256Hex, writeReceipt } from './egress-receipt';
|
||||
import { runBashSync } from './script-invocation';
|
||||
|
||||
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string }
|
||||
/**
|
||||
* `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 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;
|
||||
@@ -50,7 +54,7 @@ export interface ApiRun {
|
||||
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null;
|
||||
}
|
||||
export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null }
|
||||
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown }
|
||||
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown; skipped_files?: unknown }
|
||||
|
||||
/** What a red run's reader needs beyond RunDeps (lib/pregate/flaky-exception.ts): every file of one artifact, and a pull request's head. */
|
||||
export interface RunArtifactDeps {
|
||||
@@ -69,14 +73,15 @@ export interface RunDeps {
|
||||
}
|
||||
|
||||
export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | 'workflow_mismatch' | 'event_mismatch' | 'run_in_progress'
|
||||
| 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch'
|
||||
| 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_skipped' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch'
|
||||
| 'artifact_reused' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error';
|
||||
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null }
|
||||
/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */
|
||||
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[]; skipped_jobs?: string[] }
|
||||
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
|
||||
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request'];
|
||||
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os'];
|
||||
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os', 'skippable'];
|
||||
const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
|
||||
const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/;
|
||||
export const DEFAULT_MAX_AGE_HOURS = 24;
|
||||
@@ -113,6 +118,14 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
|
||||
if (typeof p.artifact !== 'string' || !NAME.test(p.artifact)) errors.push({ path: `${at}.artifact`, message: 'must be the artifact name holding tree.json' });
|
||||
if ('max_age_hours' in p && (!Number.isInteger(p.max_age_hours) || (p.max_age_hours as number) < 1 || (p.max_age_hours as number) > 168)) errors.push({ path: `${at}.max_age_hours`, message: 'must be an integer from 1 to 168' });
|
||||
if ('os' in p && (typeof p.os !== 'string' || !/^(Linux|macOS|Windows)$/.test(p.os))) errors.push({ path: `${at}.os`, message: 'must be Linux, macOS or Windows' });
|
||||
if ('skippable' in p) {
|
||||
const s = p.skippable as Record<string, unknown> | null;
|
||||
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) || !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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gateRuns !== undefined && gateRuns !== null) {
|
||||
@@ -135,13 +148,13 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
|
||||
const fail = (reason: RunRefusal, detail: string): RunVerdict => ({ ok: false, reason, detail });
|
||||
/** When a run's newest attempt started. A run vetoes the candidate when it started at or after the second the candidate was created: a red re-run of an older run counts, and re-running the candidate does not hide a failure in between. */
|
||||
const started = (r: ApiRun): string => r.run_started_at ?? r.created_at ?? '';
|
||||
const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
|
||||
export const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
|
||||
|
||||
/**
|
||||
* The verdict for run `runRef` under `profile`. `vetoHeads` are further heads
|
||||
* whose newer runs of the same workflow can veto (the consumer's own head).
|
||||
*/
|
||||
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date }): RunVerdict {
|
||||
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date; unchanged?: (p: RunProfile) => string | null }): RunVerdict {
|
||||
const id = parseRunRef(o.runRef, o.repo);
|
||||
if (id === null) return fail('run_ref_invalid', `${JSON.stringify(o.runRef)} is not a run id or a run URL on ${o.repo}`);
|
||||
const p = o.profile;
|
||||
@@ -162,10 +175,22 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
const maxAge = p.max_age_hours ?? DEFAULT_MAX_AGE_HOURS;
|
||||
if (!Number.isFinite(finished)) return fail('api_error', `${at} has no completion time`);
|
||||
if (o.now.getTime() - finished > maxAge * 3600_000) return fail('run_stale', `${at} finished ${finishedAt}, more than ${maxAge} h before now`);
|
||||
const skippedJobs: string[] = [];
|
||||
for (const name of p.jobs) {
|
||||
const hits = jobs.filter(j => j.name === name);
|
||||
if (hits.length === 0) return fail('job_missing', `${at} has no job named ${name}`);
|
||||
if (hits.length > 1) return fail('job_ambiguous', `${at} has ${hits.length} jobs named ${name}`);
|
||||
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';
|
||||
if (changed) return fail('job_skipped', `${at}'s ${name} job was skipped, but ${changed}, so this branch decided the skip`);
|
||||
skippedJobs.push(name);
|
||||
continue;
|
||||
}
|
||||
if (hits[0]!.conclusion !== 'success') return fail('job_not_success', `${at}'s ${name} job concluded ${hits[0]!.conclusion}`);
|
||||
}
|
||||
const heads = [...new Set([run.head_sha, ...(o.vetoHeads ?? []).filter(h => SHA.test(h))])];
|
||||
@@ -195,11 +220,13 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`);
|
||||
if (rec.files !== undefined && (!Array.isArray(rec.files) || rec.files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} files is not a list of paths`);
|
||||
if (rec.tests !== undefined && (!Number.isInteger(rec.tests) || (rec.tests as number) < 0)) return fail('artifact_mismatch', `${at}'s ${p.artifact} tests is not a non-negative integer`);
|
||||
if (rec.skipped_files !== undefined && (!Array.isArray(rec.skipped_files) || rec.skipped_files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} skipped_files is not a list of paths`);
|
||||
const files = ((rec.files ?? []) as string[]).map(normalPath);
|
||||
const tests = rec.tests === undefined ? null : (rec.tests as number);
|
||||
const changed = runChanged(deps, id, run.run_attempt);
|
||||
if (changed) return fail('run_changed', changed);
|
||||
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests } };
|
||||
const skipped = rec.skipped_files === undefined ? {} : { skipped: (rec.skipped_files as string[]).map(normalPath) };
|
||||
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests, ...skipped, ...(skippedJobs.length ? { skipped_jobs: skippedJobs } : {}) } };
|
||||
} catch (e: any) {
|
||||
return fail('api_error', `reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`);
|
||||
}
|
||||
@@ -219,13 +246,21 @@ export function runInventory(command: string, cwd: string, env?: Record<string,
|
||||
}
|
||||
|
||||
/** The files in `inventory` that no verified run's tree.json lists. */
|
||||
export function uncoveredFiles(inventory: string[], evidence: RunEvidence[]): string[] {
|
||||
const ran = new Set(evidence.flatMap(e => e.files.map(normalPath)));
|
||||
/** A run's files minus the ones whose every test skipped there (GST-1190): only those ran anything. Older tree.json: all files. */
|
||||
export const ranFiles = (e: RunEvidence): string[] => { const skipped = new Set(e.skipped ?? []); return e.files.map(normalPath).filter(f => !skipped.has(f)); };
|
||||
|
||||
/**
|
||||
* The inventory files no run covers. `skipped: 'uncovered'` (a carry, which drops tests on a machine where they might run)
|
||||
* also counts a file every test of which skipped in the run; `'covered'` (the remote gate, a Linux runner like CI) does not.
|
||||
*/
|
||||
export function uncoveredFiles(inventory: string[], evidence: RunEvidence[], skipped: 'uncovered' | 'covered' = 'covered'): string[] {
|
||||
const ran = new Set(evidence.flatMap(e => (skipped === 'uncovered' ? ranFiles(e) : e.files.map(normalPath))));
|
||||
return inventory.filter(f => !ran.has(normalPath(f)));
|
||||
}
|
||||
|
||||
/** What a run check reads: the repository, the verifier's dependencies, the base branch's profiles and the clock. */
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date }
|
||||
/** `unchanged` (findCarry only): null when a profile's workflow and gate inputs match the base, else which file differs. */
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date; unchanged?: (p: RunProfile) => string | null }
|
||||
export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string };
|
||||
|
||||
/**
|
||||
|
||||
+12
-10
@@ -10,6 +10,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { fragmentSlug } from './changelog-fragments';
|
||||
import { laneMapping, recordCiResult, type ShipLaneMapping } from './ci-carry';
|
||||
import { isConfigUnavailable, loadConfigFromBase } from './pregate/config';
|
||||
import { parseUnifiedDiff, versionAtRef } from './restamp';
|
||||
import { branchSides, stampOnlyLine } from './stamp-only';
|
||||
@@ -174,15 +175,15 @@ 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 }
|
||||
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, byte for byte (the ledger binds its sha256). */
|
||||
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, { profiles: string[]; inventory: 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> {
|
||||
const loaded = loadConfigFromBase(cwd, base);
|
||||
const map = new Map<string, { profiles: string[]; inventory: string }>();
|
||||
const map = new Map<string, ShipLaneMapping>();
|
||||
if (isConfigUnavailable(loaded) || loaded.errors.length) return map;
|
||||
const from = loaded.config.ship_lanes_from;
|
||||
for (const l of list) if (Object.prototype.hasOwnProperty.call(from, l.command)) map.set(l.label, from[l.command]!);
|
||||
for (const l of list) { const m = laneMapping(from, l.command); if (m) map.set(l.label, m); }
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -192,9 +193,9 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co
|
||||
*/
|
||||
export function carryLane(cwd: string, base: string, l: { label: string; command: string }): LaneResult {
|
||||
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000);
|
||||
const got = / tests_ran=(\d+) log=- source=ci runs=(\S+)/.exec(rec.stderr);
|
||||
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[1]!, log: '-', carried: got[2]! };
|
||||
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: /^CARRY_NONE: (.+)$/m.exec(rec.stderr)?.[1] ?? `record_failed: ${firstLine(rec.stderr)}` };
|
||||
const got = recordCiResult(rec.stderr);
|
||||
if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}) };
|
||||
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +205,7 @@ export function carryLane(cwd: string, base: string, l: { label: string; command
|
||||
*/
|
||||
export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity, opts: { base?: string; reuse?: boolean } = {}): Promise<LaneResult[]> {
|
||||
const out: LaneResult[] = [];
|
||||
const mapped = opts.base ? mappedLanes(cwd, opts.base, list) : new Map<string, { profiles: string[]; inventory: string }>();
|
||||
const mapped = opts.base ? mappedLanes(cwd, opts.base, list) : new Map<string, ShipLaneMapping>();
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < list.length) {
|
||||
@@ -225,7 +226,8 @@ 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.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}`, ok: true };
|
||||
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}${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 };
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findCarry, laneMapping, mappingCoverage } from './ci-carry';
|
||||
import { defaultBaseBranch } from './default-base';
|
||||
import { isConfigUnavailable, loadConfigFromHead } from './pregate/config';
|
||||
import { buildRunCheck } from './pregate/run-check';
|
||||
import { classifyDiff } from './review-depth';
|
||||
import { wtreeFingerprint } from './script-invocation';
|
||||
import { audit } from './ship-audit';
|
||||
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
|
||||
|
||||
@@ -20,6 +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 [--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:; 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)
|
||||
|
||||
@@ -164,6 +173,40 @@ async function tests(cwd: string, base: string, list: { label: string; command:
|
||||
r.next('Step 6: select eval suites for prompt-related changes with the project\'s selector, then Step 7');
|
||||
}
|
||||
|
||||
/**
|
||||
* --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. 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 }[], 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: 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, 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();
|
||||
const wtree = () => { const fp = wtreeFingerprint({ cwd }); return 'tree' in fp ? fp.tree : null; };
|
||||
let mapped = 0;
|
||||
let carried = 0;
|
||||
for (const l of list) {
|
||||
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}` : ''}${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);
|
||||
}
|
||||
|
||||
export async function preflight(argv: readonly string[], cwd: string): Promise<{ text: string; exit: number }> {
|
||||
const { flag, has } = flagReader(argv);
|
||||
const base = flag('--base');
|
||||
@@ -178,6 +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, 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 };
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.94.16",
|
||||
"version": "1.94.17",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -388,6 +388,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-release.test.ts',
|
||||
reason: 'spawns bin/gstack-ship-release (shebang) directly and writes #!/bin/sh gh stubs through test/helpers/ship-bin-fixture.ts; windows-latest exits before the bin runs (windows-free-shard 3, run 41073592483)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-ci-carry.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-evidence, gstack-ci-carry and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-release.test.ts; windows-latest fails every bin case at the first spawn (windows-scoped-shard, run 41607840098)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-rereview-triggers.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-release.test.ts; windows-latest fails every bin case at the first spawn (windows-free-shard 6, run 41193336209)',
|
||||
|
||||
+1
-1
@@ -871,7 +871,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
+1
-1
@@ -592,7 +592,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
@@ -229,7 +229,7 @@ at this exact, clean tree instead of running here: `LANE: <label> carried run=<i
|
||||
Before the first push run only targeted tests and the repro (the full suite too when the diff touches
|
||||
merge-check code: `lib/ship-*`, `lib/pregate/*`, the ship and pregate bins,
|
||||
`scripts/lib/shard-engine.ts`). Push early so CI runs beside the review: commit, `gstack-ship-release push
|
||||
--branch <branch> --early` (sealed and secrets scans first), open a draft PR, then `gstack-ci-carry wait` in
|
||||
--branch <branch> --early` (sealed, secrets and guards first), open a draft PR, then `gstack-ci-carry wait` in
|
||||
the background; once it prints `CI_WAIT: green`, rerun `--tests-only` and the mapped lane is carried. A red
|
||||
run is a failed lane: triage it below. Batch review fixes into one commit and one push. `CI_CARRY: none
|
||||
label=<l> (<reason>)` names why a mapped lane ran here (a dirty tree, no green run at HEAD yet, a fork or stale
|
||||
|
||||
@@ -25,7 +25,7 @@ at this exact, clean tree instead of running here: `LANE: <label> carried run=<i
|
||||
Before the first push run only targeted tests and the repro (the full suite too when the diff touches
|
||||
merge-check code: `lib/ship-*`, `lib/pregate/*`, the ship and pregate bins,
|
||||
`scripts/lib/shard-engine.ts`). Push early so CI runs beside the review: commit, `gstack-ship-release push
|
||||
--branch <branch> --early` (sealed and secrets scans first), open a draft PR, then `gstack-ci-carry wait` in
|
||||
--branch <branch> --early` (sealed, secrets and guards first), open a draft PR, then `gstack-ci-carry wait` in
|
||||
the background; once it prints `CI_WAIT: green`, rerun `--tests-only` and the mapped lane is carried. A red
|
||||
run is a failed lane: triage it below. Batch review fixes into one commit and one push. `CI_CARRY: none
|
||||
label=<l> (<reason>)` names why a mapped lane ran here (a dirty tree, no green run at HEAD yet, a fork or stale
|
||||
|
||||
@@ -40,9 +40,11 @@ describe('findCarry', () => {
|
||||
const v = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, inventory: () => ['test/a.test.ts', 'test/b.test.ts'] });
|
||||
expect(v.ok).toBe(true);
|
||||
if (!v.ok) return;
|
||||
expect(v.tests).toBe(40);
|
||||
expect([v.tests, v.unit]).toEqual([2, 'files']);
|
||||
expect(v.runs.map(e => e.run_id)).toEqual([4]);
|
||||
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb tests=40 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
|
||||
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb files=2 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
|
||||
const whole = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean });
|
||||
expect(whole.ok && [whole.tests, whole.unit, whole.receipt.split(' ')[2]]).toEqual([40, undefined, 'tests=40']);
|
||||
});
|
||||
|
||||
// Value: protects=A2, a carry binds to the working-tree fingerprint before and after; fails_when=a dirty tree or a tree edited during the lookup carries
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('gstack-evidence run --reuse (A5)', () => {
|
||||
describe('gstack-evidence record-ci and carried (A4)', () => {
|
||||
const ciRow = () => {
|
||||
const tree = wtree();
|
||||
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'] };
|
||||
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'], policy: 'origin/main@000000000000:.gstack/pregate.json' };
|
||||
};
|
||||
|
||||
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source, and the receipt lists it only at its own tree;
|
||||
@@ -121,7 +121,8 @@ describe('gstack-evidence record-ci and carried (A4)', () => {
|
||||
expect(chk.status).toBe(0);
|
||||
expect(chk.stdout).toContain('EVIDENCE: FRESH label=free exit=0 tests_ran=900');
|
||||
expect(chk.stdout).toContain('source=ci runs=41/2');
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
|
||||
expect(JSON.parse(run(['carried', '--base', 'main', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
|
||||
expect(JSON.parse(run(['carried', '--base', 'release', '--json']).stdout)).toEqual([]);
|
||||
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
|
||||
gitIn(repo, 'commit -qam two');
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
|
||||
|
||||
+1
-1
@@ -872,7 +872,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
+1
-1
@@ -871,7 +871,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
+1
-1
@@ -878,7 +878,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
+1
-1
@@ -880,7 +880,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
|
||||
```
|
||||
|
||||
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
|
||||
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
|
||||
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
|
||||
|
||||
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
|
||||
|
||||
@@ -7,8 +7,11 @@ import * as path from 'node:path';
|
||||
|
||||
export interface FakeWorld {
|
||||
runs: Array<Record<string, unknown> & { id: number; run_attempt: number; head_sha: string; path: string }>;
|
||||
/** Keyed `<run id>/<attempt>` for one attempt's jobs, else `<run id>` for every attempt. */
|
||||
jobs: Record<string, Array<{ name: string; conclusion: string | null; completed_at?: string }>>;
|
||||
trees: Record<string, Record<string, unknown>>;
|
||||
/** Once `marker` exists, the next run read writes `content` to `file` (a working-tree edit inside a run recheck). */
|
||||
editWhenArmed?: { marker: string; file: string; content: string };
|
||||
}
|
||||
|
||||
/** The stub body for shipFixture's gh(): run this file with the world beside it. */
|
||||
@@ -27,11 +30,13 @@ if (import.meta.main) {
|
||||
if ((m = /\/actions\/runs\/(\d+)$/.exec(route!))) {
|
||||
const run = world.runs.find(r => r.id === Number(m![1]));
|
||||
if (!run) { process.stderr.write('HTTP 404'); process.exit(1); }
|
||||
const e = world.editWhenArmed;
|
||||
if (e && fs.existsSync(e.marker)) fs.writeFileSync(e.file, e.content);
|
||||
process.stdout.write(JSON.stringify(run));
|
||||
} else if ((m = /\/actions\/workflows\/([^/]+)\/runs$/.exec(route!))) {
|
||||
lines(world.runs.filter(r => r.head_sha === q.get('head_sha') && r.path === `.github/workflows/${m![1]}`));
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/attempts\/\d+\/jobs$/.exec(route!))) {
|
||||
lines(world.jobs[m[1]!] ?? []);
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/attempts\/(\d+)\/jobs$/.exec(route!))) {
|
||||
lines(world.jobs[`${m[1]}/${m[2]}`] ?? world.jobs[m[1]!] ?? []);
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/artifacts$/.exec(route!))) {
|
||||
lines(world.trees[m[1]!] ? [{ name: q.get('name'), size_in_bytes: 200, expired: false }] : []);
|
||||
} else { process.stderr.write(`fake gh: no route for ${p}`); process.exit(1); }
|
||||
|
||||
@@ -64,7 +64,8 @@ describe('verifyRun', () => {
|
||||
['run_stale', world({ run: { ...base.run, updated_at: '2026-10-28T10:00:00Z' } })],
|
||||
['job_missing', world({ jobs: [{ name: 'lint', conclusion: 'success' }] })],
|
||||
['job_ambiguous', world({ jobs: [{ name: 'full-suite', conclusion: 'success' }, { name: 'full-suite', conclusion: 'success' }] })],
|
||||
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
|
||||
['job_skipped', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
|
||||
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'cancelled' }] })],
|
||||
['artifact_missing', world({ artifact: new Error('no artifact named full-suite-tree') })],
|
||||
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_id: 99 } })],
|
||||
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_attempt: 2 } })],
|
||||
|
||||
+455
-7
@@ -27,14 +27,15 @@ const runs = (f: ShipFx, name: string) => { const p = path.join(f.root, name); r
|
||||
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||
|
||||
/** One run of free.yml at HEAD (id 7 unless named), green with a tree.json naming HEAD's tree, unless overridden. */
|
||||
function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<string, unknown>; others?: Array<Record<string, unknown>>; jobs?: Array<{ name: string; conclusion: string | null }> } = {}): void {
|
||||
function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<string, unknown>; others?: Array<Record<string, unknown>>; jobs?: Array<{ name: string; conclusion: string | null }>; attempts?: Record<string, Array<{ name: string; conclusion: string | null }>> } = {}): void {
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' }, ...over.run };
|
||||
const w: FakeWorld = {
|
||||
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) }] },
|
||||
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 } },
|
||||
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: 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));
|
||||
@@ -51,7 +52,7 @@ describe('Step 5 lanes: CI carry and the tree cache', () => {
|
||||
world(f);
|
||||
const first = preflight(f);
|
||||
expect(first.status).toBe(0);
|
||||
expect(lane(first.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
|
||||
expect(lane(first.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
|
||||
expect(lane(first.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+$/);
|
||||
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
|
||||
const again = preflight(f);
|
||||
@@ -103,7 +104,7 @@ describe('Step 5 lanes: CI carry and the tree cache', () => {
|
||||
expect(ev(['check', '--label', 'free', '--expect-cmd', FREE]).stdout).not.toContain('EVIDENCE: FRESH');
|
||||
expect(JSON.parse(ev(['carried', '--json']).stdout)).toEqual([]);
|
||||
const ok = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=12 log=- source=ci runs=7/1 policy=origin/main@')]);
|
||||
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files log=- source=ci runs=7/1 policy=origin/main@')]);
|
||||
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect(row.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/pregate\.json$/);
|
||||
});
|
||||
@@ -151,8 +152,8 @@ describe('finish, push --early and accept (A1)', () => {
|
||||
world(f);
|
||||
const ok = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
expect(ok.stdout).toContain('CI_WAIT: green ci/free run=7/1');
|
||||
expect(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
|
||||
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=12 .* source=ci runs=7\/1$/);
|
||||
expect(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
|
||||
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=2 unit=files .* source=ci runs=7\/1$/);
|
||||
expect(ok.stdout).toContain('CI_CARRY: accepted ci-carry tree=');
|
||||
expect(ok.stdout).toContain('NEXT: Step 18');
|
||||
expect(ok.status).toBe(0);
|
||||
@@ -192,3 +193,450 @@ describe('gstack-pregate tests stage and gstack-ci-carry', () => {
|
||||
expect(cli(['bogus']).status).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1186: pregate guards carry like lanes (founderstar shape: guards only, no lanes)', () => {
|
||||
// Value: protects=a guard whose exact command the base's ship_lanes_from maps is carried from the verified CI run at this tree, and a guard
|
||||
// whose exact command already passed at this tree in the ledger is reused, so guard-only repos stop rerunning suites CI or /ship already ran;
|
||||
// fails_when=check-quick reruns beside a green CI run, play-through reruns after a same-tree ledger pass, or a dirty tree still carries;
|
||||
// why_new=FOUN-74 (founderstar GST-1184): about 6 minutes of guard suites rerun per /ship; seam=gstack-evidence record-ci and passed; no_claim=CI
|
||||
const script = (name: string, tests: number) => `printf x >> ../${name}-runs\nprintf ' ${tests} pass\\n 0 fail\\nRan ${tests} tests across 1 file. [1.00ms]\\n' >&2\n`;
|
||||
const CHECK = 'bash tests/check.sh --quick';
|
||||
const PLAY = 'bash tests/play-through.sh';
|
||||
const guard = (id: string, command: string) => ({ id, when: ['src/**'], command, fix: `rerun ${id}` });
|
||||
const FOUNDERSTAR = {
|
||||
...BASE,
|
||||
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [CHECK]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } },
|
||||
guards: [guard('check-quick', CHECK), guard('play-through', PLAY), guard('assets', 'bash tests/assets.sh')] }, null, 2) + '\n',
|
||||
'tests/check.sh': script('check', 4), 'tests/play-through.sh': script('play', 2), 'tests/assets.sh': script('assets', 1),
|
||||
};
|
||||
const guards = (f: ShipFx) => f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
|
||||
const counts = (f: ShipFx) => [runs(f, 'check-runs'), runs(f, 'play-runs'), runs(f, 'assets-runs')];
|
||||
|
||||
test('check-quick carries from CI, play-through reuses a same-tree ledger pass, assets runs; a dirty tree carries nothing', () => {
|
||||
const f = shipFixture(FOUNDERSTAR); fixtures.push(f);
|
||||
world(f);
|
||||
const first = guards(f);
|
||||
expect(first.status, first.stdout + first.stderr).toBe(0);
|
||||
expect(first.stdout).toContain('GUARD: check-quick carried runs=7/1');
|
||||
expect(first.stdout).toContain('3 guard(s) passed: check-quick, play-through, assets (1 from an earlier pass at this tree)');
|
||||
expect(counts(f)).toEqual([0, 1, 1]);
|
||||
expect(f.run(path.join(BIN, 'gstack-evidence'), ['run', '--label', 'play', '--', PLAY]).status).toBe(0);
|
||||
const second = guards(f);
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stdout).toMatch(/GUARD: play-through reused label=play source=local ts=\S+/);
|
||||
expect(counts(f)).toEqual([0, 2, 2]);
|
||||
f.write({ 'src/b.ts': 'export const b = 3;\n' });
|
||||
const dirty = guards(f);
|
||||
expect(dirty.stdout).toMatch(/CI_CARRY: none guard=check-quick \(dirty_tree/);
|
||||
expect(dirty.stdout).not.toContain('reused');
|
||||
expect(counts(f)).toEqual([1, 3, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1189: a lane carries by named CI jobs instead of an inventory', () => {
|
||||
// Value: protects=a ship_lanes_from entry that declares { profiles, jobs } carries only when every named job succeeded in the newest attempt
|
||||
// of the verified run at HEAD's tree, and refuses with a named reason otherwise; fails_when=a typecheck-only lane cannot carry (no test
|
||||
// files to inventory), or carries beside a missing, skipped or older-attempt job, or another tree's run;
|
||||
// why_new=gbrain run 41564913688 (tree 13570abc): verify and typecheck passed in CI but no inventory could name their files; seam=fake gh
|
||||
const VERIFY = 'bun run verify';
|
||||
const pregateJson = (entry: Record<string, unknown>) => JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], ...entry } } }, null, 2) + '\n';
|
||||
const GBRAIN = { ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify', 'typecheck'] }) };
|
||||
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'verify', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
|
||||
const NO_FILES = { files: [], tests: 0 };
|
||||
const gbrain = () => { const f = shipFixture(GBRAIN); fixtures.push(f); return f; };
|
||||
const find = (f: ShipFx) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']);
|
||||
|
||||
test('every named job green at the same tree carries, with no test files; record-ci records the jobs', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL, tree: NO_FILES });
|
||||
const ok = find(f);
|
||||
expect([ok.status, ok.stdout]).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/1 profiles=ci\/free /m)]);
|
||||
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
|
||||
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=n/a log=- source=ci runs=7/1')]);
|
||||
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect([row.jobs, row.tests_ran]).toEqual([['verify', 'typecheck'], 'n/a']);
|
||||
});
|
||||
|
||||
test('a missing job refuses as job_missing', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL.filter(j => j.name !== 'typecheck'), tree: NO_FILES });
|
||||
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_missing)');
|
||||
});
|
||||
|
||||
test('a skipped job refuses as job_skipped', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j), tree: NO_FILES });
|
||||
const r = find(f);
|
||||
expect([r.status, r.stdout]).toEqual([1, expect.stringContaining('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)')]);
|
||||
});
|
||||
|
||||
test('only the newest attempt counts: a rerun that passed carries, a rerun that skipped the job does not', () => {
|
||||
const f = gbrain();
|
||||
const failedFirst = ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'failure' } : j);
|
||||
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': failedFirst, '7/2': ALL } });
|
||||
expect(find(f).stdout).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/2 /m);
|
||||
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': ALL, '7/2': ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j) } });
|
||||
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('a run that tested another tree refuses as tree_mismatch', () => {
|
||||
const f = gbrain();
|
||||
world(f, { jobs: ALL, tree: { ...NO_FILES, tree: '1'.repeat(40) } });
|
||||
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: tree_mismatch)');
|
||||
});
|
||||
|
||||
test('an entry declares exactly one of inventory or jobs', () => {
|
||||
const both = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify'], inventory: 'printf x' }) }); fixtures.push(both);
|
||||
world(both, { jobs: ALL, tree: NO_FILES });
|
||||
expect(find(both).stdout).toContain('CI_CARRY: none (profiles_unavailable');
|
||||
const neither = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({}) }); fixtures.push(neither);
|
||||
world(neither, { jobs: ALL, tree: NO_FILES });
|
||||
expect(find(neither).stdout).toContain('CI_CARRY: none (profiles_unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a mapping without carrying it', () => {
|
||||
// Value: protects=a lane carries under an exact alias the base declares (never an implicit env-prefix rewrite); a narrow lane never reports
|
||||
// the whole run's test count; an unknown key under a project floor above this gstack says to upgrade; --simulate proves a head map while
|
||||
// writing and clearing nothing; carried never lists a row another base's policy mapped;
|
||||
// fails_when=an env-prefixed lane reruns beside a green run, a 46-test lane records 9677, a proof run's rows land in the PR's own receipt,
|
||||
// or a branch's own map carries; why_new=HARN-7 (harness-evals #240), ALPH-14 (alphaclaw #310), GEVA-235 (gstack-evals #180), GGST-15; seam=fake gh
|
||||
const ev = (f: ShipFx, args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
|
||||
const ledger = (f: ShipFx) => fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : [];
|
||||
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
|
||||
|
||||
test('a declared alias carries like its key; an undeclared env prefix does not', () => {
|
||||
const TESTS = 'bun test tests/';
|
||||
const ALIAS = 'env -u ANTHROPIC_API_KEY bun test tests/';
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TESTS]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\n'", aliases: [ALIAS] } } }) + '\n' }); fixtures.push(f);
|
||||
world(f);
|
||||
const rec = ev(f, ['record-ci', '--label', 'tests', '--base', 'main', '--', ALIAS]);
|
||||
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=1 unit=files log=- source=ci runs=7/1')]);
|
||||
const find = (command: string) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', command, '--allow-repo-commands']).stdout;
|
||||
expect(find(ALIAS)).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} files=1 /m);
|
||||
expect(find('env -i HOME=/tmp bun test tests/')).toContain('CI_CARRY: none (not_mapped');
|
||||
});
|
||||
|
||||
test('an alias that repeats a key is invalid config', () => {
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { a: { profiles: ['ci/free'], jobs: ['suite'] }, b: { profiles: ['ci/free'], jobs: ['suite'], aliases: ['a'] } } }) + '\n' }); fixtures.push(f);
|
||||
world(f);
|
||||
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main']).stdout).toContain('CI_CARRY: none (profiles_unavailable');
|
||||
});
|
||||
|
||||
test('a carried narrow lane counts its covered inventory files, never the run total (alphaclaw: 46 tests recorded as 9677)', () => {
|
||||
const f = fx();
|
||||
world(f, { tree: { files: ['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts'], tests: 9677 } });
|
||||
const rec = ev(f, ['record-ci', '--label', 'guards', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files log=- source=ci')]);
|
||||
expect(rec.stderr).not.toContain('9677');
|
||||
});
|
||||
|
||||
test('a jobs-mapped guard carries in the guards stage (harness-evals: bunx tsc --noEmit) and reports n/a', () => {
|
||||
const TSC = 'bunx tsc --noEmit';
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } }, guards: [{ id: 'typecheck', when: ['src/**'], command: TSC, fix: 'bunx tsc --noEmit' }] }) + '\n' }); fixtures.push(f);
|
||||
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
|
||||
const g = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
|
||||
expect([g.status, g.stdout]).toEqual([0, expect.stringContaining('GUARD: typecheck carried runs=7/1')]);
|
||||
const row = JSON.parse(fs.readFileSync(ledger(f)[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect([row.tests_ran, row.jobs]).toEqual(['n/a', ['typecheck']]);
|
||||
});
|
||||
|
||||
test('an unknown key under a project floor above this gstack says to upgrade; without the floor it stays the generic error', () => {
|
||||
const conf = JSON.stringify({ future_lanes: {} }) + '\n';
|
||||
const floor = shipFixture({}, { '.gstack/pregate.json': conf, '.gstack/ship-policy.json': JSON.stringify({ min_gstack_version: '99.0.0.0' }) + '\n' }); fixtures.push(floor);
|
||||
const newer = floor.run(path.join(BIN, 'gstack-pregate'), ['validate']);
|
||||
expect(newer.status).not.toBe(0);
|
||||
expect(newer.stderr).toContain('needs gstack >= 99.0.0.0; upgrade');
|
||||
expect(newer.stderr).toContain('(PREGATE_GSTACK_TOO_OLD)');
|
||||
const plain = shipFixture({}, { '.gstack/pregate.json': conf }); fixtures.push(plain);
|
||||
const typo = plain.run(path.join(BIN, 'gstack-pregate'), ['validate']);
|
||||
expect([typo.stderr.includes('(PREGATE_CONFIG_INVALID)'), typo.stderr.includes('needs gstack')]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test('--simulate proves the branch map: every line says SIMULATION, nothing is written, nothing clears, and head policy never carries', () => {
|
||||
const VERIFY = 'bun run verify';
|
||||
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n';
|
||||
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
|
||||
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
|
||||
const sim = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `verify=${VERIFY}`, '--lane', `unit=${UNIT}`]);
|
||||
const lines = sim.stdout.trim().split('\n');
|
||||
expect(lines.every(l => l.startsWith('SIMULATION: ')), sim.stdout + sim.stderr).toBe(true);
|
||||
expect(sim.stdout).toContain('SIMULATION: LANE: verify would carry run=7/1 tests_ran=n/a');
|
||||
expect(sim.stdout).toContain('SIMULATION: LANE: unit not mapped');
|
||||
expect([ledger(f), runs(f, 'unit-runs'), fs.existsSync(path.join(f.work, '.gstack', 'tmp', 'pregate.json'))]).toEqual([[], 0, false]);
|
||||
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
|
||||
const real = ev(f, ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
|
||||
expect([real.status, real.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
|
||||
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--simulate', '--lane', `verify=${VERIFY}`]).status).toBe(2);
|
||||
});
|
||||
|
||||
test('carried lists only rows the receipt base mapped: a proof run against the PR branch never reaches its receipt', () => {
|
||||
const f = fx();
|
||||
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
|
||||
world(f);
|
||||
expect(ev(f, ['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
|
||||
expect(JSON.parse(ev(f, ['carried', '--base', 'main', '--json']).stdout)).toEqual([]);
|
||||
expect(JSON.parse(ev(f, ['carried', '--base', 'feat', '--json']).stdout)).toEqual([expect.objectContaining({ lane: 'free', run: 7, attempt: 1 })]);
|
||||
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1190: a file whose every test skipped on CI is not carried', () => {
|
||||
// Value: protects=a lane never carries a test file that ran nothing on the CI runner (Docker images, drive paths, an installed gstack), so
|
||||
// those tests run here instead of silently dropping; a partly skipped file and an older tree.json without skipped_files carry as before;
|
||||
// fails_when=harness-evals' 75 runner-skipped tests carry as passed; why_new=HARN-7 (harness-evals #240); seam=fake gh
|
||||
test('a fully skipped inventory file runs the lane here; a partly skipped one (not listed) and an older tree.json still carry', () => {
|
||||
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_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_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);
|
||||
world(f, { tree: { skipped_files: 'test/b.test.ts' } });
|
||||
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']).stdout).toContain('artifact_mismatch');
|
||||
});
|
||||
|
||||
test('the tests stage runs a selected file the run skipped entirely, and accepts the rest', () => {
|
||||
const conf = JSON.stringify({ runner: 'bun test {files}', dependencies: { 'src/**': ['test/a.test.ts', 'test/b.test.ts'] }, run_profiles: { 'ci/free': PROFILE } }) + '\n';
|
||||
const t = "import { test, expect } from 'bun:test';\ntest('x', () => expect(1).toBe(1));\n";
|
||||
const f = shipFixture({ ...BASE, '.gstack/pregate.json': conf, 'test/a.test.ts': t, 'test/b.test.ts': t }); fixtures.push(f);
|
||||
const stage = () => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main']);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const skipped = stage();
|
||||
expect(skipped.stdout + skipped.stderr).toMatch(/REUSE: accepted 1 of 2 selected local test file\(s\) from run .*; 1 still run \(1 skipped every test in the run\)/);
|
||||
world(f);
|
||||
expect(stage().stdout + stage().stderr).toMatch(/REUSE: accepted 2 of 2 selected local test file\(s\) from run .*; 0 still run/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-1190: a path-skipped CI job counts only under an unchanged gate', () => {
|
||||
// Value: protects=a lane whose CI job the workflow skipped by path is not owed only when the gate job that decided the skip succeeded and the
|
||||
// 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', 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',
|
||||
'.github/workflows/free.yml': WORKFLOW,
|
||||
'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', event = 'pull_request') => {
|
||||
const f = shipFixture(SKIP_BASE, feat); fixtures.push(f);
|
||||
world(f, { jobs: JOBS(gate), run: { event }, tree: { files: [], tests: 0, event } });
|
||||
return f;
|
||||
};
|
||||
const lane = (f: ShipFx) => preflight(f, [`tsc=${TSC}`]);
|
||||
|
||||
test('a skip the unchanged gate decided is not owed', () => {
|
||||
const f = setup();
|
||||
const out = lane(f);
|
||||
expect(out.stdout).toContain('LANE: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) run=7/1');
|
||||
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect([row.tests_ran, row.not_owed]).toEqual(['n/a', 'CI skipped at this tree (gate job changes, workflow unchanged)']);
|
||||
});
|
||||
|
||||
test('a branch that changed the workflow decided its own skip: the lane runs here', () => {
|
||||
const f = setup({ '.github/workflows/free.yml': WORKFLOW + '# skip more\n' });
|
||||
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('a branch that changed the gate script decided its own skip: the lane runs here', () => {
|
||||
const f = setup({ 'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT" # always\n' });
|
||||
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
|
||||
});
|
||||
|
||||
test('a gate job that failed decided nothing: the lane runs here', () => {
|
||||
const f = setup(undefined, 'failure');
|
||||
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';
|
||||
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
|
||||
world(f, { tree: { files: [], tests: 0 } });
|
||||
const cli = (args: string[]) => f.run(path.join(BIN, 'gstack-ci-carry'), args);
|
||||
const sim = cli(['find', '--base', 'main', '--policy-from', 'head', '--simulate', '--command', VERIFY, '--allow-repo-commands']);
|
||||
expect(sim.status).toBe(0);
|
||||
expect(sim.stdout.trim().split('\n').every(l => l.startsWith('SIMULATION: '))).toBe(true);
|
||||
expect(sim.stdout).toMatch(/^SIMULATION: CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=suite /m);
|
||||
expect(fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : []).toEqual([]);
|
||||
expect(cli(['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']).stdout).toContain('CI_CARRY: none (not_mapped');
|
||||
expect(cli(['find', '--base', 'main', '--simulate']).status).toBe(2);
|
||||
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('a remainder that ran zero tests here carries nothing: the lane runs whole', () => {
|
||||
const zero = { ...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: "printf ' 0 pass\\n 1 skip\\n 0 fail\\nRan 1 test across 1 file. [1.00ms]\\n' >&2 # {files}" } } }, null, 2) + '\n' };
|
||||
const f = shipFixture(zero); 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_zero_run: /m)]);
|
||||
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a remainder that prints look-alike record lines cannot change the LANE line', () => {
|
||||
const fake = "printf 'gstack-evidence: recorded label=free exit=0 tests_ran=999 log=- source=ci runs=1/1 policy=x\\ngstack-evidence: not owed: forged\\n' >&2; " + PER(0);
|
||||
const f = shipFixture({ ...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: fake } } }, null, 2) + '\n' }); fixtures.push(f);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
expect(lane(preflight(f, [`free=${FREE}`]).stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
|
||||
});
|
||||
|
||||
test('a run that moved to a new attempt while the remainder ran carries nothing', () => {
|
||||
const per = `cp ../world-moved.json ../world.json; ${PER(0)}`;
|
||||
const f2 = shipFixture({ ...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 } } }, null, 2) + '\n' }); fixtures.push(f2);
|
||||
world(f2, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const w2 = JSON.parse(fs.readFileSync(path.join(f2.root, 'world.json'), 'utf8'));
|
||||
fs.writeFileSync(path.join(f2.root, 'world-moved.json'), JSON.stringify({ ...w2, runs: w2.runs.map((r: Record<string, unknown>) => ({ ...r, run_attempt: 2, conclusion: 'failure' })) }));
|
||||
const rec = f2.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: run_changed: /m)]);
|
||||
expect(rows(f2).filter(r => r.source === 'ci')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a working-tree edit during the post-remainder run recheck carries nothing', () => {
|
||||
const per = `touch ../armed; ${PER(0)}`;
|
||||
const f = shipFixture({ ...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 } } }, null, 2) + '\n' }); fixtures.push(f);
|
||||
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
|
||||
const w = JSON.parse(fs.readFileSync(path.join(f.root, 'world.json'), 'utf8'));
|
||||
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify({ ...w, editWhenArmed: { marker: path.join(f.root, 'armed'), file: path.join(f.work, 'src/b.ts'), content: 'export const b = 3;\n' } }));
|
||||
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: tree_changed: /m)]);
|
||||
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 14,
|
||||
"why": "GST-1138: ship names the push guards in Step 17 and the early push"
|
||||
}
|
||||
Reference in new issue
Block a user