diff --git a/scripts/verify-pr-hosted-gates.mjs b/scripts/verify-pr-hosted-gates.mjs index 22d857b870f..35fad2404d8 100644 --- a/scripts/verify-pr-hosted-gates.mjs +++ b/scripts/verify-pr-hosted-gates.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node +import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; -import { execGhApiRead } from "./lib/plain-gh.mjs"; +import { execGhApiRead, plainGhEnv } from "./lib/plain-gh.mjs"; export const SCHEDULED_HOSTED_WORKFLOWS = [ "Blacksmith Testbox", @@ -26,6 +27,9 @@ const COMPARE_COMMITS_PAGE_SIZE = 100; export const HOSTED_GATE_MAX_AGE_HOURS = 24; const HOSTED_GATE_MAX_AGE_MS = HOSTED_GATE_MAX_AGE_HOURS * 60 * 60 * 1_000; const HOSTED_GATE_CLOCK_SKEW_MS = 5 * 60 * 1_000; +const MAX_CI_REUSE_CANDIDATES = 5; +const CI_REUSE_RUN_LIST_LIMIT = 50; +const GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; function readOptionValue(argv, index, optionName) { const value = argv[index + 1]; @@ -151,6 +155,128 @@ function isSuccessfulRecentRun(run, nowMs) { return run?.status === "completed" && run.conclusion === "success" && isRecentRun(run, nowMs); } +function runGit(args, { input } = {}) { + const result = spawnSync("git", args, { + encoding: "utf8", + input, + maxBuffer: GIT_MAX_BUFFER_BYTES, + stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`git ${args[0]} failed with exit code ${result.status ?? "unknown"}`); + } + return result.stdout; +} + +function parseSingleObjectId(raw, label) { + const values = String(raw).trim().split(/\s+/u).filter(Boolean); + if (values.length !== 1 || !/^[0-9a-f]{40,64}$/u.test(values[0])) { + throw new Error(`Expected one ${label} object id.`); + } + return values[0]; +} + +function computePatchId(sha, mainRef, execGit) { + const mergeBase = parseSingleObjectId( + execGit(["merge-base", mainRef, sha]), + `merge base for ${sha}`, + ); + const diff = execGit(["diff", mergeBase, sha]); + const patchIdLines = String(execGit(["patch-id", "--stable"], { input: diff })) + .trim() + .split(/\r?\n/u) + .filter(Boolean); + if (patchIdLines.length !== 1) { + throw new Error(`Expected one patch id for ${sha}.`); + } + const [patchId, commitId, ...rest] = patchIdLines[0].trim().split(/\s+/u); + if ( + rest.length > 0 || + !/^[0-9a-f]{40,64}$/u.test(patchId ?? "") || + !/^[0-9a-f]{40,64}$/u.test(commitId ?? "") + ) { + throw new Error(`Invalid patch-id output for ${sha}.`); + } + return patchId; +} + +function ensureCommitAvailable(sha, execGit) { + try { + execGit(["cat-file", "-e", `${sha}^{commit}`]); + return true; + } catch { + try { + execGit(["fetch", "origin", sha]); + execGit(["cat-file", "-e", `${sha}^{commit}`]); + return true; + } catch { + return false; + } + } +} + +function isQualifyingCiReuseRun(run) { + if (run?.event === "pull_request") { + return run?.name === "CI"; + } + return isReleaseGateCiRun(run, run?.head_sha); +} + +function findPatchIdenticalCiReuse({ + sha, + candidateRuns, + nowMs, + mainRef = "origin/main", + execGit = runGit, +}) { + if (!Array.isArray(candidateRuns)) { + return undefined; + } + const candidates = candidateRuns + .filter( + (run) => + run?.head_sha !== sha && + /^[0-9a-f]{40,64}$/u.test(String(run?.head_sha ?? "")) && + isQualifyingCiReuseRun(run) && + isSuccessfulRecentRun(run, nowMs), + ) + .toSorted((left, right) => + String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")), + ) + .slice(0, MAX_CI_REUSE_CANDIDATES); + if (candidates.length === 0) { + return undefined; + } + + let currentPatchId; + try { + currentPatchId = computePatchId(sha, mainRef, execGit); + } catch { + return undefined; + } + for (const run of candidates) { + if (!ensureCommitAvailable(run.head_sha, execGit)) { + continue; + } + try { + if (computePatchId(run.head_sha, mainRef, execGit) === currentPatchId) { + return { + run, + reusedFromSha: run.head_sha, + reusedRunId: run.id, + patchIdMatched: true, + }; + } + } catch { + // A candidate is reusable only when every local git proof step succeeds. + } + } + return undefined; +} + const CI_GATE_CHECK_NAME = "openclaw/ci-gate"; /** @@ -349,6 +475,8 @@ export function collectHostedGateEvidence({ pullRequestHeadRepository = "", workflowRuns, ciGateJobs = [], + loadCiReuseCandidates = () => [], + execGit = runGit, changelogOnly = false, nowMs = Date.now(), }) { @@ -357,17 +485,21 @@ export function collectHostedGateEvidence({ } const pullRequestCommitShaSet = new Set(pullRequestCommitShas); - const collectForSha = (evidenceSha, { allowManual, requiredScheduledWorkflows = new Set() }) => { + const collectForSha = ( + evidenceSha, + { allowManual, requiredScheduledWorkflows = new Set(), ciRun }, + ) => { const workflows = []; const fallbackCoveredWorkflows = []; if (!changelogOnly) { workflows.push( - successfulRunOrThrow(workflowRuns, "CI", evidenceSha, { - allowManual, - nowMs, - // Gate proof only vouches for the exact head under verification. - ciGateJobs: evidenceSha === sha ? ciGateJobs : [], - }), + ciRun ?? + successfulRunOrThrow(workflowRuns, "CI", evidenceSha, { + allowManual, + nowMs, + // Gate proof only vouches for the exact head under verification. + ciGateJobs: evidenceSha === sha ? ciGateJobs : [], + }), ); } for (const workflowName of SCHEDULED_HOSTED_WORKFLOWS) { @@ -402,14 +534,43 @@ export function collectHostedGateEvidence({ return { workflows, fallbackCoveredWorkflows }; }; + let ciRun; + let ciReuse; + if (!changelogOnly) { + try { + ciRun = successfulRunOrThrow(workflowRuns, "CI", sha, { + allowManual: true, + nowMs, + ciGateJobs, + }); + } catch (exactCiError) { + let candidateRuns; + try { + candidateRuns = loadCiReuseCandidates(); + } catch { + candidateRuns = []; + } + ciReuse = findPatchIdenticalCiReuse({ + sha, + candidateRuns, + nowMs, + execGit, + }); + if (!ciReuse) { + throw exactCiError; + } + ciRun = ciReuse.run; + } + } + let evidenceSha = sha; let selected; try { - selected = collectForSha(sha, { allowManual: true }); + selected = collectForSha(sha, { allowManual: true, ciRun }); } catch (exactError) { - // Hosted CI proves the PR cohort, not ancestry freshness. A newer head's - // failure must not discard a complete same-PR green cohort from the last - // 24 hours; review and focused gates own the newer delta. + // Scheduled hosted workflows retain their existing recent-cohort fallback. + // CI itself is either exact-head proof or the patch-identical run selected + // above; never silently replace it with an unverified prior-head run. const targetScheduledWorkflows = new Set( SCHEDULED_HOSTED_WORKFLOWS.filter( (workflowName) => @@ -417,6 +578,7 @@ export function collectHostedGateEvidence({ ), ); const fallbackShas = [ + ciReuse?.reusedFromSha, recentSha, ...workflowRuns .filter( @@ -443,6 +605,7 @@ export function collectHostedGateEvidence({ selected = collectForSha(fallbackSha, { allowManual: false, requiredScheduledWorkflows: targetScheduledWorkflows, + ciRun, }); evidenceSha = fallbackSha; break; @@ -473,6 +636,11 @@ export function collectHostedGateEvidence({ if (evidenceSha !== sha) { evidence.evidenceHeadSha = evidenceSha; } + if (ciReuse) { + evidence.reusedFromSha = ciReuse.reusedFromSha; + evidence.reusedRunId = ciReuse.reusedRunId; + evidence.patchIdMatched = true; + } if (selected.fallbackCoveredWorkflows.length > 0) { evidence.fallbackCoveredWorkflows = selected.fallbackCoveredWorkflows; } @@ -520,6 +688,51 @@ function loadWorkflowRuns(repo, sha, recentSha, headBranch) { return [...new Map(workflowRuns.map((run) => [run.id, run])).values()]; } +function loadCiReuseCandidateRuns(repo, headBranch) { + const raw = execFileSync( + "gh", + [ + "run", + "list", + "--repo", + repo, + "--workflow", + "ci.yml", + "--branch", + headBranch, + "--limit", + String(CI_REUSE_RUN_LIST_LIMIT), + "--json", + "databaseId,workflowName,headSha,headBranch,event,status,conclusion,createdAt,updatedAt,url,displayTitle", + ], + { + encoding: "utf8", + env: plainGhEnv(), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const runs = JSON.parse(stripAnsi(raw)); + if (!Array.isArray(runs)) { + throw new Error("Expected gh run list to return an array."); + } + // The workflow selector above supplies the path identity that the REST + // release-gate matcher normally reads from each full workflow-run object. + return runs.map((run) => ({ + id: run.databaseId, + name: run.workflowName, + event: run.event, + status: run.status, + conclusion: run.conclusion, + head_sha: run.headSha, + head_branch: run.headBranch, + path: CI_WORKFLOW_PATH, + created_at: run.createdAt, + updated_at: run.updatedAt, + html_url: run.url, + display_title: run.displayTitle, + })); +} + export function compareCommitPageCount(totalCommits) { if (!Number.isSafeInteger(totalCommits) || totalCommits < 0) { throw new Error("Expected comparison total_commits to be a non-negative integer."); @@ -643,6 +856,7 @@ function main(argv = process.argv.slice(2)) { pullRequestHeadRepository: headRepository, workflowRuns, ciGateJobs: loadCiGateJobs(args.repo, workflowRuns, args.sha), + loadCiReuseCandidates: () => loadCiReuseCandidateRuns(args.repo, headBranch), changelogOnly: args.changelogOnly, }); const evidenceHeadSha = evidence.evidenceHeadSha ?? args.sha; @@ -652,13 +866,28 @@ function main(argv = process.argv.slice(2)) { repo: args.repo, pullRequestNumber: args.pr, selection: { - mode: evidenceHeadSha === args.sha ? "exact-head" : "recent-pr-head", + mode: evidence.patchIdMatched + ? "patch-identical-pre-rebase" + : evidenceHeadSha === args.sha + ? "exact-head" + : "recent-pr-head", maxAgeHours: HOSTED_GATE_MAX_AGE_HOURS, }, ...evidence, }; mkdirSync(path.dirname(args.output), { recursive: true }); writeFileSync(args.output, `${JSON.stringify(manifest, null, 2)}\n`); + if (evidence.patchIdMatched) { + const reusedRun = manifest.workflows.find((workflow) => workflow.id === evidence.reusedRunId); + const updatedAtMs = runUpdatedAtMs({ updated_at: reusedRun?.updatedAt }); + const ageHours = + updatedAtMs === null + ? "unknown" + : `${(Math.max(0, Date.now() - updatedAtMs) / (60 * 60 * 1_000)).toFixed(1)}h`; + console.log( + `hosted CI reused from patch-identical pre-rebase head ${evidence.reusedFromSha} (run ${evidence.reusedRunId}, age ${ageHours})`, + ); + } console.log( `Hosted gates passed for PR #${args.pr} at ${args.sha} using ${evidenceHeadSha}: ${manifest.workflows .map((workflow) => `${workflow.name}#${workflow.id}`) diff --git a/test/scripts/verify-pr-hosted-gates.test.ts b/test/scripts/verify-pr-hosted-gates.test.ts index 69762eaf25a..343f218b1c5 100644 --- a/test/scripts/verify-pr-hosted-gates.test.ts +++ b/test/scripts/verify-pr-hosted-gates.test.ts @@ -13,6 +13,7 @@ import { const sha = "773ffd87a1e1e34451ad6e38fda37380c2569a50"; const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119"; +const scheduledFallbackSha = "ad620a11e5d9ed3888b6afb3c35c4c30e8054f4e"; const pr = 100606; const nowMs = Date.parse("2026-06-17T10:55:00Z"); const BUILD_ARTIFACTS_WORKFLOW = "Blacksmith Build Artifacts Testbox"; @@ -27,7 +28,24 @@ const requiredCliArgs = [ ".local/gates-hosted-checks.json", ]; -function successfulRun(name: string, id: number, updatedAt: string) { +type WorkflowRunFixture = { + id: number; + name: string; + event: string; + status: string; + conclusion: string | null; + head_sha: string; + head_branch: string; + head_repository: { full_name: string }; + pull_requests: Array<{ number: number }>; + path: string; + created_at: string; + updated_at: string; + html_url: string; + display_title?: string; +}; + +function successfulRun(name: string, id: number, updatedAt: string): WorkflowRunFixture { return { id, name, @@ -68,13 +86,234 @@ function queuedBuildArtifactFallbackRuns() { ]; } -function collectHostedGateEvidence( - options: Omit[0], "nowMs" | "pr">, +function collectHostedGateEvidence(options: Omit) { + return collectHostedGateEvidenceWithReuse({ nowMs, pr, ...options }); +} + +type GitExec = (args: string[], options?: { input?: string }) => string; +type CollectHostedGateOptions = Parameters[0] & { + loadCiReuseCandidates?: () => Array>; + execGit?: GitExec; +}; +type HostedGateEvidence = ReturnType & { + reusedFromSha?: string; + reusedRunId?: unknown; + patchIdMatched?: boolean; +}; +const collectHostedGateEvidenceWithReuse = collectHostedGateEvidenceRaw as unknown as ( + options: CollectHostedGateOptions, +) => HostedGateEvidence; + +function priorSuccessfulCiRun(overrides: Partial = {}): WorkflowRunFixture { + return { + ...successfulRun("CI", 101, "2026-06-17T09:55:00Z"), + head_sha: previousSha, + ...overrides, + }; +} + +type PatchIdExecOptions = { + currentPatchId?: string; + priorPatchId?: string; + unfetchableShas?: Set; + failCommand?: string; +}; + +function createPatchIdExec({ + currentPatchId: suppliedCurrentPatchId = "a".repeat(40), + priorPatchId, + unfetchableShas = new Set(), + failCommand = "", +}: PatchIdExecOptions = {}) { + const currentPatchId: string = suppliedCurrentPatchId; + const resolvedPriorPatchId = priorPatchId ?? currentPatchId; + const calls: string[] = []; + const execGit: GitExec = (args, options = {}) => { + const command = args.join(" "); + calls.push(command); + if (command === failCommand) { + throw new Error(`mock failure: ${command}`); + } + switch (args[0]) { + case "cat-file": { + const candidateSha = args[2]?.replace(/\^\{commit\}$/u, "") ?? ""; + if (unfetchableShas.has(candidateSha)) { + throw new Error("missing object"); + } + return ""; + } + case "fetch": + if (unfetchableShas.has(args[2] ?? "")) { + throw new Error("unfetchable object"); + } + return ""; + case "merge-base": + return `${(args[2] === sha ? "b" : "c").repeat(40)}\n`; + case "diff": + return `diff:${args[2]}`; + case "patch-id": { + const patchId = options.input === `diff:${sha}` ? currentPatchId : resolvedPriorPatchId; + return `${patchId} ${"0".repeat(40)}\n`; + } + default: + throw new Error(`unexpected git command: ${command}`); + } + }; + return { calls, execGit }; +} + +function patchReuseOptions( + candidate: WorkflowRunFixture = priorSuccessfulCiRun(), + execGit = createPatchIdExec().execGit, ) { - return collectHostedGateEvidenceRaw({ nowMs, pr, ...options }); + return { + loadCiReuseCandidates: () => [candidate], + execGit, + }; } describe("verify-pr-hosted-gates", () => { + it("reuses successful recent CI from a patch-identical pre-rebase head", () => { + const candidate = priorSuccessfulCiRun(); + const evidence = collectHostedGateEvidence({ + sha, + workflowRuns: [], + ...patchReuseOptions(candidate), + }); + + expect(evidence).toEqual({ + headSha: sha, + workflows: [expect.objectContaining({ id: 101, name: "CI", headSha: previousSha })], + reusedFromSha: previousSha, + reusedRunId: 101, + patchIdMatched: true, + }); + }); + + it("rejects a successful prior-head CI run whose patch differs", () => { + const { execGit } = createPatchIdExec({ priorPatchId: "d".repeat(40) }); + expect(() => + collectHostedGateEvidence({ + sha, + workflowRuns: [], + ...patchReuseOptions(priorSuccessfulCiRun(), execGit), + }), + ).toThrow(`Missing successful recent CI workflow for ${sha}`); + }); + + it("rejects patch-identical CI reuse after the 24-hour window", () => { + let gitCalled = false; + expect(() => + collectHostedGateEvidence({ + sha, + workflowRuns: [], + loadCiReuseCandidates: () => [priorSuccessfulCiRun({ updated_at: "2026-06-16T10:54:59Z" })], + execGit: () => { + gitCalled = true; + throw new Error("stale candidates must be filtered before git"); + }, + }), + ).toThrow(`Missing successful recent CI workflow for ${sha}`); + expect(gitCalled).toBe(false); + }); + + it("skips an unfetchable prior head", () => { + const { calls, execGit } = createPatchIdExec({ + unfetchableShas: new Set([previousSha]), + }); + expect(() => + collectHostedGateEvidence({ + sha, + workflowRuns: [], + ...patchReuseOptions(priorSuccessfulCiRun(), execGit), + }), + ).toThrow(`Missing successful recent CI workflow for ${sha}`); + expect(calls).toContain(`fetch origin ${previousSha}`); + }); + + it("fails closed when patch-id computation errors", () => { + const { execGit } = createPatchIdExec({ + failCommand: `merge-base origin/main ${previousSha}`, + }); + expect(() => + collectHostedGateEvidence({ + sha, + workflowRuns: [], + ...patchReuseOptions(priorSuccessfulCiRun(), execGit), + }), + ).toThrow(`Missing successful recent CI workflow for ${sha}`); + }); + + it("filters prior runs by successful qualifying CI shape", () => { + const invalidCandidates = [ + priorSuccessfulCiRun({ id: 1, conclusion: "failure" }), + priorSuccessfulCiRun({ id: 2, name: "Docs" }), + priorSuccessfulCiRun({ + id: 3, + event: "workflow_dispatch", + display_title: `CI release gate ${previousSha}`, + path: ".github/workflows/not-ci.yml", + }), + ]; + let gitCalled = false; + expect(() => + collectHostedGateEvidence({ + sha, + workflowRuns: [], + loadCiReuseCandidates: () => invalidCandidates, + execGit: () => { + gitCalled = true; + throw new Error("invalid candidates must be filtered before git"); + }, + }), + ).toThrow(`Missing successful recent CI workflow for ${sha}`); + expect(gitCalled).toBe(false); + }); + + it("accepts a patch-identical prior release-gate run with the exact dispatch title", () => { + const candidate = priorSuccessfulCiRun({ + id: 102, + event: "workflow_dispatch", + path: ".github/workflows/ci.yml", + display_title: `CI release gate ${previousSha}`, + }); + expect( + collectHostedGateEvidence({ + sha, + workflowRuns: [], + ...patchReuseOptions(candidate), + }), + ).toEqual({ + headSha: sha, + workflows: [expect.objectContaining({ id: 102, event: "workflow_dispatch" })], + reusedFromSha: previousSha, + reusedRunId: 102, + patchIdMatched: true, + }); + }); + + it("short-circuits reuse discovery when exact-head CI already succeeds", () => { + let reuseCalled = false; + const evidence = collectHostedGateEvidence({ + sha, + workflowRuns: [successfulRun("CI", 1, "2026-06-17T10:47:00Z")], + loadCiReuseCandidates: () => { + reuseCalled = true; + throw new Error("exact-head success must not inspect reuse candidates"); + }, + execGit: () => { + reuseCalled = true; + throw new Error("exact-head success must not execute git reuse proof"); + }, + }); + + expect(evidence).toEqual({ + headSha: sha, + workflows: [expect.objectContaining({ id: 1, headSha: sha })], + }); + expect(reuseCalled).toBe(false); + }); + it("accepts an in-progress CI run whose own attempt's ci-gate job succeeded", () => { const inProgressRun = { ...successfulRun("CI", 42, "2026-06-17T10:52:00Z"), @@ -235,68 +474,77 @@ describe("verify-pr-hosted-gates", () => { }); it("accepts 13-hour green evidence from the recorded pre-rebase head", () => { + const priorRun = { + ...successfulRun("CI", 1, "2026-06-16T21:55:00Z"), + head_sha: previousSha, + }; const evidence = collectHostedGateEvidence({ sha, recentSha: previousSha, workflowRuns: [ - { - ...successfulRun("CI", 1, "2026-06-16T21:55:00Z"), - head_sha: previousSha, - }, + priorRun, { ...successfulRun("CI", 2, "2026-06-17T10:54:00Z"), status: "in_progress", conclusion: null, }, ], + ...patchReuseOptions(priorRun), }); expect(evidence).toEqual({ headSha: sha, - evidenceHeadSha: previousSha, workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })], + reusedFromSha: previousSha, + reusedRunId: 1, + patchIdMatched: true, }); }); it("accepts recent green evidence from an earlier head of the same PR", () => { + const priorRun = { + ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), + head_sha: previousSha, + }; const evidence = collectHostedGateEvidence({ sha, workflowRuns: [ - { - ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), - head_sha: previousSha, - }, + priorRun, { ...successfulRun("CI", 2, "2026-06-17T10:54:00Z"), status: "in_progress", conclusion: null, }, ], + ...patchReuseOptions(priorRun), }); expect(evidence).toEqual({ headSha: sha, - evidenceHeadSha: previousSha, workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })], + reusedFromSha: previousSha, + reusedRunId: 1, + patchIdMatched: true, }); }); it("accepts a recent green fork head when GitHub omits pull request links", () => { const headBranch = "fix/token-listener"; const headRepository = "contributor/openclaw"; + const priorRun = { + ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), + head_sha: previousSha, + head_branch: headBranch, + head_repository: { full_name: headRepository }, + pull_requests: [], + }; const evidence = collectHostedGateEvidence({ sha, pullRequestCommitShas: [previousSha, sha], pullRequestHeadBranch: headBranch, pullRequestHeadRepository: headRepository, workflowRuns: [ - { - ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), - head_sha: previousSha, - head_branch: headBranch, - head_repository: { full_name: headRepository }, - pull_requests: [], - }, + priorRun, { ...successfulRun("CI", 2, "2026-06-17T10:54:00Z"), head_branch: headBranch, @@ -305,12 +553,15 @@ describe("verify-pr-hosted-gates", () => { conclusion: "failure", }, ], + ...patchReuseOptions(priorRun), }); expect(evidence).toEqual({ headSha: sha, - evidenceHeadSha: previousSha, workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })], + reusedFromSha: previousSha, + reusedRunId: 1, + patchIdMatched: true, }); }); @@ -386,9 +637,14 @@ describe("verify-pr-hosted-gates", () => { targetArmRun, ]; - expect(() => collectHostedGateEvidence({ sha, recentSha: previousSha, workflowRuns })).toThrow( - `Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`, - ); + expect(() => + collectHostedGateEvidence({ + sha, + recentSha: previousSha, + workflowRuns, + ...patchReuseOptions(workflowRuns[0]), + }), + ).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`); const evidence = collectHostedGateEvidence({ sha, @@ -400,35 +656,85 @@ describe("verify-pr-hosted-gates", () => { head_sha: previousSha, }, ], + ...patchReuseOptions(workflowRuns[0]), }); expect(evidence.workflows).toEqual([ expect.objectContaining({ name: "CI", headSha: previousSha }), expect.objectContaining({ name: "Blacksmith ARM Testbox", headSha: previousSha }), ]); + expect(evidence).toMatchObject({ + reusedFromSha: previousSha, + reusedRunId: 1, + patchIdMatched: true, + }); + }); + + it("keeps the existing scheduled-workflow fallback after CI reuses another head", () => { + const priorCiRun = priorSuccessfulCiRun({ id: 1 }); + const evidence = collectHostedGateEvidence({ + sha, + workflowRuns: [ + priorCiRun, + { + ...successfulRun("CI", 2, "2026-06-17T10:54:00Z"), + status: "in_progress", + conclusion: null, + }, + { + ...successfulRun("Blacksmith ARM Testbox", 3, "2026-06-17T10:54:00Z"), + status: "queued", + conclusion: null, + }, + { + ...successfulRun("Blacksmith ARM Testbox", 4, "2026-06-17T10:53:00Z"), + head_sha: scheduledFallbackSha, + }, + ], + ...patchReuseOptions(priorCiRun), + }); + + expect(evidence).toMatchObject({ + headSha: sha, + evidenceHeadSha: scheduledFallbackSha, + reusedFromSha: previousSha, + reusedRunId: 1, + patchIdMatched: true, + workflows: [ + expect.objectContaining({ name: "CI", headSha: previousSha }), + expect.objectContaining({ + name: "Blacksmith ARM Testbox", + headSha: scheduledFallbackSha, + }), + ], + }); }); it.each(["failure", "cancelled", "skipped"])( "reuses recent same-PR green evidence after a current-head %s run", (conclusion) => { + const priorRun = { + ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), + head_sha: previousSha, + }; const evidence = collectHostedGateEvidence({ sha, recentSha: previousSha, workflowRuns: [ - { - ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), - head_sha: previousSha, - }, + priorRun, { ...successfulRun("CI", 2, "2026-06-17T10:54:00Z"), conclusion, }, ], + ...patchReuseOptions(priorRun), }); expect(evidence).toEqual({ headSha: sha, - evidenceHeadSha: previousSha, workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })], + reusedFromSha: previousSha, + reusedRunId: 1, + patchIdMatched: true, }); }, ); @@ -473,7 +779,16 @@ describe("verify-pr-hosted-gates", () => { status: "in_progress", conclusion: null, }, + { + ...successfulRun("Blacksmith ARM Testbox", 4, "2026-06-17T10:54:00Z"), + status: "queued", + conclusion: null, + }, ], + ...patchReuseOptions({ + ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), + head_sha: previousSha, + }), }), ).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`); }); @@ -497,20 +812,30 @@ describe("verify-pr-hosted-gates", () => { status: "in_progress", conclusion: null, }, + { + ...successfulRun("Blacksmith ARM Testbox", 4, "2026-06-17T10:54:00Z"), + status: "queued", + conclusion: null, + }, ], + ...patchReuseOptions({ + ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), + head_sha: previousSha, + }), }), ).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`); }); it("reuses pre-rebase green evidence after a failed current-head manual gate", () => { + const priorRun = { + ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), + head_sha: previousSha, + }; const evidence = collectHostedGateEvidence({ sha, recentSha: previousSha, workflowRuns: [ - { - ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), - head_sha: previousSha, - }, + priorRun, { ...successfulRun("CI", 2, "2026-06-17T10:53:00Z"), status: "in_progress", @@ -523,12 +848,15 @@ describe("verify-pr-hosted-gates", () => { conclusion: "failure", }, ], + ...patchReuseOptions(priorRun), }); expect(evidence).toEqual({ headSha: sha, - evidenceHeadSha: previousSha, workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })], + reusedFromSha: previousSha, + reusedRunId: 1, + patchIdMatched: true, }); }); @@ -549,7 +877,7 @@ describe("verify-pr-hosted-gates", () => { recentSha: previousSha, workflowRuns: [staleRun, currentPending], }), - ).toThrow(`Missing successful recent CI workflow for ${previousSha}`); + ).toThrow(`Missing successful recent CI workflow for ${sha}`); const recentUnrelatedRun = { ...successfulRun("CI", 4, "2026-06-17T10:50:00Z"), @@ -562,7 +890,7 @@ describe("verify-pr-hosted-gates", () => { recentSha: previousSha, workflowRuns: [recentUnrelatedRun, currentPending], }), - ).toThrow(`Missing successful recent CI workflow for ${previousSha}`); + ).toThrow(`Missing successful recent CI workflow for ${sha}`); expect(() => collectHostedGateEvidence({ sha,