diff --git a/bin/gstack-evidence b/bin/gstack-evidence index d8ac06fd1..26dd4c83e 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -712,6 +712,35 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number return true; } +/** record-ci's record line, the one lib/ci-carry.ts recordCiResult reads. */ +const recordedCiLine = (r: EvidenceRecord): string => + `gstack-evidence: recorded label=${r.label} exit=0 tests_ran=${r.tests_ran}${r.tests_unit ? ` unit=${r.tests_unit}` : ""} log=- source=ci runs=${r.runs!.join(",")} policy=${r.policy}${r.skipped_here ? ` skipped_here=${r.skipped_here.files.length} tests_ran_here=${r.skipped_here.tests_ran}${r.skipped_here.not_owed ? ` not_owed_here=${r.skipped_here.not_owed.length}` : ""}` : ""}`; + +/** + * record-ci's reuse (GST-1795): the label's newest ledger row stands only when it is the CI row this record-ci would write again + * (every `want` field equal: commit, clean tree, command, verified runs, profiles, jobs, base policy, count, unit, not-owed reason), + * it grades FRESH, it carries no test-lane exception, and its per_file remainder is the same files, each file's newest local run at + * this tree written before that row (the run the row was validated from) and a pass under this runtime. Anything else records afresh. + * The caller has already verified the run itself live. + */ +function reusableCiRow(label: string, want: Partial, remainder: string[], perFile: string | undefined): EvidenceRecord | null { + let rows: EvidenceRecord[] = []; + try { rows = readJsonl(ledgerPath().file); } catch { return null; } + const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + const at = rows.findLastIndex((x) => x.label === label); + const r = rows[at]; + if (!r || r.source !== "ci" || r.exception || r.dirty !== false || r.tree !== want.wtree) return null; + if (!Object.entries(want).every(([k, v]) => same(r[k as keyof EvidenceRecord], v))) return null; + if (gradeRecord(r, { maxAgeHours: DEFAULT_MAX_AGE_HOURS, allowPaths: [], wtreeNow: want.wtree }).verdict !== "FRESH") return null; + if (!same(r.skipped_here?.files ?? [], remainder)) return null; + const identity = identityDigest(); + const passedHere = (file: string) => { + const i = rows.findLastIndex((x) => x.label === `${label}.skipped` && x.source === undefined && x.cmd_sha256 === sha256(perFileCommand(perFile!, [file])) && x.wtree === want.wtree); + return i >= 0 && i < at && rows[i]!.exit === 0 && rows[i]!.identity === identity; + }; + return remainder.every(passedHere) ? r : null; +} + /** * `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; @@ -772,6 +801,14 @@ async function cmdRecordCi(argv: string[]): Promise { return 0; } if (!v.ok) return none(v.reason, `${v.detail}${v.reason === "inventory_uncovered" && !atHead.length && elsewhere.length ? `; test-lane exception ${elsewhere.join(", ")} binds another head, not HEAD ${head.slice(0, 12)}` : ""}`, v.diagnosis); + const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`); + const prior = v.excepted?.length ? null : reusableCiRow(label, { commit: head, wtree: tree, cmd_sha256: sha256(command), runs, profiles: m.profiles, policy: loaded.label, jobs: m.jobs, tests_ran: v.tests, tests_unit: v.unit, not_owed: v.notOwed }, v.remainder ?? [], m.per_file); + if (prior) { + console.error(recordedCiLine(prior)); + if (prior.not_owed) console.error(`gstack-evidence: not owed: ${prior.not_owed}`); + console.error(`CI_RECORD_REUSED: label=${label} runs=${runs.join(",")} tree=${tree.slice(0, 12)} policy=${loaded.label} (the ledger's CI row for this exact tree, run and selection; nothing ran here)`); + return 0; + } let exception: BoundLaneException | undefined; const grant = atHead.find((g) => g.approval === v.exceptedBy); if (v.excepted?.length && grant) { @@ -826,13 +863,12 @@ async function cmdRecordCi(argv: string[]): Promise { 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(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: testsRan, 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 } : {}), ...(exception ? { exception } : {}), }; appendJsonl(paths.file, rec, { mode: 0o600 }); - console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${testsRan}${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}${here.not_owed ? ` not_owed_here=${here.not_owed.length}` : ""}` : ""}`); + console.error(recordedCiLine(rec)); if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`); if (exception) console.error(`TEST_LANE_EXCEPTION: ${testLaneExceptionText(exception)}`); return 0; diff --git a/changelog.d/gst-1795-record-ci-reuse.md b/changelog.d/gst-1795-record-ci-reuse.md new file mode 100644 index 000000000..ab5bb7267 --- /dev/null +++ b/changelog.d/gst-1795-record-ci-reuse.md @@ -0,0 +1 @@ +- **`gstack-ship-release accept` no longer reruns a CI carry's local remainder that Step 5 already ran at the same tree.** /ship's Step 5 and accept both call `gstack-evidence record-ci`, which ran the lane's `per_file` remainder (the inventory files CI skipped entirely) each time, so on harness-evals #315 a remainder file of about 7 minutes ran twice and accept's record-ci hit the 600 s `ci_record_timeout_s`. After verifying the run live, record-ci now reuses the label's newest ledger row when it is the CI row it would write again: same commit, clean tree, command, run id and attempt, profiles, jobs, base policy, covered count and remainder files, FRESH, no test-lane exception, and each remainder file's newest local run at this tree written before that row and a pass under this runtime. It prints `CI_RECORD_REUSED: label= runs=/ tree= policy=`, and accept's lane line ends `(reused the CI record at tree )`. Any mismatch, a newer row for the label, or a run that is no longer green records afresh or refuses as before. No timeout or coverage change. See docs/pregate.md#record-ci-reuse. (GST-1795) diff --git a/docs/pregate.md b/docs/pregate.md index d5f60d2cc..3ebb11165 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -1270,6 +1270,22 @@ them (`ci_carried=:/`). Each `record-ci` gets the base policy's [`ci_record_timeout_s`](ship-policy.md#ci-record-timeout) (600 s by default, 3600 s at most), and accept prints it as `CI_RECORD_TIMEOUT:`. +**record-ci reuses its own row at the same tree (GST-1795).** Step 5's +carry and accept both call `record-ci`, so at an unchanged pushed tree the +second call would run the `per_file` remainder again. Instead, after it +verifies the run live, `record-ci` reuses the label's newest ledger row when +that row is the CI row it would write again: the same commit, clean tree, +command, verified runs (id and attempt), profiles, jobs, base policy, covered +count, unit and not-owed reason, FRESH within 24 hours, with no test-lane +exception, and the same remainder files, each file's newest `.skipped` +run at this tree written before that row and a pass under this runtime (a +remainder run after the CI row, such as a zero-test rerun record-ci refused, +vetoes reuse). It runs nothing here, prints +`CI_RECORD_REUSED: label= runs=/ tree= policy=` +after its record line, and accept's lane line ends `(reused the CI record at +tree )`. Any difference, a newer row for the label, or a run that is no +longer green records afresh (or refuses) as before. + **When accept's final pregate runs the tests here (GST-1606).** The final pregate's tests stage carries only under a base run profile whose `os` matches this machine and whose workflow holds a lane this machine runs. A lane carried diff --git a/lib/ci-carry.ts b/lib/ci-carry.ts index 68058d17f..cb71e561a 100644 --- a/lib/ci-carry.ts +++ b/lib/ci-carry.ts @@ -206,7 +206,7 @@ export function importedTestLines(cwd: string, missing: string[], candidates: st * not-owed reason, or the last CARRY_NONE reason. A lane the cleared remote gate stood in for (--remote-gate-stands-in, GST-1688) * reads as its receipt, its count and why the CI carry refused. */ -export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; exception?: string; none?: string; diagnosis?: string[]; remoteGate?: string; standIn?: string } { +export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; exception?: string; reused?: string; none?: string; diagnosis?: string[]; remoteGate?: string; standIn?: string } { const lines = stderr.split('\n'); const last = lines.findLast(x => x.startsWith('gstack-evidence: recorded label=')); const gateRow = last && / tests_ran=(\d+) log=- source=remote_gate receipt=(.+)$/.exec(last); @@ -220,7 +220,8 @@ export function recordCiResult(stderr: string): { runs?: string; tests?: string; 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+)( not_owed_here=\d+)?$/.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]}${here[3] ?? ''}` } : {}), ...(exception ? { exception } : {}) }; + const reused = lines.slice(at + 1).map(x => /^CI_RECORD_REUSED: .* tree=([0-9a-f]+) /.exec(x)?.[1]).find(Boolean); + 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]}${here[3] ?? ''}` } : {}), ...(exception ? { exception } : {}), ...(reused ? { reused } : {}) }; } /** The per_file runner command for `files`: `{files}` replaced by the paths, each anchored with ./ (Bun reads a bare relative path as a substring filter) and single-quoted for bash. */ diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 8dca315f5..94f46da2e 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -204,7 +204,7 @@ export function carryLane(cwd: string, base: string, l: { label: string; command const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--remote-gate-stands-in', '--', l.command], timeoutS * 1000); const got = recordCiResult(rec.stderr); if (rec.status === 0 && got.remoteGate) return { label: l.label, exit: 0, testsRan: got.tests!, log: '-', remoteGate: got.remoteGate, standIn: got.standIn ?? 'not carried' }; - 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 } : {}), ...(got.exception ? { exception: got.exception } : {}) }; + 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 } : {}), ...(got.exception ? { exception: got.exception } : {}), ...(got.reused ? { reused: got.reused } : {}) }; return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}`, ...(got.diagnosis ? { diagnosis: got.diagnosis } : {}) }; } @@ -245,7 +245,7 @@ export async function runLanes(cwd: string, list: { label: string; command: stri /** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone; a carried lane names its CI runs. */ export function laneLine(l: LaneResult): { line: string; ok: boolean } { if (l.notOwed) return { line: `LANE: ${l.label} not owed: ${l.notOwed} run=${l.carried}`, ok: true }; - if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}${l.here ? ` ${l.here}` : ''}`, ok: true }; + if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}${l.here ? ` ${l.here}` : ''}${l.reused ? ` (reused the CI record at tree ${l.reused})` : ''}`, ok: true }; if (l.remoteGate) return { line: `LANE: ${l.label} satisfied by remote_gate receipt=${l.remoteGate}`, 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'; diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 29462225a..4e2fa8165 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -439,6 +439,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> file: 'test/ship-accept-remote-gate-lanes.test.ts', reason: 'drives bin/gstack-ship-release, gstack-evidence and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-accept-same-tree-clears.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42302497169)', }, + { + file: 'test/ship-record-ci-reuse.test.ts', + reason: 'drives bin/gstack-evidence, gstack-ship-preflight and gstack-ship-release (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-accept-remote-gate-lanes.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42382484796)', + }, ]; // Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for diff --git a/test/ship-ci-carry.test.ts b/test/ship-ci-carry.test.ts index 8a8c9154c..ccd50c66d 100644 --- a/test/ship-ci-carry.test.ts +++ b/test/ship-ci-carry.test.ts @@ -599,7 +599,8 @@ describe('GST-1190: a lane carries the covered files and runs only the skipped r expect(args(f).trim().split('\n')).toEqual(SIX.map(([p]) => './' + p)); const ci = rows(f).find(r => r.source === 'ci'); expect(ci).toMatchObject({ label: 'free', tests_ran: 1, tests_unit: 'files', skipped_here: { label: 'free.skipped', files: SIX.map(([p]) => p), tests_ran: 0, not_owed: SIX.map(([p]) => p) } }); - expect(lane(preflight(f, [`free=${FREE}`]).stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=6 file(s) tests_ran_here=0 not_owed_here=6'); + expect(lane(preflight(f, [`free=${FREE}`]).stdout, 'free')).toMatch(/^LANE: free carried run=7\/1 tests_ran=1 \(files\) skipped_here=6 file\(s\) tests_ran_here=0 not_owed_here=6 \(reused the CI record at tree [0-9a-f]{12}\)$/); + expect(args(f).trim().split('\n')).toHaveLength(SIX.length); }); test('GST-1314: a remainder that mixes a file that ran here with one that registers no test still refuses', () => { diff --git a/test/ship-record-ci-reuse.test.ts b/test/ship-record-ci-reuse.test.ts new file mode 100644 index 000000000..2cc44c694 --- /dev/null +++ b/test/ship-record-ci-reuse.test.ts @@ -0,0 +1,142 @@ +/** + * GST-1795: `gstack-evidence record-ci` reuses its own CI row when the label's newest ledger row already records the same + * verified run at the same clean tree, profiles, policy and selection, instead of running the per_file remainder again. + * harness-evals #315: /ship's Step 5 carry ran the remainder (one file takes about 7 minutes), then accept's record-ci at the + * identical tree ran it again and hit the 600 s ci_record_timeout_s. + */ +import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs'; +import { findFilesBySuffix } from './helpers/scratch-repo'; +import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; + +setDefaultTimeout(120_000); +const EVIDENCE = path.join(path.dirname(RELEASE), 'gstack-evidence'); +const fixtures: ShipFx[] = []; +afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); }); + +const FREE = "printf x >> ../free-runs; printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2"; +const PER = "printf '%s\\n' {files} >> ../perfile-args; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2"; +const HOST_OS = ({ darwin: 'macOS', win32: 'Windows' } as Record)[process.platform] ?? 'Linux'; +const RUNNER = ({ darwin: 'macos-latest', win32: 'windows-latest' } as Record)[process.platform] ?? 'ubuntu-latest'; +const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: HOST_OS }; +const 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', + '.github/workflows/free.yml': `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ${RUNNER}\n steps:\n - run: bun test\n`, +}; +const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString(); + +/** Run 7 of free.yml at HEAD, green, whose tree.json names HEAD's tree and lists test/b.test.ts as skipped entirely. */ +function world(f: ShipFx, run: Record = {}): void { + const head = f.git('rev-parse', 'HEAD').stdout.trim(); + const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim(); + const r = { 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' }, ...run }; + const w: FakeWorld = { + runs: [r] as FakeWorld['runs'], + jobs: { [r.id as number]: [{ name: 'suite', conclusion: r.conclusion === 'success' ? 'success' : 'failure', completed_at: at(5) }] }, + trees: { [r.id as number]: { run_id: r.id, run_attempt: r.run_attempt, event: r.event, sha: head, tree, files: ['test/a.test.ts', 'test/b.test.ts'], skipped_files: ['test/b.test.ts'], tests: 12, os: HOST_OS } }, + }; + fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w)); + f.gh(fakeGhScript(path.join(f.root, 'world.json'))); +} +function fx(): ShipFx { const f = shipFixture(BASE); fixtures.push(f); world(f); return f; } +const recordCi = (f: ShipFx) => f.run(EVIDENCE, ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]); +const perFileRuns = (f: ShipFx) => { const p = path.join(f.root, 'perfile-args'); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).length : 0; }; +const ledger = (f: ShipFx) => findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!; +const rows = (f: ShipFx) => fs.readFileSync(ledger(f), 'utf8').trim().split('\n').map(l => JSON.parse(l)); +/** Rewrite the newest row matching `pick`, as a stale or foreign record would read. */ +function tamper(f: ShipFx, pick: (r: Record) => boolean, edit: (r: Record) => Record): void { + const all = rows(f); + const i = all.findLastIndex(pick); + expect(i).toBeGreaterThanOrEqual(0); + all[i] = edit(all[i]); + fs.writeFileSync(ledger(f), all.map(r => JSON.stringify(r)).join('\n') + '\n'); +} +const isCi = (r: Record) => r.source === 'ci'; +const isHere = (r: Record) => r.label === 'free.skipped'; + +describe('record-ci reuses its own CI row at the same tree (GST-1795)', () => { + // Value: protects=a second record-ci at an identical tree, run, profile, policy and selection reuses the row instead of rerunning + // the per_file remainder, and names the run it reused; fails_when=harness-evals #315: accept reruns a ~7-minute remainder file + // that Step 5 already ran at the same tree and stops at ci_record_timeout_s; seam=fake gh, gstack-evidence record-ci + test('the second record-ci at the same tree reuses the row and runs nothing here', () => { + const f = fx(); + const first = recordCi(f); + expect([first.status, perFileRuns(f)], first.stderr).toEqual([0, 1]); + const second = recordCi(f); + expect([second.status, perFileRuns(f)], second.stderr).toEqual([0, 1]); + expect(lineOf(second.stderr, 'CI_RECORD_REUSED')).toMatch(/^CI_RECORD_REUSED: label=free runs=7\/1 tree=[0-9a-f]{12} policy=origin\/main@/); + expect(second.stderr).toMatch(/^gstack-evidence: recorded label=free exit=0 tests_ran=1 unit=files log=- source=ci runs=7\/1 policy=\S+ skipped_here=1 tests_ran_here=1$/m); + expect(rows(f).filter(isCi)).toHaveLength(1); + }); + + // Value: protects=the /ship flow end to end: Step 5's carry ran the remainder, so accept at the pushed, identical tree reuses it and + // says so on its LANE line; fails_when=accept's record-ci runs the remainder again; seam=bin/gstack-ship-preflight, bin/gstack-ship-release accept + test('accept after the Step 5 carry at the same tree reuses it', () => { + const f = fx(); + const pre = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', `free=${FREE}`]); + expect([pre.status, perFileRuns(f)], pre.stdout + pre.stderr).toEqual([0, 1]); + expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0); + const out = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']); + expect(out.stdout.split('\n').find(l => l.startsWith('LANE: free ')), out.stdout + out.stderr).toMatch(/^LANE: free carried run=7\/1 tests_ran=1 \(files\) skipped_here=1 file\(s\) tests_ran_here=1 \(reused the CI record at tree [0-9a-f]{12}\)$/); + expect(lineOf(out.stdout, 'EVIDENCE')).toStartWith('EVIDENCE: FRESH label=free'); + expect(perFileRuns(f)).toBe(1); + }); + + // Value: protects=reuse fails closed: any field that would differ in a fresh record (tree, command, profiles, policy, runs, count, + // selection, the remainder's local runs and their runtime) records afresh and reruns the remainder; fails_when=a stale or foreign + // row is reused; seam=gstack-evidence record-ci against a tampered ledger + test.each([ + ['tree', isCi, (r: Record) => ({ ...r, wtree: '0'.repeat(40) })], + ['command', isCi, (r: Record) => ({ ...r, cmd_sha256: '0'.repeat(64) })], + ['profiles', isCi, (r: Record) => ({ ...r, profiles: ['ci/other'] })], + ['policy', isCi, (r: Record) => ({ ...r, policy: 'origin/main@000000000000:.gstack/pregate.json' })], + ['runs', isCi, (r: Record) => ({ ...r, runs: ['6/1'] })], + ['covered count', isCi, (r: Record) => ({ ...r, tests_ran: 2 })], + ['remainder selection', isCi, (r: Record) => ({ ...r, skipped_here: { ...r.skipped_here, files: ['test/a.test.ts'] } })], + ['age', isCi, (r: Record) => ({ ...r, ts: at(25 * 60) })], + ['exception', isCi, (r: Record) => ({ ...r, exception: { approval: 'a-1', head: r.commit } })], + ['clean flag', isCi, (r: Record) => ({ ...r, dirty: true })], + ['remainder runtime', isHere, (r: Record) => ({ ...r, identity: '0'.repeat(64) })], + ['remainder verdict', isHere, (r: Record) => ({ ...r, exit: 1 })], + ])('a mismatched %s records afresh', (_name, pick, edit) => { + const f = fx(); + expect(recordCi(f).status).toBe(0); + tamper(f, pick, edit); + const again = recordCi(f); + expect([again.status, perFileRuns(f), lineOf(again.stderr, 'CI_RECORD_REUSED')], again.stderr).toEqual([0, 2, '']); + }); + + // Value: protects=the run's own verdict still decides: a run that turned red (a failed rerun attempt) carries nothing even with a + // matching row in the ledger; fails_when=the ledger row is reused without the live check; seam=fake gh, gstack-evidence record-ci + test('a run that is no longer green carries nothing, ledger row or not', () => { + const f = fx(); + expect(recordCi(f).status).toBe(0); + world(f, { run_attempt: 2, conclusion: 'failure' }); + const again = recordCi(f); + expect([again.status, perFileRuns(f), lineOf(again.stderr, 'CI_RECORD_REUSED')], again.stderr).toEqual([1, 1, '']); + expect(again.stderr).toMatch(/^CARRY_NONE: /m); + }); + + // Value: protects=reuse stands only on the remainder runs the CI row was written from, so a newer `.skipped` row (a zero-test rerun + // after a runtime change, which record-ci refuses as skipped_zero_run) never reads as the remainder's pass; fails_when=the newest + // exit-0 remainder row past the CI row satisfies reuse; seam=gstack-evidence record-ci against an appended ledger row + test('a remainder run newer than the CI row vetoes reuse', () => { + const f = fx(); + expect(recordCi(f).status).toBe(0); + fs.appendFileSync(ledger(f), JSON.stringify({ ...rows(f).findLast(isHere)!, tests_ran: 0 }) + '\n'); + const again = recordCi(f); + expect([again.status, perFileRuns(f), lineOf(again.stderr, 'CI_RECORD_REUSED')], again.stderr).toEqual([0, 2, '']); + }); + + // Value: protects=a newer label row (a local run of the lane after the carry) is what the ledger reads, so it vetoes reuse; + // fails_when=an older CI row is reused past a newer local row; seam=gstack-evidence run, record-ci + test('a newer row for the label vetoes reuse', () => { + const f = fx(); + expect(recordCi(f).status).toBe(0); + expect(f.run(EVIDENCE, ['run', '--label', 'free', '--', FREE]).status).toBe(0); + const again = recordCi(f); + expect([again.status, perFileRuns(f), lineOf(again.stderr, 'CI_RECORD_REUSED')], again.stderr).toEqual([0, 2, '']); + }); +});