From 066f785aaad8534acf95fcf96a2258795aa0b414 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:40:09 -0400 Subject: [PATCH] fix(ci): release gate no longer fails when a merge cancels main's CI run Dispatching a release while merges are landing failed at "Verify required CI checks passed". CI cancels the in-progress main run whenever the next merge lands, and the gate treated that cancelled run as the verdict for the release commit, even though the merge queue had already run the same checks on that exact commit and passed. The gate now ignores a cancelled run when the same check has a newer or older run with a real verdict on the commit. In-progress runs are still waited on, and a check whose only run was cancelled still fails. --- scripts/verify-release-ci.test.ts | 74 +++++++++++++++++++++++++++++++ scripts/verify-release-ci.ts | 27 ++++++++--- 2 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 scripts/verify-release-ci.test.ts diff --git a/scripts/verify-release-ci.test.ts b/scripts/verify-release-ci.test.ts new file mode 100644 index 00000000..a72d8dcd --- /dev/null +++ b/scripts/verify-release-ci.test.ts @@ -0,0 +1,74 @@ +import { assert, it } from "@effect/vitest"; + +import { evaluateRequiredChecks } from "./verify-release-ci.ts"; + +const CHECK = "Format, Lint, Typecheck, Test, Build"; + +// A merge landing on main cancels the previous main CI run. The merge queue +// already passed the same checks on that exact commit, so the release gate +// must fall through to that verdict instead of failing on the cancellation. +it("a cancelled newer run yields to an older passing run of the same check", () => { + const evaluation = evaluateRequiredChecks( + [CHECK], + [ + { + name: CHECK, + status: "completed", + conclusion: "success", + completed_at: "2026-09-06T09:11:40Z", + html_url: "https://example.test/merge-queue", + }, + { + name: CHECK, + status: "completed", + conclusion: "cancelled", + completed_at: "2026-09-06T09:14:50Z", + html_url: "https://example.test/main-push", + }, + ], + ); + + assert.deepEqual(evaluation.failures, []); + assert.deepEqual(evaluation.pending, []); + assert.equal(evaluation.checksByName.get(CHECK)?.html_url, "https://example.test/merge-queue"); +}); + +it("a newer run still in progress is waited on even when an older run passed", () => { + const evaluation = evaluateRequiredChecks( + [CHECK], + [ + { + name: CHECK, + status: "completed", + conclusion: "success", + completed_at: "2026-09-06T09:11:40Z", + }, + { + name: CHECK, + status: "in_progress", + conclusion: null, + started_at: "2026-09-06T09:12:00Z", + }, + ], + ); + + assert.deepEqual(evaluation.failures, []); + assert.equal(evaluation.pending.length, 1); +}); + +it("a check whose only run was cancelled still fails the gate", () => { + const evaluation = evaluateRequiredChecks( + [CHECK], + [ + { + name: CHECK, + status: "completed", + conclusion: "cancelled", + completed_at: "2026-09-06T09:14:50Z", + }, + ], + ); + + assert.equal(evaluation.failures.length, 1); + assert.deepEqual(evaluation.pending, []); +}); diff --git a/scripts/verify-release-ci.ts b/scripts/verify-release-ci.ts index 75a905f9..1548bc9b 100644 --- a/scripts/verify-release-ci.ts +++ b/scripts/verify-release-ci.ts @@ -154,13 +154,27 @@ function formatCheckRunStatus(checkName: string, checkRun: CheckRun | undefined) return `${checkName}: status=${checkRun.status ?? "unknown"}, conclusion=${checkRun.conclusion ?? "unknown"} (${checkRun.html_url ?? checkRun.details_url ?? "no URL"})`; } -function evaluateRequiredChecks( +function isCancelled(checkRun: CheckRun): boolean { + return checkRun.status === "completed" && checkRun.conclusion === "cancelled"; +} + +/** + * Picks the newest run of each required check and grades it. A cancelled run + * is not a verdict on the commit, so it yields to the newest run that has one: + * a merge landing on main cancels the previous main CI run, but the merge + * queue already ran the same checks on that exact commit. + */ +export function evaluateRequiredChecks( requiredChecks: ReadonlyArray, checkRuns: ReadonlyArray, ): CheckRunEvaluation { const checksByName = new Map(); - for (const checkRun of checkRuns) { - if (checkRun.name && !checksByName.has(checkRun.name)) { + for (const checkRun of [...checkRuns].sort(compareCheckRuns)) { + if (!checkRun.name) { + continue; + } + const current = checksByName.get(checkRun.name); + if (!current || (isCancelled(current) && !isCancelled(checkRun))) { checksByName.set(checkRun.name, checkRun); } } @@ -196,8 +210,7 @@ async function fetchRequiredGithubActionCheckRuns({ const requiredCheckSet = new Set(requiredChecks); return (await fetchCheckRuns(repository, ref, token)) .filter((run) => requiredCheckSet.has(run.name ?? "")) - .filter((run) => !run.app?.slug || run.app.slug === "github-actions") - .sort(compareCheckRuns); + .filter((run) => !run.app?.slug || run.app.slug === "github-actions"); } async function waitForRequiredChecks({ @@ -296,4 +309,6 @@ async function main(): Promise { console.log(`Required CI checks passed for ${ref}.`); } -await main(); +if (import.meta.main) { + await main(); +}