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(); +}