diff --git a/test/pr-e2e-gate-dispatch-recovery.test.ts b/test/pr-e2e-gate-dispatch-recovery.test.ts index f7f20f163d..665eac9256 100644 --- a/test/pr-e2e-gate-dispatch-recovery.test.ts +++ b/test/pr-e2e-gate-dispatch-recovery.test.ts @@ -625,7 +625,18 @@ describe("PR E2E dispatch-not-observed recovery", () => { ]); }); - it("accepts an exact child authorization after the PATCH response is lost", async () => { + it.each([ + { + label: "exact child URL", + publishedDetailsUrl: "https://github.com/NVIDIA/NemoClaw/actions/runs/23", + }, + { + label: "canonical check URL", + publishedDetailsUrl: "https://github.com/NVIDIA/NemoClaw/runs/17", + }, + ])("accepts an exact child authorization with $label after the PATCH response is lost", async ({ + publishedDetailsUrl, + }) => { const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pr-e2e-auth-response-")); const outputPath = path.join(workDir, "github-output"); fs.writeFileSync(outputPath, "", { mode: 0o600 }); @@ -633,7 +644,7 @@ describe("PR E2E dispatch-not-observed recovery", () => { vi.stubEnv("GITHUB_REPOSITORY", "NVIDIA/NemoClaw"); vi.stubEnv("GITHUB_OUTPUT", outputPath); const requests: RecordedGitHubRequest[] = []; - let check = reservedCheck(); + let check: Record = reservedCheck(); vi.spyOn(globalThis, "fetch").mockImplementation( createGitHubFetchRouter( [ @@ -665,9 +676,15 @@ describe("PR E2E dispatch-not-observed recovery", () => { githubFetchRoute( ({ url, method }) => url.endsWith("/check-runs/17") && method === "PATCH", (request) => { - check = { ...check, ...((request.body ?? {}) as Record) }; const title = (request.body as { output?: { title?: string } } | undefined)?.output ?.title; + check = { + ...check, + ...((request.body ?? {}) as Record), + ...(title?.startsWith("Running ") + ? { details_url: publishedDetailsUrl } + : undefined), + }; return title?.startsWith("Running ") ? new Response("{", { status: 200 }) : githubResponse(check); @@ -697,7 +714,7 @@ describe("PR E2E dispatch-not-observed recovery", () => { expect(check).toMatchObject({ status: "in_progress", conclusion: null, - details_url: "https://github.com/NVIDIA/NemoClaw/actions/runs/23", + details_url: publishedDetailsUrl, output: { title: expect.stringMatching(/^Running /u) }, }); expect(requests.filter((request) => request.url.endsWith("/dispatches"))).toHaveLength(1); diff --git a/test/pr-e2e-gate-lifecycle.test.ts b/test/pr-e2e-gate-lifecycle.test.ts index b0e9765d02..e62ce5139e 100644 --- a/test/pr-e2e-gate-lifecycle.test.ts +++ b/test/pr-e2e-gate-lifecycle.test.ts @@ -388,7 +388,7 @@ describe("PR E2E controller lifecycle", () => { } }); - it("cancels the child and closes the check when startup fails after dispatch", async () => { + it("revokes and cancels when a lost PATCH publishes a foreign check URL", async () => { const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pr-e2e-gate-start-")); const outputPath = path.join(workDir, "github-output"); fs.writeFileSync(outputPath, "", { mode: 0o600 }); @@ -397,6 +397,7 @@ describe("PR E2E controller lifecycle", () => { vi.stubEnv("GITHUB_OUTPUT", outputPath); const requests: RecordedGitHubRequest[] = []; let checkPatches = 0; + let failedRunningUpdate: Record | undefined; vi.spyOn(globalThis, "fetch").mockImplementation( createGitHubFetchRouter( [ @@ -439,11 +440,16 @@ describe("PR E2E controller lifecycle", () => { () => githubResponse( exactPrGateCheck({ - output: { - title: "Evaluating PR commit", - summary: - "Validating the PR SHA and selecting deterministic E2E jobs and typed targets.", - }, + ...(failedRunningUpdate ?? { + output: { + title: "Evaluating PR commit", + summary: + "Validating the PR SHA and selecting deterministic E2E jobs and typed targets.", + }, + }), + ...(failedRunningUpdate + ? { details_url: "https://github.com/NVIDIA/NemoClaw/runs/999" } + : undefined), }), ), ), @@ -455,6 +461,10 @@ describe("PR E2E controller lifecycle", () => { ({ url, method }) => url.endsWith("/check-runs/17") && method === "PATCH", (request) => { checkPatches += 1; + failedRunningUpdate = + checkPatches === 2 + ? ((request.body ?? {}) as Record) + : failedRunningUpdate; return checkPatches === 2 ? githubResponse({ message: "simulated update failure" }, 500) : prGateMutationResponse(request); diff --git a/tools/e2e/pr-e2e-gate.mts b/tools/e2e/pr-e2e-gate.mts index 68acd2f168..c478ed51b0 100755 --- a/tools/e2e/pr-e2e-gate.mts +++ b/tools/e2e/pr-e2e-gate.mts @@ -1380,6 +1380,7 @@ async function updateRunningCheck( }, ): Promise { const childRunUrl = `https://github.com/${context.repository}/actions/runs/${options.childRunId}`; + const canonicalCheckUrl = `https://github.com/${context.repository}/runs/${context.checkRunId}`; const selectionCount = options.jobs.length + options.targets.length; const title = `Running ${selectionCount} E2E ${selectionCount === 1 ? "check" : "checks"}`; const summary = `Risk plan ${options.planHash} selected jobs: ${options.jobs.join(", ") || "none"}; targets: ${options.targets.join(", ") || "none"}. Child run: ${childRunUrl}.`; @@ -1394,7 +1395,7 @@ async function updateRunningCheck( title, summary, }); - if (check.details_url !== childRunUrl) { + if (check.details_url !== childRunUrl && check.details_url !== canonicalCheckUrl) { throw new Error("GitHub did not bind the controller check to the selected child run"); } };