diff --git a/bin/gstack-ci-carry b/bin/gstack-ci-carry index c29156107..398517753 100755 --- a/bin/gstack-ci-carry +++ b/bin/gstack-ci-carry @@ -14,10 +14,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 { findCarry, laneMapping, mappingCoverage, waitForRun } from '../lib/ci-carry'; import { isConfigUnavailable, loadConfigFromBase } 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'; @@ -48,8 +47,8 @@ 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 command = flag('--command'); -const mapping = command !== undefined ? loaded.config.ship_lanes_from[command] : undefined; -if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is not a key of origin/${base}'s ship_lanes_from)`); process.exit(1); } +const mapping = command !== undefined ? laneMapping(loaded.config.ship_lanes_from, command) : undefined; +if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is neither a key nor a declared alias in origin/${base}'s ship_lanes_from)`); process.exit(1); } if (mapping && !argv.includes('--allow-repo-commands')) usage('--command runs the mapping\'s inventory, a pin-file command: pass --allow-repo-commands'); const profiles = all('--profile').length ? all('--profile') : mapping ? mapping.profiles : [...new Set(Object.values(loaded.config.ship_lanes_from).flatMap(m => m.profiles))]; if (!profiles.length) { out(`CI_CARRY: none (no_profile: origin/${base} maps no lane in ship_lanes_from; pass --profile )`); process.exit(1); } @@ -60,7 +59,7 @@ const tree = git(['rev-parse', 'HEAD^{tree}']); function find(): number { const wtree = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; }; - const v = findCarry(check as Exclude, { profiles, head, tree, wtree, ...(mapping ? { inventory: () => runInventory(mapping.inventory, root) } : {}) }); + const v = findCarry(check as Exclude, { profiles, head, tree, wtree, ...(mapping ? mappingCoverage(mapping, root) : {}) }); if (argv.includes('--json')) out(JSON.stringify(v)); else out(v.ok ? `CI_CARRY: accepted ${v.receipt}` : `CI_CARRY: none (${v.reason}: ${v.detail})`); return v.ok ? 0 : 1; diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 9d5f80ef6..99999da9d 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -6,7 +6,7 @@ * gstack-evidence run [--reuse [--max-age ]] --label -- * gstack-evidence record-ci --label --base -- * gstack-evidence passed -- - * gstack-evidence carried [--json] + * gstack-evidence carried [--base ] [--json] * gstack-evidence check [--label [--expect-cmd ]]... | --all * [--max-age ] [--allow-paths ] * gstack-evidence bundle --out [--label ]... [--require ] [--selection L=]... @@ -66,8 +66,11 @@ * `record-ci` writes the explicit CI-sourced row for a lane CI carried: it * finds and verifies the run itself (lib/ci-carry.ts) for a command the base's * ship_lanes_from maps, then records source "ci", the runs and profiles, the - * run's finish time as ts, bound to HEAD's clean tree; `carried` lists those - * rows at the current tree for the ship receipt. + * run's finish time as ts, bound to HEAD's clean tree, and the policy that + * mapped it (`origin/@:…`); `carried` lists those rows at the + * current tree for the ship receipt, only when that policy is origin/ (GST-1190: a row a run against another base wrote never + * counts). * * MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced. * A synced record citing an unsynced log would grade FRESH on a machine where @@ -91,10 +94,9 @@ 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 } from "../lib/ci-carry"; 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; @@ -123,10 +125,14 @@ interface EvidenceRecord { source?: "ci"; runs?: string[]; profiles?: string[]; + /** The CI jobs a jobs-mapped lane carried from (record-ci, GST-1189); tests_ran is then "n/a". */ + jobs?: string[]; /** The base policy that mapped a carried lane (record-ci), as `origin/@:.gstack/pregate.json`. */ policy?: string; /** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */ - tests_ran?: number | "unknown"; + tests_ran?: number | "unknown" | "n/a"; + /** "files" when a carried narrow lane's tests_ran counts its inventory files the run covered (record-ci), not tests. */ + tests_unit?: "files"; } /** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */ @@ -678,16 +684,15 @@ 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 paths: ReturnType; try { @@ -700,15 +705,21 @@ 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 } : {}), }; 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}`); return 0; } -/** `carried`: each label's newest row when it is a CI-sourced row at the current working tree, as the receipt's ci_carried entries. */ +/** + * `carried`: each label's newest row when it is a CI-sourced row at the current working tree whose mapping policy is + * origin/ (the receipt's base; default the resolved base branch), as the receipt's ci_carried entries. + */ function cmdCarried(argv: string[]): number { + const at = argv.indexOf("--base"); + if (at >= 0 && !argv[at + 1]) { console.error("usage: gstack-evidence carried [--base ] [--json]"); return 2; } + const policyRef = `origin/${(at >= 0 ? argv[at + 1]! : resolveBaseBranch(git(["rev-parse", "--show-toplevel"]) ?? process.cwd())).replace(/^origin\//, "")}@`; let records: EvidenceRecord[] = []; try { records = readJsonl(ledgerPath().file); @@ -718,7 +729,7 @@ function cmdCarried(argv: string[]): number { const wtree = currentWtree(); const latest = new Map(); for (const r of records) latest.set(r.label, r); - const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs)) + const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef)) .flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; })); if (argv.includes("--json")) console.log(JSON.stringify(carried)); else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`); @@ -783,10 +794,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) { diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index cc4e80dae..c76f20e47 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -172,9 +172,9 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree }; } -/** The lanes CI carried at the checked-out tree, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */ -function ciCarried(): ShipReceipt['ci_carried'] { - const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--json'], { encoding: 'utf8', timeout: 60_000 }); +/** The lanes CI carried at the checked-out tree under origin/'s mapping, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */ +function ciCarried(base: string): ShipReceipt['ci_carried'] { + const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--base', base, '--json'], { encoding: 'utf8', timeout: 60_000 }); try { const list = JSON.parse(r.stdout); return Array.isArray(list) && list.length ? list : undefined; } catch { return undefined; } } @@ -268,7 +268,7 @@ function cmdWrite(rest: string[], json: boolean): number { version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base, gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined, tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined, - gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(), + gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base), spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined, session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), diff --git a/changelog.d/gsta258-l6-guard-carry.md b/changelog.d/gsta258-l6-guard-carry.md index 7afc6e0ab..0fbaa0102 100644 --- a/changelog.d/gsta258-l6-guard-carry.md +++ b/changelog.d/gsta258-l6-guard-carry.md @@ -1,5 +1,15 @@ - A pregate guard whose exact command already passed at this tree no longer runs again (GST-1186). When the base branch's `ship_lanes_from` maps the command, it is carried from the verified CI run through the same `gstack-evidence record-ci` path /ship lanes use (`GUARD: carried runs=…`); otherwise a counted ledger pass of that command at the same working tree stands (`GUARD: reused …`, and the new read-only `gstack-evidence passed -- `). Repos whose suites are guards, like founderstar, stop rerunning about six minutes of suites per /ship. See docs/pregate.md#guard-carry. - Known limit: `ship_lanes_from` is still read from `origin/` only, so the PR that adds a mapping cannot carry from it. +- A `ship_lanes_from` entry can name the CI jobs that run a lane instead of an inventory command: `{ "profiles": [...], "jobs": ["verify", "typecheck"] }`, exactly one of the two (GST-1189). It carries only when every named job succeeded in the newest attempt of the verified run at HEAD's tree, through the same `verifyRun` job check. A missing job refuses as `job_missing` and a skipped one as the new `job_skipped`. Lanes with no test files, like gbrain's verify and typecheck (run 41564913688) or harness-evals' `bunx tsc --noEmit`, can now carry, and they record `tests_ran=n/a`. Fake `check:` entries in `tree.json` can move to this form. A repo declaring `jobs` should raise its `min_gstack_version` to this release first, since older gstack rejects the key. See docs/pregate.md#ship-lanes-jobs. + +- A `ship_lanes_from` entry can declare `aliases`, other exact spellings of the same lane such as `env -u ANTHROPIC_API_KEY bun test tests/` (GST-1190). gstack never strips env prefixes on its own, since an `env -i` run is a different environment. +- A carried lane no longer reports the whole run's test count. An inventory lane reports how many of its files the run covered, labeled as a file count (alphaclaw's 46-test guards lane recorded 9677 before). +- `gstack-ship-preflight --tests-only --policy-from head --simulate` proves a new mapping before it lands: every line starts `SIMULATION:`, nothing runs, no evidence row is written and nothing clears. A branch's own map never carries, so the first pull request that adopts a mapping pays its full lanes. +- `gstack-evidence carried`, which the ship receipt reads, lists only rows the receipt base's policy mapped, so a run against another base never lands in a receipt. +- An unknown `.gstack/pregate.json` key under a `min_gstack_version` above the running gstack now says `needs gstack >= ; upgrade` (`PREGATE_GSTACK_TOO_OLD`). It still refuses. +- docs/pregate.md#prove-a-mapping documents the proof route, the version floors, and that `tree.json` writers using Bun's JUnit must take the root `testsuite` count. ### 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. diff --git a/docs/pregate.md b/docs/pregate.md index 357f498d8..ca1421775 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -90,7 +90,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `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)) | | `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`: the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and either the command listing its test files, which their runs must cover, or the CI jobs that must have succeeded (see [CI carry](#ci-carry)); read from the base branch only | | `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) | Only the `origin/` copy or the user-owned file below is honoured, so a @@ -785,7 +785,7 @@ Then, for the lane: Any API or git error refuses. Each refusal prints `REUSE: refused reason= — …` 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), @@ -933,8 +933,9 @@ 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. 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. @@ -986,8 +987,90 @@ it and the command that lists its test files: } ``` +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:` 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. + +**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. + +**Proving a new mapping.** `ship_lanes_from` is read from `origin/` +only, so a branch that adds a mapping cannot carry from it, and running +against `--base ` fails (`BASE_CONTAINS_HEAD`). Prove it with the dry +run instead: + +```bash +gstack-ship-preflight --tests-only --base main --policy-from head --simulate --lane 'verify=bun run verify' +``` + +It reads the branch's own `ship_lanes_from` and `run_profiles`, checks each lane +against the verified CI run at this tree exactly as a carry would, and prints +`SIMULATION:` at the start of every line: `LANE: