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
147 changes: 147 additions & 0 deletions .github/scripts/preview-cloudflare.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
markPreviewRemoved,
previewBranch,
type GitHubClient,
type Repository,
} from "./preview-github.mts";

type ApiResponse<T> = {
success: boolean;
result: T;
result_info?: { total_pages?: number };
};
export type PagesClient = {
get<T>(suffix?: string): Promise<ApiResponse<T>>;
delete(suffix: string): Promise<void>;
};
type Deployment = {
id: string;
environment: string;
deployment_trigger?: { metadata?: { branch?: string } };
};

export function cloudflareClient(
account: string,
project: string,
token: string,
fetcher: typeof fetch = fetch,
): PagesClient {
if (
!/^[0-9a-f]{32}$/i.test(account) ||
!/^[a-z0-9][a-z0-9-]{0,62}$/.test(project) ||
!token
) {
throw new Error(
"Configure the Cloudflare account, preview project, and API token.",
);
}
const base = `https://api.cloudflare.com/client/v4/accounts/${account}/pages/projects/${project}`;
async function request(suffix = "", method = "GET") {
const response = await fetcher(base + suffix, {
method,
headers: { Authorization: `Bearer ${token}` },
});
// Deletions are idempotent, including retries after a partial cleanup.
if (method === "DELETE" && response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(
`Cloudflare request failed: HTTP ${response.status}.`,
);
}
const body = (await response.json()) as ApiResponse<unknown>;
if (!body.success) {
throw new Error("Cloudflare rejected the preview request.");
}
return body;
}
return {
async get<T>(suffix = "") {
const response = await request(suffix);
if (response === null)
throw new Error("Missing Cloudflare response.");
return response as ApiResponse<T>;
},
async delete(suffix) {
await request(suffix, "DELETE");
},
};
}

export async function checkPreviewProject(client: PagesClient, number: number) {
const branch = previewBranch(number);
const { result } = await client.get<{ production_branch: string }>();
// A mistaken project configuration must never turn a PR deployment into
// production, where deleting its latest deployment would be unsafe.
if (
typeof result.production_branch !== "string" ||
/^pr-\d+$/.test(result.production_branch)
) {
throw new Error(
"The Pages production branch must not use a PR preview branch.",
);
}
return branch;
}

export async function deletePreviewDeployments(
client: PagesClient,
number: number,
) {
const branch = await checkPreviewProject(client, number);
const deployments: Deployment[] = [];
// Collect every page before deleting anything. Otherwise deletions shift
// the remaining pages and can leave older PR builds behind.
for (let page = 1; ; page++) {
const response = await client.get<Deployment[]>(
`/deployments?env=preview&per_page=100&page=${page}`,
);
deployments.push(
...response.result.filter(
(deployment) =>
deployment.environment === "preview" &&
deployment.deployment_trigger?.metadata?.branch === branch,
),
);
const totalPages = response.result_info?.total_pages;
if (
totalPages !== undefined
? page >= totalPages
: response.result.length < 100
) {
break;
}
}
for (const deployment of deployments) {
// force removes the branch alias as well as historic immutable URLs.
// The explicit environment and branch checks above protect production
// and every other PR, even if the API returns an unexpected entry.
await client.delete(
`/deployments/${encodeURIComponent(deployment.id)}?force=true`,
);
}
return deployments.length;
}

// Both the close workflow and late publishers use the same cleanup. GitHub
// replaces older pending jobs in a concurrency group, even with cancellation
// disabled. A publisher queued after a close must therefore clean up too,
// rather than rely solely on the potentially replaced close job.
export async function removeClosedPreview(
github: GitHubClient,
repo: Repository,
number: number,
client: PagesClient,
) {
const { data: pr } = await github.rest.pulls.get({
...repo,
pull_number: number,
});
if (pr.state !== "closed") {
return null;
}
const deleted = await deletePreviewDeployments(client, number);
await markPreviewRemoved(github, repo, number, pr.head.sha);
return deleted;
}
199 changes: 199 additions & 0 deletions .github/scripts/preview-cloudflare.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Octokit } from "@octokit/rest";
import {
cloudflareClient,
deletePreviewDeployments,
removeClosedPreview,
} from "./preview-cloudflare.mts";

const account = "a".repeat(32);

test("merged and unmerged closes clean up; a reopened PR keeps its preview", async () => {
for (const [state, merged] of [
["closed", true],
["closed", false],
["open", false],
] as const) {
const github = new Octokit({
request: {
fetch: (input: RequestInfo | URL) => {
const url = new URL(
input instanceof Request ? input.url : input.toString(),
);
const data = url.pathname.endsWith("/pulls/42")
? { state, merged, head: { sha: "a".repeat(40) } }
: url.pathname.endsWith("/issues/42/comments")
? []
: { total_count: 0, check_runs: [] };
const response = new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" },
});
Object.defineProperty(response, "url", {
value: url.toString(),
});
return Promise.resolve(response);
},
},
});
let removed = 0;
const client = cloudflareClient(
account,
"lso-previews",
"test-token",
(input, init) => {
const url = new URL(
input instanceof Request ? input.url : input.toString(),
);
if (init?.method === "DELETE") removed++;
const result = url.pathname.includes("deployments")
? [deployment("latest", "pr-42")]
: { production_branch: "master" };
return Promise.resolve(
new Response(JSON.stringify({ success: true, result })),
);
},
);
assert.equal(
await removeClosedPreview(
github,
{ owner: "LiveSplit", repo: "LiveSplitOne" },
42,
client,
),
state === "closed" ? 1 : null,
);
assert.equal(removed, state === "closed" ? 1 : 0);
}
});

function deployment(id: string, branch: string, environment = "preview") {
return { id, environment, deployment_trigger: { metadata: { branch } } };
}

test("removes every PR build and its alias without touching other PRs or production", async () => {
const calls: string[] = [];
const fetcher: typeof fetch = (input, init) => {
const url = new URL(
input instanceof Request ? input.url : input.toString(),
);
const call = `${init?.method ?? "GET"} ${url.pathname}${url.search}`;
calls.push(call);
let result: unknown;
let pages: { total_pages: number } | undefined;
if (!url.pathname.includes("/deployments")) {
result = { production_branch: "master" };
} else if (init?.method === "DELETE") {
assert.equal(url.searchParams.get("force"), "true");
result = null;
} else {
assert.equal(url.searchParams.get("env"), "preview");
pages = { total_pages: 2 };
result =
url.searchParams.get("page") === "1"
? [
deployment("latest", "pr-42"),
deployment("other", "pr-43"),
deployment("production", "pr-42", "production"),
]
: [
deployment("old", "pr-42"),
deployment("similar", "pr-420"),
];
}
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
result,
result_info: pages,
}),
{ headers: { "content-type": "application/json" } },
),
);
};
const client = cloudflareClient(
account,
"lso-previews",
"test-token",
fetcher,
);
assert.equal(await deletePreviewDeployments(client, 42), 2);
assert(calls[1]?.includes("page=1") && calls[2]?.includes("page=2"));
const removed = calls.filter((call) => call.startsWith("DELETE"));
assert.equal(removed.length, 2);
assert(
removed[0]?.includes("/latest?force=true") &&
removed[1]?.includes("/old?force=true"),
);
});

test("empty and already-deleted previews are safe to clean up repeatedly", async () => {
for (const entries of [[], [deployment("gone", "pr-42")]]) {
const client = cloudflareClient(
account,
"lso-previews",
"test-token",
(input, init) => {
if (init?.method === "DELETE")
return Promise.resolve(new Response(null, { status: 404 }));
const url = new URL(
input instanceof Request ? input.url : input.toString(),
);
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
result: url.pathname.includes("deployments")
? entries
: { production_branch: "master" },
}),
),
);
},
);
assert.equal(
await deletePreviewDeployments(client, 42),
entries.length,
);
}
});

test("rejects a misconfigured production branch before listing or deleting deployments", async () => {
let requests = 0;
const client = cloudflareClient(
account,
"lso-previews",
"test-token",
() => {
requests++;
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
result: { production_branch: "pr-42" },
}),
),
);
},
);
await assert.rejects(
deletePreviewDeployments(client, 42),
/production branch/,
);
assert.equal(requests, 1);
});

test("authentication errors fail cleanup without logging the token or response body", async () => {
const token = "private-test-token";
const client = cloudflareClient(account, "lso-previews", token, () =>
Promise.resolve(new Response(token, { status: 401 })),
);
await assert.rejects(
deletePreviewDeployments(client, 42),
(error: unknown) =>
error instanceof Error &&
error.message.includes("401") &&
!error.message.includes(token),
);
});
Loading
Loading