diff --git a/changelog.d/ci-carry-accepts-unrelated-job-rerun.md b/changelog.d/ci-carry-accepts-unrelated-job-rerun.md
new file mode 100644
index 000000000..9fc3e8606
--- /dev/null
+++ b/changelog.d/ci-carry-accepts-unrelated-job-rerun.md
@@ -0,0 +1,4 @@
+---
+important: blocker
+---
+- **The CI carry now accepts a run where only an unrelated job was rerun.** Rerunning one job of a green run (a flaky `public-export`, say) made GitHub number the run's newest attempt 2 while the checks job, which wasn't rerun, still carried its attempt-1 `tree.json`, so `accept` refused with `artifact_mismatch`; rerunning the checks job too left two same-named artifacts and refused with `artifact_missing`. Run verification now works out which attempt each profile job actually ran in (GitHub relists a job it didn't rerun with its old start and finish times) and checks `tree.json` against that attempt, and with one artifact per attempt it reads the one from the uploading job's newest attempt. Every other field (head, merge commit, tree, event, job conclusion) is still compared exactly, and an older attempt's artifact is never read in place of the newest. The 24-hour freshness check now dates a run by its own test jobs, so rerunning an unrelated job never makes days-old results look fresh. When the pregate's skipped-tests command fails because the checkout has no installed dependencies, the refusal now says so (GST-2284, harness-evals HARN-56).
diff --git a/docs/pregate.md b/docs/pregate.md
index 32383920c..3ad21242b 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -833,6 +833,12 @@ 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.
+ The attempt it names is the newest one that ran a profile job itself
+ (GST-2284). A job-level rerun relists every job it did not rerun under the
+ new attempt with that job's old start and finish times, so a run where only
+ an unrelated job was rerun verifies against the attempt whose job uploaded
+ `tree.json`. A rerun of the uploading job leaves one artifact per attempt;
+ the one that names the job's newest attempt is read, and no other.
`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
@@ -869,11 +875,10 @@ once:
failed, was cancelled or is still running (`run_started_at`, so a red re-run
of an older run counts, a run started in the same second counts, and
re-running this run does not hide a failure in between).
-- The run finished within `max_age_hours` (its jobs' latest `completed_at`,
- else the run's `updated_at`), and on the profile's OS when it names one.
- Re-running only the failed jobs refuses (`artifact_mismatch`) unless the job
- that uploads `tree.json` re-runs too, because that file must name the
- newest attempt. The 8 MiB cap is read from the artifact's zipped size before
+- The run finished within `max_age_hours` (its profile jobs' latest
+ `completed_at`, else the run's `updated_at`), and on the profile's OS when it
+ names one. A job outside the profile never dates the run, so rerunning one
+ does not refresh old test results. The 8 MiB cap is read from the artifact's zipped size before
download, so a workflow on the branch itself can still unpack a larger file
into the temporary directory.
@@ -902,7 +907,8 @@ Then, for the lane:
(gstack's gate leaves paid test files to the paid PR gate). Those files need
no run, and the receipt counts them as `skipped=`. A file the inventory
prints stays required even when `skipped` names it, and a failing `skipped`
- command refuses (`skipped_failed`). When `skipped` names every selected file,
+ command refuses (`skipped_failed`); when it failed because the checkout has
+ no installed dependencies, the refusal says so. When `skipped` names every selected file,
no selected test ran, so the clear refuses (`paid_only`), just as the gate's
own `tests=0` report never clears. Without `skipped`, every selected file
must appear in a run's `files`.
diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts
index 54788e98c..152eca8c8 100644
--- a/lib/pregate/index.ts
+++ b/lib/pregate/index.ts
@@ -455,7 +455,8 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run
const skipCommand = check.gateRuns!.skipped;
if (skipCommand && required.size) {
const skipped = check.inventory(skipCommand, file.tree, [...required]);
- if ('error' in skipped) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=skipped_failed — the remote gate's skipped command failed: ${skipped.error}; ${lane} stays uncleared` };
+ const noDeps = 'error' in skipped && /Cannot find (?:package|module) '/.test(skipped.error);
+ if ('error' in skipped) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=skipped_failed — the remote gate's skipped command failed${noDeps ? ' because this checkout has no installed dependencies (install them, for example with bun install, then clear again)' : ''}: ${skipped.error}; ${lane} stays uncleared` };
const inventory = new Set(listed);
for (const f of skipped) if (!inventory.has(f) && required.delete(f)) skippedCount++;
if (!required.size) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=paid_only — the remote gate skips every selected test file (${skippedCount}), so no selected test ran; like the gate's own tests=0 report, that never clears; ${lane} stays uncleared` };
diff --git a/lib/run-evidence.ts b/lib/run-evidence.ts
index fae562338..f913fd6c8 100644
--- a/lib/run-evidence.ts
+++ b/lib/run-evidence.ts
@@ -54,7 +54,7 @@ export interface ApiRun {
path?: string; created_at?: string; run_started_at?: string; updated_at?: string;
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null; head_branch?: string | null;
}
-export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null }
+export interface ApiJob { name: string; conclusion: string | null; started_at?: 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; sizing_experiment?: 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. */
@@ -69,7 +69,8 @@ export interface RunDeps {
run(id: number): ApiRun;
runsForHead(workflow: string, sha: string): ApiRun[];
jobs(id: number, attempt: number): ApiJob[];
- artifact(id: number, name: string): TreeRecord;
+ /** The tree.json of the run's unexpired artifact `name`: the only one, or with one per rerun attempt of its job, the one that records run_attempt `attempt`. */
+ artifact(id: number, name: string, attempt: number): TreeRecord;
commitTree(sha: string): string | null;
}
@@ -89,6 +90,8 @@ const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/;
export const DEFAULT_MAX_AGE_HOURS = 24;
const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
+/** Same-named artifacts one run may hold, one per attempt of the job that uploads it. */
+const MAX_ARTIFACT_ATTEMPTS = 10;
/** Shard logs are the whole test stream, so a red run's reader allows more than a tree.json. */
const MAX_LOG_ARTIFACT_BYTES = 64 * 1024 * 1024;
@@ -172,7 +175,7 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
if (run.conclusion !== 'success') return fail('run_not_success', `${at} concluded ${run.conclusion}`);
if (!SHA.test(run.head_sha)) return fail('api_error', `${at} has no head commit`);
const jobs = deps.jobs(id, run.run_attempt);
- const completed = jobs.map(j => j.completed_at).filter((t): t is string => !!t).sort().pop();
+ const completed = jobs.filter(j => p.jobs.includes(j.name)).map(j => j.completed_at).filter((t): t is string => !!t).sort().pop();
const finishedAt = completed ?? run.updated_at;
const finished = Date.parse(finishedAt ?? '');
const maxAge = p.max_age_hours ?? DEFAULT_MAX_AGE_HOURS;
@@ -208,15 +211,16 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
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, profile: o.profileName, event: run.event, finished_at: finishedAt!, files: [], tests: null, skipped_jobs: skippedJobs, docs_only: true } };
}
+ const ranIn = ownAttempt(deps, id, run.run_attempt, jobs, p.jobs.filter(n => !skippedJobs.includes(n)));
let rec: TreeRecord;
- try { rec = deps.artifact(id, p.artifact); }
+ try { rec = deps.artifact(id, p.artifact, ranIn); }
catch (e: any) { return fail('artifact_missing', `${at} has no readable ${p.artifact} tree.json: ${String(e?.message ?? e).slice(0, 200)}`); }
if (!rec || typeof rec !== 'object') return fail('artifact_mismatch', `${at}'s ${p.artifact} tree.json is not a JSON object`);
if (rec.reused === true || rec.reuse === true) return fail('artifact_reused', `${at} reused another run; a reuse is never evidence`);
if (rec.sizing_experiment !== undefined) return fail('sizing_experiment', `${at} was a runner-sizing experiment (a dispatch that overrode the runner, shards or sub-shards); it is never a Free Tests result`);
const pr = run.event === 'pull_request';
const recHeads = pr ? [['head_sha', rec.head_sha, run.head_sha] as const] : rec.head_sha === undefined && rec.sha === undefined ? [['head', undefined, run.head_sha] as const] : ([['sha', rec.sha, run.head_sha], ['head_sha', rec.head_sha, run.head_sha]] as const).filter(([, got]) => got !== undefined);
- const wrong = ([['run_id', rec.run_id, id], ['run_attempt', rec.run_attempt, run.run_attempt], ['event', rec.event, run.event], ...recHeads] as const).filter(([, got, want]) => got !== want);
+ const wrong = ([['run_id', rec.run_id, id], ['run_attempt', rec.run_attempt, ranIn], ['event', rec.event, run.event], ...recHeads] as const).filter(([, got, want]) => got !== want);
if (wrong.length) return fail('artifact_mismatch', `${at}'s ${p.artifact} records ${wrong.map(([k, got, want]) => `${k} ${String(got)} (expected ${want})`).join(', ')}`);
const headTree = deps.commitTree(run.head_sha);
if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
@@ -248,6 +252,24 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
}
}
+/**
+ * The newest attempt that ran any of `names` itself (GST-2284). A job-level rerun relists every job it did not rerun under the
+ * new attempt with its old start and finish times, so a job listed with the same times and conclusion one attempt earlier
+ * was carried from there, and so is the artifact it uploaded. A job without both times counts as run in `attempt`.
+ */
+function ownAttempt(deps: RunDeps, id: number, attempt: number, jobs: ApiJob[], names: string[]): number {
+ const carried = (a: ApiJob, b: ApiJob) => a.started_at === b.started_at && a.completed_at === b.completed_at && a.conclusion === b.conclusion;
+ let pending = names.map(n => jobs.find(j => j.name === n)!);
+ if (!pending.length || pending.some(j => !j.started_at || !j.completed_at)) return attempt;
+ for (let k = attempt - 1; k >= 1; k--) {
+ const earlier = deps.jobs(id, k);
+ const still = pending.filter(j => { const hits = earlier.filter(e => e.name === j.name); return hits.length === 1 && carried(hits[0]!, j); });
+ if (still.length < pending.length) return k + 1;
+ pending = still;
+ }
+ return 1;
+}
+
/** Why run `id` no longer stands as attempt `attempt`, a success; null while it does. Read again after the evidence, and for every run once a whole clear is assembled. */
export function runChanged(deps: RunDeps, id: number, attempt: number): string | null {
const again = deps.run(id);
@@ -314,9 +336,9 @@ export function parseRunReceipt(receipt: string): { runs: string[]; profiles: st
const receiptFor = (host: string, payloadClass: string, request: string) => ({ sink: 'run-evidence', host, payloadClass, bytes: 0, sha256: sha256Hex(request), consent: 'a run verification the user or the repository\'s own workflow started (gstack-pregate clear --run or --from-run, the main-push reuse job); reads its own repository\'s Actions runs and fetches a run head by sha' });
const receiptFailed = (err: unknown) => process.stderr.write(`[run-evidence] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
-function gh(args: string[], timeoutMs = 60_000): string {
+function gh(args: string[], timeoutMs = 60_000, encoding: 'utf8' | 'latin1' = 'utf8'): string {
try { writeReceipt(receiptFor('api.github.com', 'actions-run-read', args.join(' '))); } catch (err) { receiptFailed(err); }
- const r = ghSpawnSync(args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
+ const r = ghSpawnSync(args, { encoding, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0 || r.error) throw new Error(`gh ${args.slice(0, 2).join(' ')} failed: ${(r.stderr || String(r.error ?? '')).trim().slice(0, 300)}`);
return r.stdout;
}
@@ -337,16 +359,30 @@ export function ghRunDeps(repo: string, repoRoot: string): RunDeps {
const read = (n: number) => all(`repos/${repo}/actions/runs/${id}/attempts/${attempt}/jobs?per_page=${n}`, 'jobs');
try { return read(50); } catch (e) { if (!/\bHTTP 5\d\d\b/.test((e as Error).message)) throw e; return read(25); }
},
- artifact: (id, name) => {
- const listed = all<{ name: string; size_in_bytes: number; expired: boolean }>(`repos/${repo}/actions/runs/${id}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, 'artifacts').filter(a => a.name === name && !a.expired);
- if (listed.length !== 1) throw new Error(`run ${id} has ${listed.length} unexpired artifacts named ${name}`);
- if (!(listed[0]!.size_in_bytes <= MAX_ARTIFACT_BYTES)) throw new Error(`artifact ${name} is ${listed[0]!.size_in_bytes} bytes, over ${MAX_ARTIFACT_BYTES}`);
+ // A rerun of the uploading job leaves one artifact per attempt (GST-2284): read each by id and keep the one from `attempt`.
+ artifact: (id, name, attempt) => {
+ const listed = all<{ id: number; name: string; size_in_bytes: number; expired: boolean }>(`repos/${repo}/actions/runs/${id}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, 'artifacts').filter(a => a.name === name && !a.expired);
+ if (listed.length === 0 || listed.length > MAX_ARTIFACT_ATTEMPTS) throw new Error(`run ${id} has ${listed.length} unexpired artifacts named ${name}`);
+ for (const a of listed) if (!(a.size_in_bytes <= MAX_ARTIFACT_BYTES)) throw new Error(`artifact ${name} is ${a.size_in_bytes} bytes, over ${MAX_ARTIFACT_BYTES}`);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-run-evidence-'));
try {
- gh(['run', 'download', String(id), '--repo', repo, '--name', name, '--dir', dir], 120_000);
- const file = path.join(dir, 'tree.json');
- if (fs.statSync(file).size > MAX_ARTIFACT_BYTES) throw new Error(`tree.json is over ${MAX_ARTIFACT_BYTES} bytes`);
- return JSON.parse(fs.readFileSync(file, 'utf8')) as TreeRecord;
+ if (listed.length === 1) {
+ gh(['run', 'download', String(id), '--repo', repo, '--name', name, '--dir', dir], 120_000);
+ const file = path.join(dir, 'tree.json');
+ if (fs.statSync(file).size > MAX_ARTIFACT_BYTES) throw new Error(`tree.json is over ${MAX_ARTIFACT_BYTES} bytes`);
+ return JSON.parse(fs.readFileSync(file, 'utf8')) as TreeRecord;
+ }
+ const records = listed.map(a => {
+ if (!Number.isSafeInteger(a.id)) throw new Error(`run ${id}'s artifact ${name} has no numeric id`);
+ const zip = path.join(dir, `${a.id}.zip`);
+ fs.writeFileSync(zip, Buffer.from(gh(['api', `repos/${repo}/actions/artifacts/${a.id}/zip`], 120_000, 'latin1'), 'latin1'));
+ const r = spawnSync('unzip', ['-p', zip, 'tree.json'], { encoding: 'utf8', timeout: 60_000, maxBuffer: MAX_ARTIFACT_BYTES });
+ if (r.status !== 0 || r.error) throw new Error(`artifact ${a.id} has no readable tree.json: ${(r.stderr || String(r.error ?? '')).trim().slice(0, 200)}`);
+ return JSON.parse(r.stdout) as TreeRecord;
+ });
+ const hits = records.filter(r => r && r.run_attempt === attempt);
+ if (hits.length !== 1) throw new Error(`run ${id} has ${listed.length} unexpired artifacts named ${name} and ${hits.length} of them record run_attempt ${attempt}`);
+ return hits[0]!;
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
},
commitTree: sha => {
diff --git a/test/pregate-remote-gate-runs.test.ts b/test/pregate-remote-gate-runs.test.ts
index 7eb901c56..85846b50a 100644
--- a/test/pregate-remote-gate-runs.test.ts
+++ b/test/pregate-remote-gate-runs.test.ts
@@ -138,6 +138,9 @@ describe.skipIf(process.platform === 'win32')('clear --run with the Free Tests p
expect(clear({ head, files: files.filter(f => f !== 'test/other.test.js') })).toContain("reason=inventory_uncovered — 1 of the");
expect(clear({ head, files: files.filter(f => f !== 'test/other.test.js') }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: `${SKIPPED}; echo test/other.test.js` } })).toContain('(test/other.test.js)');
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: 'exit 3' } })).toContain('reason=skipped_failed');
+ expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: 'exit 3' } })).not.toContain('no installed dependencies');
+ expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: "echo 'TypeError: x at /repo/node_modules/zod/index.js:1' >&2; exit 1" } })).not.toContain('no installed dependencies');
+ expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: "echo \"error: Cannot find package 'zod' from '/x/test/helpers/paid-test-set.ts'\" >&2; exit 1" } })).toContain('reason=skipped_failed — the remote gate\'s skipped command failed because this checkout has no installed dependencies (install them, for example with bun install, then clear again): ');
const paidOnly = structuredClone(file);
paidOnly.requires_remote!.find(r => r.lane === LANE)!.tests = [PAID];
expect(msg(clearDecision(paidOnly, LANE, { runs: [RUN], evidence: gateCheck(repo, { head, files }) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=paid_only — the remote gate skips every selected test file (1), so no selected test ran; like the gate's own tests=0 report, that never clears; ${LANE} stays uncleared`);
diff --git a/test/run-evidence.test.ts b/test/run-evidence.test.ts
index a68bf735c..3bb2860cf 100644
--- a/test/run-evidence.test.ts
+++ b/test/run-evidence.test.ts
@@ -14,7 +14,7 @@ const TREE = 'b'.repeat(40);
const NOW = new Date('2026-10-29T12:00:00Z');
const PROFILE: RunProfile = { workflow: 'test.yml', events: ['workflow_dispatch', 'push'], jobs: ['full-suite'], artifact: 'full-suite-tree' };
-interface World { run: ApiRun; others: ApiRun[]; jobs: ApiJob[]; artifact: TreeRecord | Error; trees: Record; throwOn?: 'run'; later?: ApiRun }
+interface World { run: ApiRun; others: ApiRun[]; jobs: ApiJob[]; jobsByAttempt?: Record; artifact: TreeRecord | Error; trees: Record; throwOn?: 'run'; later?: ApiRun }
function world(over: Partial = {}): World {
const run: ApiRun = {
id: 100, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: HEAD,
@@ -32,7 +32,7 @@ function deps(w: World): RunDeps {
return {
run: id => { if (w.throwOn === 'run') throw new Error('HTTP 502'); if (id !== w.run.id) throw new Error('HTTP 404'); return reads++ && w.later ? w.later : w.run; },
runsForHead: (_wf, sha) => [w.run, ...w.others].filter(r => r.head_sha === sha),
- jobs: () => w.jobs,
+ jobs: (_id, attempt) => w.jobsByAttempt?.[attempt] ?? w.jobs,
artifact: () => { if (w.artifact instanceof Error) throw w.artifact; return w.artifact; },
commitTree: sha => w.trees[sha] ?? null,
};
@@ -320,3 +320,112 @@ describe.skipIf(process.platform === 'win32')('ghRunDeps jobs read through a pro
expect(missing.calls()).toEqual([page(50)]);
});
});
+
+describe('job-level reruns (GST-2284, HARN-56 run 42724981450)', () => {
+ // Shapes from harness-evals run 42724981450: GitHub relists a job it did not rerun under each later attempt with its old
+ // start and finish times, and a rerun of the uploading job leaves one checks-tree artifact per attempt.
+ const MERGE = 'e'.repeat(40);
+ const LATER = new Date('2026-11-10T02:00:00Z');
+ const CHECKS: RunProfile = { workflow: 'test.yml', events: ['pull_request', 'push'], jobs: ['checks'], artifact: 'checks-tree', os: 'Linux' };
+ const checks1: ApiJob = { name: 'checks', conclusion: 'success', started_at: '2026-11-10T00:02:31Z', completed_at: '2026-11-10T00:19:51Z' };
+ const checks3: ApiJob = { name: 'checks', conclusion: 'success', started_at: '2026-11-10T01:27:17Z', completed_at: '2026-11-10T01:46:47Z' };
+ const export1: ApiJob = { name: 'public-export', conclusion: 'failure', started_at: '2026-11-10T00:03:28Z', completed_at: '2026-11-10T00:40:49Z' };
+ const export2: ApiJob = { name: 'public-export', conclusion: 'success', started_at: '2026-11-10T00:45:04Z', completed_at: '2026-11-10T01:20:31Z' };
+ const record = (attempt: number, over: Partial = {}): TreeRecord => ({ run_id: 100, run_attempt: attempt, event: 'pull_request', sha: MERGE, head_sha: HEAD, tree: TREE, os: 'Linux', files: ['test/a.test.ts'], tests: 4, ...over });
+ const rerun = (attempt: number, jobsByAttempt: Record, artifact: TreeRecord, trees: Record = { [HEAD]: TREE, [MERGE]: TREE }) => {
+ const base = world();
+ return world({ run: { ...base.run, event: 'pull_request', run_attempt: attempt, created_at: '2026-11-10T00:00:48Z', run_started_at: '2026-11-10T00:42:13Z', updated_at: '2026-11-10T01:20:31Z' }, jobs: jobsByAttempt[attempt]!, jobsByAttempt, artifact, trees });
+ };
+ const verify = (w: World, d: RunDeps = deps(w)) => verifyRun(d, { repo: REPO, runRef: '100', profileName: 'ci/checks', profile: CHECKS, now: LATER });
+ const why = (w: World) => { const v = verify(w); return v.ok ? 'ok' : v.reason; };
+ const onlyExportRerun = { 1: [checks1, export1], 2: [export2, checks1] };
+
+ // Value: protects=Case A, a rerun of an unrelated job keeps the carry; fails_when=the artifact must record the run's newest attempt
+ test('a checks job carried unchanged from attempt 1 verifies its attempt-1 artifact in attempt 2', () => {
+ const v = verify(rerun(2, onlyExportRerun, record(1)));
+ expect(v.ok ? [v.evidence.run_attempt, v.evidence.tree] : v.detail).toEqual([2, TREE]);
+ });
+
+ // Value: protects=freshness dates a carried run by its profile jobs; fails_when=rerunning an unrelated job refreshes old test results
+ test('rerunning an unrelated job does not refresh a carried run whose checks job finished long ago', () => {
+ const old = { ...checks1, started_at: '2026-11-02T00:02:31Z', completed_at: '2026-11-02T00:19:51Z' };
+ expect(why(rerun(2, { 1: [old, export1], 2: [export2, old] }, record(1)))).toBe('run_stale');
+ const v = verify(rerun(2, onlyExportRerun, record(1)));
+ expect(v.ok && v.evidence.finished_at).toBe(checks1.completed_at!);
+ });
+
+ // Value: protects=the carried attempt binds only the job's own attempt and every other field; fails_when=any recorded attempt or field is accepted
+ test('a carried artifact still refuses a different tree, head, merge tree, event, run or job conclusion', () => {
+ expect(why(rerun(2, onlyExportRerun, record(1, { tree: 'd'.repeat(40) })))).toBe('tree_mismatch');
+ expect(why(rerun(2, onlyExportRerun, record(1, { head_sha: 'c'.repeat(40) })))).toBe('artifact_mismatch');
+ expect(why(rerun(2, onlyExportRerun, record(1), { [HEAD]: TREE, [MERGE]: 'f'.repeat(40) }))).toBe('tree_mismatch');
+ expect(why(rerun(2, onlyExportRerun, record(1, { event: 'push' })))).toBe('artifact_mismatch');
+ expect(why(rerun(2, onlyExportRerun, record(1, { run_id: 99 })))).toBe('artifact_mismatch');
+ expect(why(rerun(2, { 1: [checks1, export1], 2: [export2, { ...checks1, conclusion: 'failure' }] }, record(1)))).toBe('job_not_success');
+ expect(why(rerun(2, onlyExportRerun, record(2)))).toBe('artifact_mismatch');
+ });
+
+ // Value: protects=a rerun checks job never counts an older attempt's artifact; fails_when=any earlier attempt the run had is accepted
+ test('a checks job rerun in a later attempt needs the artifact from that attempt', () => {
+ const rerunChecks = { 1: [checks1, export1], 2: [export2, checks1], 3: [checks3, export2] };
+ expect(why(rerun(3, rerunChecks, record(3)))).toBe('ok');
+ expect(why(rerun(3, rerunChecks, record(1)))).toBe('artifact_mismatch');
+ expect(why(rerun(3, rerunChecks, record(2)))).toBe('artifact_mismatch');
+ expect(why(rerun(3, { 1: [checks1, export1], 2: [export2, checks1], 3: [checks1, export2] }, record(1)))).toBe('ok');
+ expect(why(rerun(2, { 1: [checks1, export1], 2: [export2, { ...checks1, started_at: undefined }] }, record(1)))).toBe('artifact_mismatch');
+ });
+
+ describe.skipIf(process.platform === 'win32')('ghRunDeps with one checks-tree artifact per attempt (Case B)', () => {
+ const dirs: string[] = [];
+ afterAll(() => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });
+ const storedZip = (name: string, body: string): Buffer => {
+ const data = Buffer.from(body), file = Buffer.from(name), crc = Bun.hash.crc32(data) >>> 0;
+ const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(file.length, 26);
+ const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(file.length, 28);
+ const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10); end.writeUInt32LE(46 + file.length, 12); end.writeUInt32LE(30 + file.length + data.length, 16);
+ return Buffer.concat([local, file, data, central, file, end]);
+ };
+ function stubGh(records: TreeRecord[]) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst2284-'));
+ dirs.push(dir);
+ records.forEach((r, i) => fs.writeFileSync(path.join(dir, `${i + 1}.zip`), storedZip('tree.json', JSON.stringify(r))));
+ fs.writeFileSync(path.join(dir, 'gh'), [
+ '#!/usr/bin/env bash',
+ 'url=""; for a in "$@"; do case "$a" in repos/*) url="$a";; esac; done',
+ 'case "$url" in',
+ ` */actions/runs/100/artifacts\\?name=checks-tree*) for i in ${records.map((_, i) => i + 1).join(' ')}; do printf '{"id":%d,"name":"checks-tree","size_in_bytes":200,"expired":false}\\n' $i; done;;`,
+ ` */actions/artifacts/*/zip) id="${'$'}{url%/zip}"; cat '${dir}'/"${'$'}{id##*/}".zip;;`,
+ ' *) echo "gh: unexpected $*" >&2; exit 1;;',
+ 'esac',
+ ].join('\n'), { mode: 0o755 });
+ return dir;
+ }
+ function verifyThrough(dir: string, w: World) {
+ const saved = { PATH: process.env.PATH, GSTACK_STATE_ROOT: process.env.GSTACK_STATE_ROOT };
+ process.env.PATH = `${dir}:${saved.PATH}`;
+ process.env.GSTACK_STATE_ROOT = path.join(dir, 'state');
+ try { return verify(w, { ...deps(w), artifact: ghRunDeps(REPO, dir).artifact }); } finally {
+ process.env.PATH = saved.PATH;
+ if (saved.GSTACK_STATE_ROOT === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = saved.GSTACK_STATE_ROOT;
+ }
+ }
+ const rerunChecks = { 1: [checks1, export1], 2: [export2, checks1], 3: [checks3, export2] };
+
+ // Value: protects=Case B, rerunning the checks job to work around Case A keeps the carry; fails_when=two same-named artifacts refuse as artifact_missing
+ test('two checks-tree artifacts: the one from the checks job\'s newest attempt is the evidence', () => {
+ const v = verifyThrough(stubGh([record(1), record(3, { tests: 7 })]), rerun(3, rerunChecks, record(0)));
+ expect(v.ok ? [v.evidence.run_attempt, v.evidence.tests] : v.detail).toEqual([3, 7]);
+ });
+
+ // Value: protects=no fallback to an older artifact; fails_when=a picked artifact with another tree is passed over for an older matching one
+ test('the newest attempt\'s artifact with a different tree refuses, even beside an older one with the right tree', () => {
+ expect(verifyThrough(stubGh([record(1), record(3, { tree: 'd'.repeat(40) })]), rerun(3, rerunChecks, record(0)))).toMatchObject({ ok: false, reason: 'tree_mismatch' });
+ });
+
+ // Value: protects=fail closed when no artifact belongs to the job's attempt; fails_when=the newest or any artifact is taken regardless
+ test('no artifact from the checks job\'s own attempt refuses as artifact_missing', () => {
+ const v = verifyThrough(stubGh([record(1), record(2)]), rerun(3, rerunChecks, record(0)));
+ expect(v.ok ? 'ok' : [v.reason, v.detail.includes('0 of them record run_attempt 3')]).toEqual(['artifact_missing', true]);
+ });
+ });
+});