diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 6be73a295..91725e34a 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -22,6 +22,9 @@ "ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" }, "ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" } }, + "ship_lanes_from": { + "bun run test": { "profiles": ["ci-reuse/free-tests"], "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'" } + }, "warn": [], "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/bin/gstack-ci-carry b/bin/gstack-ci-carry new file mode 100755 index 000000000..c29156107 --- /dev/null +++ b/bin/gstack-ci-carry @@ -0,0 +1,77 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-ci-carry — CI as the one full test run (GSTA-258; docs/pregate.md#ci-carry). + * + * gstack-ci-carry find [--base ] [--profile ]... [--command --allow-repo-commands] [--json] + * gstack-ci-carry wait [--base ] [--profile ] [--timeout ] [--interval ] + * + * find: the newest verified run of each profile (default: every profile the base branch's + * ship_lanes_from names) at HEAD's clean tree. With --command, the base branch's mapping for that exact lane + * command supplies the profiles and the inventory the runs must cover (a pin-file command, so it needs + * --allow-repo-commands). Prints CI_CARRY: accepted (exit 0) or CI_CARRY: none () (exit 1). + * wait: polls the newest run of the profile's workflow at HEAD until it completes (default timeout 1800 s), + * prints CI_WAIT: green|red|running|none, and on green runs find. Exit 0 green and carried, 1 red or not + * 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 { buildRunCheck } from '../lib/pregate/run-check'; +import { runInventory } from '../lib/run-evidence'; +import { wtreeFingerprint } from '../lib/script-invocation'; +import { resolveBaseBranch } from '../lib/ship-policy'; + +const HELP = `gstack-ci-carry — use a verified CI run at HEAD's exact tree instead of running the suite again (docs/pregate.md#ci-carry) + + find [--base ] [--profile ]... [--command --allow-repo-commands] [--json] + CI_CARRY: accepted (exit 0) or CI_CARRY: none () (exit 1) + wait [--base ] [--profile ] [--timeout ] [--interval ] + 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/'s .gstack/pregate.json only. 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.`; + +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 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 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 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); } +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); } +const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), 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, { profiles, head, tree, wtree, ...(mapping ? { inventory: () => runInventory(mapping.inventory, 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; +} + +if (cmd === 'find') process.exit(find()); +const timeout = Number(flag('--timeout') ?? '1800'); +const interval = Number(flag('--interval') ?? '20'); +if (!(timeout >= 0 && timeout <= 21_600) || !(interval >= 1 && interval <= 600)) usage('--timeout is 0-21600 seconds and --interval 1-600'); +const profile = check.profiles[profiles[0]!]; +if (!profile) usage(`${profiles[0]} is not a run_profiles name on origin/${base}`); +const w = await waitForRun(check.deps, { workflow: profile!.workflow, events: profile!.events, head, timeoutMs: timeout * 1000, intervalMs: interval * 1000, sleep: ms => Bun.sleep(ms), clock: Date.now }); +out(`CI_WAIT: ${w.state} ${profiles[0]} ${'run' in w ? `run=${w.run.id}/${w.run.run_attempt}${w.state === 'red' ? ` failing=${w.failing.join(',') || '-'}` : ''}` : w.reason}`); +process.exit(w.state === 'green' ? find() : w.state === 'red' ? 1 : 3); diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 35ca01d7b..3af9b909c 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -3,7 +3,9 @@ * gstack-evidence — verification-evidence ledger: the mechanical arm of /ship's * IRON LAW ("no completion claims without fresh verification evidence"). * - * gstack-evidence run --label -- + * gstack-evidence run [--reuse [--max-age ]] --label -- + * gstack-evidence record-ci --label --runs --profiles --tree --tests --finished -- + * gstack-evidence carried [--json] * gstack-evidence check [--label [--expect-cmd ]]... | --all * [--max-age ] [--allow-paths ] * gstack-evidence bundle --out [--label ]... [--require ] [--selection L=]... @@ -51,6 +53,20 @@ * normalization — the same convention as bin/gstack-verify-gate (which hashes * for TRUST; this ledger hashes for FRESHNESS). * + * `run --reuse` (GSTA-258, what gstack-ship-preflight and finish pass): before + * running, the newest row for this label, command hash and working-tree + * fingerprint decides. It is reused only when it passed with a counted run + * (tests_ran > 0) under the same execution identity (bun and node versions, + * OS, architecture and runner image; lockfiles and pins are tree content) within --max-age + * hours (default 24); a newer failure on that tree vetoes an older pass. The + * 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. + * * 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 * the log doesn't exist. @@ -93,6 +109,14 @@ interface EvidenceRecord { wtree_changed?: string[]; log_path?: string; redacted?: boolean; + /** sha256 of the runtime the run had (identityDigest). */ + identity?: string; + /** The original ts of the row a `run --reuse` copied. */ + reused_from?: string; + /** "ci" for a lane CI carried (record-ci); absent for a local run. */ + source?: "ci"; + runs?: string[]; + profiles?: 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"; } @@ -369,7 +393,15 @@ async function cmdRun(argv: string[]): Promise { const sep = argv.indexOf("--"); if (li >= 0 && li + 1 < argv.length && (sep < 0 || li < sep)) label = argv[li + 1]; if (sep < 0 || sep + 1 >= argv.length) { - console.error("usage: gstack-evidence run --label -- "); + console.error("usage: gstack-evidence run [--reuse [--max-age ]] --label -- "); + return 2; + } + const opts = argv.slice(0, sep); + const reuse = opts.includes("--reuse") && !opts.includes("--no-reuse"); + const maxAgeAt = opts.indexOf("--max-age"); + const reuseMaxAge = maxAgeAt >= 0 ? Number(opts[maxAgeAt + 1]) : 24; + if (!Number.isFinite(reuseMaxAge) || reuseMaxAge <= 0) { + console.error(`gstack-evidence: --max-age must be a positive number of hours, got: ${JSON.stringify(opts[maxAgeAt + 1])}`); return 2; } const cmdArgv = argv.slice(sep + 1); @@ -388,6 +420,7 @@ async function cmdRun(argv: string[]): Promise { } catch (e: any) { warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`); } + if (reuse && paths && reused(paths.file, label, cmdSha, reuseMaxAge)) return 0; const log = paths ? openLog(paths.logsDir, label, cmdSha) : undefined; // Fingerprint the content BEFORE the child runs: a working-tree edit made @@ -525,6 +558,7 @@ function record( const wtreeAfter = currentWtree(); if (wtreeBefore && wtreeAfter && wtreeBefore === wtreeAfter) { rec.wtree = wtreeAfter; + rec.identity = identityDigest(); } else if (wtreeBefore || wtreeAfter) { const changed = wtreeBefore && wtreeAfter ? (git(["diff", "--name-only", wtreeBefore, wtreeAfter]) ?? "").split("\n").map((s) => s.trim()).filter(Boolean) : []; if (changed.length) rec.wtree_changed = changed.slice(0, 5); @@ -545,6 +579,108 @@ function record( } } +/** + * sha256 of the runtime a lane ran under: the bun and node on PATH, OS, architecture and runner image. The lockfiles and + * pins the execution identity also names (lib/evidence-bundle.ts) are working-tree content, so the fingerprint binds them. + */ +function identityDigest(): string { + const version = (b: string) => { + const r = spawnSync(b, ["--version"], { encoding: "utf-8", timeout: 15000 }); + return r.status === 0 ? (r.stdout || "").trim().replace(/^v/, "") || null : null; + }; + return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null })); +} + +/** + * `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. + */ +function reused(file: string, label: string, cmdSha: string, maxAgeHours: number): boolean { + const wtree = currentWtree(); + if (!wtree) return false; + let records: EvidenceRecord[] = []; + try { + records = readJsonl(file); + } catch { + 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; + try { + appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 }); + } catch (e: any) { + warn(`ledger append failed (${e?.message ?? e}) — running the command instead`); + return false; + } + console.error(`gstack-evidence: reused label=${label} tree=${wtree.slice(0, 12)} from=${newest.ts}`); + console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${newest.tests_ran} log=${newest.log_path ?? "-"}`); + 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. */ +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 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 -- "); + 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`); + 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; + } + let paths: ReturnType; + try { + paths = ledgerPath(); + mkdirpSync(paths.dir); + } catch (e: any) { + console.error(`gstack-evidence record-ci: ledger setup failed (${e?.message ?? e}); nothing recorded`); + return 1; + } + 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, + }; + appendJsonl(paths.file, rec, { mode: 0o600 }); + console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${tests} log=- source=ci runs=${runs.join(",")}`); + 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. */ +function cmdCarried(argv: string[]): number { + let records: EvidenceRecord[] = []; + try { + records = readJsonl(ledgerPath().file); + } catch { + records = []; + } + 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)) + .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)}`); + return 0; +} + function cmdCheck(argv: string[]): number { // Parse: repeated --label, each optionally followed (anywhere later) by its // own --expect-cmd; pairing is positional — an --expect-cmd binds to the most @@ -605,7 +741,8 @@ function cmdCheck(argv: string[]): number { } const testsRan = typeof latest.tests_ran === "number" ? 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 detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`; + 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}`; let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH"; let reason = ""; if (latest.exit !== 0) { @@ -756,6 +893,10 @@ const [, , sub, ...rest] = process.argv; try { if (sub === "run") { process.exit(await cmdRun(rest)); + } else if (sub === "record-ci") { + process.exit(cmdRecordCi(rest)); + } else if (sub === "carried") { + process.exit(cmdCarried(rest)); } else if (sub === "check") { process.exit(cmdCheck(rest)); } else if (sub === "bundle") { @@ -765,7 +906,7 @@ try { } else if (sub === "verify") { process.exit(cmdVerify(rest)); } else { - console.error("usage: gstack-evidence run|check|bundle|ancestor|verify ..."); + console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ..."); process.exit(2); } } catch (e: any) { diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 3e31ae8ff..3ea31c0bd 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -4,7 +4,7 @@ * * gstack-pregate [run] [--stage preflight|tests|all] [--only ] [--base ] * [--allow-repo-commands] [--policy-from base|head] [--out ] - * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--from-run ] [--explain] [--json] + * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--from-run ] [--no-ci-carry] [--explain] [--json] * gstack-pregate verify [--out ] [--json] publication check: current tree, no fail/incomplete, obligations cleared * gstack-pregate clear --remote --run-url | --run [--run …] [--allow-repo-commands] | --bundle-lane