Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions scripts/verify-release-ci.test.ts
Original file line number Diff line number Diff line change
@@ -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, []);
});
27 changes: 21 additions & 6 deletions scripts/verify-release-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
checkRuns: ReadonlyArray<CheckRun>,
): CheckRunEvaluation {
const checksByName = new Map<string, CheckRun>();
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);
}
}
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -296,4 +309,6 @@ async function main(): Promise<void> {
console.log(`Required CI checks passed for ${ref}.`);
}

await main();
if (import.meta.main) {
await main();
}
Loading