diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 3af9b909c..0ee86ab23 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -4,7 +4,7 @@ * IRON LAW ("no completion claims without fresh verification evidence"). * * gstack-evidence run [--reuse [--max-age ]] --label -- - * gstack-evidence record-ci --label --runs --profiles --tree --tests --finished -- + * gstack-evidence record-ci --label --base -- * gstack-evidence carried [--json] * gstack-evidence check [--label [--expect-cmd ]]... | --all * [--max-age ] [--allow-paths ] @@ -62,10 +62,11 @@ * reuse appends a copy that keeps the original timestamp, so check's max age * still measures the real run. * - * `record-ci` writes the explicit CI-sourced row for a lane CI carried - * (lib/ci-carry.ts): source "ci", the runs and profiles, the run's finish time - * as ts, bound to the clean working tree it names; `carried` lists those rows - * at the current tree for the ship receipt. + * `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. * * 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 @@ -89,6 +90,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 { 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; @@ -620,32 +625,41 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number return true; } -/** `record-ci`: the explicit CI-sourced row for a lane CI carried at this clean tree (lib/ci-carry.ts). Never runs anything. */ +/** + * `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. + */ function cmdRecordCi(argv: string[]): 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; }; const label = get("--label")?.replace(/[^a-zA-Z0-9._-]/g, "_"); - const runs = (get("--runs") ?? "").split(",").filter(Boolean); - const profiles = (get("--profiles") ?? "").split(",").filter(Boolean); - const tree = get("--tree") ?? ""; - const tests = Number(get("--tests")); - const finished = get("--finished") ?? ""; + const base = get("--base"); const command = sep < 0 ? "" : argv.slice(sep + 1).join(" "); - if (!label || !command || !runs.length || runs.some((r) => !/^\d{1,15}\/\d{1,4}$/.test(r)) || !profiles.length || !/^[0-9a-f]{40,64}$/.test(tree) || !Number.isInteger(tests) || tests <= 0 || !Number.isFinite(Date.parse(finished))) { - console.error("usage: gstack-evidence record-ci --label --runs --profiles --tree --tests 0> --finished -- "); + if (!label || !base || !command || opts.length !== 4) { + console.error("usage: gstack-evidence record-ci --label --base -- "); return 2; } - const wtree = currentWtree(); - const headTree = git(["rev-parse", "HEAD^{tree}"]); - if (wtree !== tree) { - console.error(`gstack-evidence record-ci: ${tree.slice(0, 12)} is not the working tree (${wtree ? wtree.slice(0, 12) : "fingerprint unavailable"}); nothing recorded`); + const none = (reason: string, detail: string) => { + console.error(`CARRY_NONE: ${reason}: ${detail}`); + console.error(`gstack-evidence record-ci: label=${label} not carried; nothing recorded`); return 1; - } - if (headTree !== tree) { - console.error(`gstack-evidence record-ci: the working tree is not HEAD's committed tree (${headTree ? headTree.slice(0, 12) : "no HEAD"}); a carry needs a clean, committed tree; nothing recorded`); - return 1; - } + }; + const cwd = process.cwd(); + 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 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) }); + if (!v.ok) return none(v.reason, v.detail); let paths: ReturnType; try { paths = ledgerPath(); @@ -654,12 +668,13 @@ function cmdRecordCi(argv: string[]): number { console.error(`gstack-evidence record-ci: ledger setup failed (${e?.message ?? e}); nothing recorded`); return 1; } + const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`); const rec: EvidenceRecord = { - ts: new Date(finished).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0, - commit: git(["rev-parse", "HEAD"]), tree: headTree, dirty: false, wtree, tests_ran: tests, source: "ci", runs, profiles, + 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, }; appendJsonl(paths.file, rec, { mode: 0o600 }); - console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${tests} log=- source=ci runs=${runs.join(",")}`); + console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")}`); return 0; } diff --git a/docs/pregate.md b/docs/pregate.md index a50802673..7d5eb62fc 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -956,9 +956,11 @@ it and the command that lists its test files: ``` A carried lane prints `LANE: