From 3a0216ce29e84e9d7e826498b67cf3fd8de56d1f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 8 Aug 2026 04:53:10 +0800 Subject: [PATCH] fix(ci): accept neutral draft gate runs (#120310) * fix(ci): accept neutral draft gate runs * fix(ci): retain decisive gate success * fix(ci): keep pending gate runs authoritative * fix(ci): order hosted gates by creation --- scripts/verify-pr-hosted-gates.mjs | 23 ++-- test/scripts/verify-pr-hosted-gates.test.ts | 112 ++++++++++++++++++-- 2 files changed, 119 insertions(+), 16 deletions(-) diff --git a/scripts/verify-pr-hosted-gates.mjs b/scripts/verify-pr-hosted-gates.mjs index a8a8316f4db3..046f6d6ab45f 100644 --- a/scripts/verify-pr-hosted-gates.mjs +++ b/scripts/verify-pr-hosted-gates.mjs @@ -174,8 +174,10 @@ function matchingAuthoritativeRuns(runs, workflowName, sha, allowManual = true) } function latestRun(runs) { - return runs.toSorted((left, right) => - String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")), + // GitHub run_number is creation order; updated_at moves as jobs finish. + return runs.toSorted( + (left, right) => + Number(right.run_number ?? right.id ?? 0) - Number(left.run_number ?? left.id ?? 0), )[0]; } @@ -370,18 +372,23 @@ function isGateProvenInProgressRun(run, ciGateJobs, nowMs) { function preferredCiRun(runs, nowMs) { const scheduledRuns = runs.filter((run) => run.event === "pull_request"); const latestScheduledRun = latestRun(scheduledRuns); - const latestCompletedScheduledRun = latestRun( - scheduledRuns.filter((run) => run.status === "completed"), + const latestDecision = latestRun( + scheduledRuns.filter( + (run) => run.status === "completed" && !["cancelled", "skipped"].includes(run.conclusion), + ), ); const latestManualRun = latestRun(runs.filter((run) => run.event === "workflow_dispatch")); // Manual proof may replace stale scheduled success or a pending run, // never an unresolved terminal non-success. - if (latestCompletedScheduledRun && latestCompletedScheduledRun.conclusion !== "success") { - return latestCompletedScheduledRun; + if (latestDecision && latestDecision.conclusion !== "success") { + return latestDecision; } - if (latestScheduledRun?.status === "completed" && isRecentRun(latestScheduledRun, nowMs)) { - return latestScheduledRun; + if (latestScheduledRun && latestScheduledRun.status !== "completed") { + return latestRun([latestScheduledRun, latestManualRun].filter(Boolean)); + } + if (latestScheduledRun?.status === "completed" && isSuccessfulRecentRun(latestDecision, nowMs)) { + return latestDecision; } return latestManualRun ?? latestScheduledRun; } diff --git a/test/scripts/verify-pr-hosted-gates.test.ts b/test/scripts/verify-pr-hosted-gates.test.ts index 36e3ac1ab230..dade34fc4166 100644 --- a/test/scripts/verify-pr-hosted-gates.test.ts +++ b/test/scripts/verify-pr-hosted-gates.test.ts @@ -31,6 +31,7 @@ const requiredCliArgs = [ type WorkflowRunFixture = { id: number; + run_number: number; name: string; event: string; status: string; @@ -49,6 +50,7 @@ type WorkflowRunFixture = { function successfulRun(name: string, id: number, updatedAt: string): WorkflowRunFixture { return { id, + run_number: id, name, event: "pull_request", status: "completed", @@ -72,6 +74,10 @@ function releaseGateRun(id: number, updatedAt: string) { }; } +function pendingCiRun(id: number, updatedAt: string, status = "queued") { + return { ...successfulRun("CI", id, updatedAt), status, conclusion: null }; +} + function queuedBuildArtifactFallbackRuns() { return [ releaseGateRun(1, "2026-06-17T10:49:00Z"), @@ -1060,15 +1066,9 @@ describe("verify-pr-hosted-gates", () => { }); it.each([ - [ - "queued", - { - ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), - status: "queued", - conclusion: null, - }, - ], ["stale", successfulRun("CI", 1, "2026-06-16T10:54:59Z")], + ["cancelled", { ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), conclusion: "cancelled" }], + ["skipped", { ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), conclusion: "skipped" }], ])("prefers a fresh exact release gate while scheduled CI is %s", (_state, scheduledRun) => { expect( collectHostedGateEvidence({ @@ -1081,6 +1081,86 @@ describe("verify-pr-hosted-gates", () => { }); }); + it.each(["cancelled", "skipped"])("rejects neutral-only scheduled CI (%s)", (conclusion) => { + expect(() => + collectHostedGateEvidence({ + sha, + workflowRuns: [{ ...successfulRun("CI", 1, "2026-06-17T10:50:00Z"), conclusion }], + }), + ).toThrow("Missing successful recent CI workflow"); + }); + + it.each(["cancelled", "skipped"])( + "retains a recent scheduled success after a newer neutral run (%s)", + (conclusion) => { + const success = successfulRun("CI", 1, "2026-06-17T10:47:00Z"); + const neutral = { ...successfulRun("CI", 2, "2026-06-17T10:48:00Z"), conclusion }; + const collect = () => collectHostedGateEvidence({ sha, workflowRuns: [success, neutral] }); + expect(collect()).toEqual({ + headSha: sha, + workflows: [expect.objectContaining({ name: "CI", id: 1 })], + }); + for (const status of ["queued", "in_progress"]) { + Object.assign(neutral, { status, conclusion: null }); + expect(collect).toThrow("Missing successful recent CI workflow"); + } + }, + ); + + it.each([ + [ + "queued over older manual", + [releaseGateRun(1, "2026-06-17T10:49:00Z"), pendingCiRun(2, "2026-06-17T10:50:00Z")], + null, + ], + [ + "in-progress over older manual", + [ + releaseGateRun(1, "2026-06-17T10:49:00Z"), + pendingCiRun(2, "2026-06-17T10:50:00Z", "in_progress"), + ], + null, + ], + [ + "pending over neutral and manual", + [ + { ...successfulRun("CI", 1, "2026-06-17T10:48:00Z"), conclusion: "skipped" }, + releaseGateRun(2, "2026-06-17T10:49:00Z"), + pendingCiRun(3, "2026-06-17T10:50:00Z"), + ], + null, + ], + [ + "newer manual over older pending", + [ + pendingCiRun(1, "2026-06-17T10:48:00Z", "in_progress"), + releaseGateRun(2, "2026-06-17T10:49:00Z"), + ], + 2, + ], + ])("applies the %s policy", (_case, workflowRuns, expectedId) => { + const collect = () => collectHostedGateEvidence({ sha, workflowRuns }); + if (expectedId === null) { + expect(collect).toThrow("Missing successful recent CI workflow"); + return; + } + expect(collect()).toEqual({ + headSha: sha, + workflows: [expect.objectContaining({ name: `CI release gate ${sha}`, id: expectedId })], + }); + }); + + it("orders runs by creation sequence when completion updates invert", () => { + const olderSuccess = successfulRun("CI", 1, "2026-06-17T10:54:00Z"); + const newerFailure = { + ...successfulRun("CI", 2, "2026-06-17T10:49:00Z"), + conclusion: "failure", + }; + expect(() => + collectHostedGateEvidence({ sha, workflowRuns: [olderSuccess, newerFailure] }), + ).toThrow("Missing successful recent CI workflow"); + }); + it("rejects a completed scheduled CI failure even when a fallback passed", () => { expect(() => collectHostedGateEvidence({ @@ -1100,6 +1180,22 @@ describe("verify-pr-hosted-gates", () => { ).toThrow("Missing successful recent CI workflow"); }); + it.each(["cancelled", "skipped"])( + "keeps an older scheduled failure blocking after a newer neutral run (%s)", + (conclusion) => { + expect(() => + collectHostedGateEvidence({ + sha, + workflowRuns: [ + { ...successfulRun("CI", 1, "2026-06-17T10:47:00Z"), conclusion: "failure" }, + { ...successfulRun("CI", 2, "2026-06-17T10:48:00Z"), conclusion }, + releaseGateRun(3, "2026-06-17T10:49:00Z"), + ], + }), + ).toThrow("Missing successful recent CI workflow"); + }, + ); + it("does not mask a failed CI run with a queued rerun and release-gate fallback", () => { expect(() => collectHostedGateEvidence({