Skip to content

Commit c8281f3

Browse files
authored
Merge pull request #1169 from CryZe/pull-request-previews
Add Pull Request Previews
2 parents 5d4b3c3 + adc476c commit c8281f3

12 files changed

Lines changed: 1347 additions & 7 deletions
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
markPreviewRemoved,
3+
previewBranch,
4+
type GitHubClient,
5+
type Repository,
6+
} from "./preview-github.mts";
7+
8+
type ApiResponse<T> = {
9+
success: boolean;
10+
result: T;
11+
result_info?: { total_pages?: number };
12+
};
13+
export type PagesClient = {
14+
get<T>(suffix?: string): Promise<ApiResponse<T>>;
15+
delete(suffix: string): Promise<void>;
16+
};
17+
type Deployment = {
18+
id: string;
19+
environment: string;
20+
deployment_trigger?: { metadata?: { branch?: string } };
21+
};
22+
23+
export function cloudflareClient(
24+
account: string,
25+
project: string,
26+
token: string,
27+
fetcher: typeof fetch = fetch,
28+
): PagesClient {
29+
if (
30+
!/^[0-9a-f]{32}$/i.test(account) ||
31+
!/^[a-z0-9][a-z0-9-]{0,62}$/.test(project) ||
32+
!token
33+
) {
34+
throw new Error(
35+
"Configure the Cloudflare account, preview project, and API token.",
36+
);
37+
}
38+
const base = `https://api.cloudflare.com/client/v4/accounts/${account}/pages/projects/${project}`;
39+
async function request(suffix = "", method = "GET") {
40+
const response = await fetcher(base + suffix, {
41+
method,
42+
headers: { Authorization: `Bearer ${token}` },
43+
});
44+
// Deletions are idempotent, including retries after a partial cleanup.
45+
if (method === "DELETE" && response.status === 404) {
46+
return null;
47+
}
48+
if (!response.ok) {
49+
throw new Error(
50+
`Cloudflare request failed: HTTP ${response.status}.`,
51+
);
52+
}
53+
const body = (await response.json()) as ApiResponse<unknown>;
54+
if (!body.success) {
55+
throw new Error("Cloudflare rejected the preview request.");
56+
}
57+
return body;
58+
}
59+
return {
60+
async get<T>(suffix = "") {
61+
const response = await request(suffix);
62+
if (response === null)
63+
throw new Error("Missing Cloudflare response.");
64+
return response as ApiResponse<T>;
65+
},
66+
async delete(suffix) {
67+
await request(suffix, "DELETE");
68+
},
69+
};
70+
}
71+
72+
export async function checkPreviewProject(client: PagesClient, number: number) {
73+
const branch = previewBranch(number);
74+
const { result } = await client.get<{ production_branch: string }>();
75+
// A mistaken project configuration must never turn a PR deployment into
76+
// production, where deleting its latest deployment would be unsafe.
77+
if (
78+
typeof result.production_branch !== "string" ||
79+
/^pr-\d+$/.test(result.production_branch)
80+
) {
81+
throw new Error(
82+
"The Pages production branch must not use a PR preview branch.",
83+
);
84+
}
85+
return branch;
86+
}
87+
88+
export async function deletePreviewDeployments(
89+
client: PagesClient,
90+
number: number,
91+
) {
92+
const branch = await checkPreviewProject(client, number);
93+
const deployments: Deployment[] = [];
94+
// Collect every page before deleting anything. Otherwise deletions shift
95+
// the remaining pages and can leave older PR builds behind.
96+
for (let page = 1; ; page++) {
97+
const response = await client.get<Deployment[]>(
98+
`/deployments?env=preview&per_page=100&page=${page}`,
99+
);
100+
deployments.push(
101+
...response.result.filter(
102+
(deployment) =>
103+
deployment.environment === "preview" &&
104+
deployment.deployment_trigger?.metadata?.branch === branch,
105+
),
106+
);
107+
const totalPages = response.result_info?.total_pages;
108+
if (
109+
totalPages !== undefined
110+
? page >= totalPages
111+
: response.result.length < 100
112+
) {
113+
break;
114+
}
115+
}
116+
for (const deployment of deployments) {
117+
// force removes the branch alias as well as historic immutable URLs.
118+
// The explicit environment and branch checks above protect production
119+
// and every other PR, even if the API returns an unexpected entry.
120+
await client.delete(
121+
`/deployments/${encodeURIComponent(deployment.id)}?force=true`,
122+
);
123+
}
124+
return deployments.length;
125+
}
126+
127+
// Both the close workflow and late publishers use the same cleanup. GitHub
128+
// replaces older pending jobs in a concurrency group, even with cancellation
129+
// disabled. A publisher queued after a close must therefore clean up too,
130+
// rather than rely solely on the potentially replaced close job.
131+
export async function removeClosedPreview(
132+
github: GitHubClient,
133+
repo: Repository,
134+
number: number,
135+
client: PagesClient,
136+
) {
137+
const { data: pr } = await github.rest.pulls.get({
138+
...repo,
139+
pull_number: number,
140+
});
141+
if (pr.state !== "closed") {
142+
return null;
143+
}
144+
const deleted = await deletePreviewDeployments(client, number);
145+
await markPreviewRemoved(github, repo, number, pr.head.sha);
146+
return deleted;
147+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { Octokit } from "@octokit/rest";
4+
import {
5+
cloudflareClient,
6+
deletePreviewDeployments,
7+
removeClosedPreview,
8+
} from "./preview-cloudflare.mts";
9+
10+
const account = "a".repeat(32);
11+
12+
test("merged and unmerged closes clean up; a reopened PR keeps its preview", async () => {
13+
for (const [state, merged] of [
14+
["closed", true],
15+
["closed", false],
16+
["open", false],
17+
] as const) {
18+
const github = new Octokit({
19+
request: {
20+
fetch: (input: RequestInfo | URL) => {
21+
const url = new URL(
22+
input instanceof Request ? input.url : input.toString(),
23+
);
24+
const data = url.pathname.endsWith("/pulls/42")
25+
? { state, merged, head: { sha: "a".repeat(40) } }
26+
: url.pathname.endsWith("/issues/42/comments")
27+
? []
28+
: { total_count: 0, check_runs: [] };
29+
const response = new Response(JSON.stringify(data), {
30+
headers: { "content-type": "application/json" },
31+
});
32+
Object.defineProperty(response, "url", {
33+
value: url.toString(),
34+
});
35+
return Promise.resolve(response);
36+
},
37+
},
38+
});
39+
let removed = 0;
40+
const client = cloudflareClient(
41+
account,
42+
"lso-previews",
43+
"test-token",
44+
(input, init) => {
45+
const url = new URL(
46+
input instanceof Request ? input.url : input.toString(),
47+
);
48+
if (init?.method === "DELETE") removed++;
49+
const result = url.pathname.includes("deployments")
50+
? [deployment("latest", "pr-42")]
51+
: { production_branch: "master" };
52+
return Promise.resolve(
53+
new Response(JSON.stringify({ success: true, result })),
54+
);
55+
},
56+
);
57+
assert.equal(
58+
await removeClosedPreview(
59+
github,
60+
{ owner: "LiveSplit", repo: "LiveSplitOne" },
61+
42,
62+
client,
63+
),
64+
state === "closed" ? 1 : null,
65+
);
66+
assert.equal(removed, state === "closed" ? 1 : 0);
67+
}
68+
});
69+
70+
function deployment(id: string, branch: string, environment = "preview") {
71+
return { id, environment, deployment_trigger: { metadata: { branch } } };
72+
}
73+
74+
test("removes every PR build and its alias without touching other PRs or production", async () => {
75+
const calls: string[] = [];
76+
const fetcher: typeof fetch = (input, init) => {
77+
const url = new URL(
78+
input instanceof Request ? input.url : input.toString(),
79+
);
80+
const call = `${init?.method ?? "GET"} ${url.pathname}${url.search}`;
81+
calls.push(call);
82+
let result: unknown;
83+
let pages: { total_pages: number } | undefined;
84+
if (!url.pathname.includes("/deployments")) {
85+
result = { production_branch: "master" };
86+
} else if (init?.method === "DELETE") {
87+
assert.equal(url.searchParams.get("force"), "true");
88+
result = null;
89+
} else {
90+
assert.equal(url.searchParams.get("env"), "preview");
91+
pages = { total_pages: 2 };
92+
result =
93+
url.searchParams.get("page") === "1"
94+
? [
95+
deployment("latest", "pr-42"),
96+
deployment("other", "pr-43"),
97+
deployment("production", "pr-42", "production"),
98+
]
99+
: [
100+
deployment("old", "pr-42"),
101+
deployment("similar", "pr-420"),
102+
];
103+
}
104+
return Promise.resolve(
105+
new Response(
106+
JSON.stringify({
107+
success: true,
108+
result,
109+
result_info: pages,
110+
}),
111+
{ headers: { "content-type": "application/json" } },
112+
),
113+
);
114+
};
115+
const client = cloudflareClient(
116+
account,
117+
"lso-previews",
118+
"test-token",
119+
fetcher,
120+
);
121+
assert.equal(await deletePreviewDeployments(client, 42), 2);
122+
assert(calls[1]?.includes("page=1") && calls[2]?.includes("page=2"));
123+
const removed = calls.filter((call) => call.startsWith("DELETE"));
124+
assert.equal(removed.length, 2);
125+
assert(
126+
removed[0]?.includes("/latest?force=true") &&
127+
removed[1]?.includes("/old?force=true"),
128+
);
129+
});
130+
131+
test("empty and already-deleted previews are safe to clean up repeatedly", async () => {
132+
for (const entries of [[], [deployment("gone", "pr-42")]]) {
133+
const client = cloudflareClient(
134+
account,
135+
"lso-previews",
136+
"test-token",
137+
(input, init) => {
138+
if (init?.method === "DELETE")
139+
return Promise.resolve(new Response(null, { status: 404 }));
140+
const url = new URL(
141+
input instanceof Request ? input.url : input.toString(),
142+
);
143+
return Promise.resolve(
144+
new Response(
145+
JSON.stringify({
146+
success: true,
147+
result: url.pathname.includes("deployments")
148+
? entries
149+
: { production_branch: "master" },
150+
}),
151+
),
152+
);
153+
},
154+
);
155+
assert.equal(
156+
await deletePreviewDeployments(client, 42),
157+
entries.length,
158+
);
159+
}
160+
});
161+
162+
test("rejects a misconfigured production branch before listing or deleting deployments", async () => {
163+
let requests = 0;
164+
const client = cloudflareClient(
165+
account,
166+
"lso-previews",
167+
"test-token",
168+
() => {
169+
requests++;
170+
return Promise.resolve(
171+
new Response(
172+
JSON.stringify({
173+
success: true,
174+
result: { production_branch: "pr-42" },
175+
}),
176+
),
177+
);
178+
},
179+
);
180+
await assert.rejects(
181+
deletePreviewDeployments(client, 42),
182+
/production branch/,
183+
);
184+
assert.equal(requests, 1);
185+
});
186+
187+
test("authentication errors fail cleanup without logging the token or response body", async () => {
188+
const token = "private-test-token";
189+
const client = cloudflareClient(account, "lso-previews", token, () =>
190+
Promise.resolve(new Response(token, { status: 401 })),
191+
);
192+
await assert.rejects(
193+
deletePreviewDeployments(client, 42),
194+
(error: unknown) =>
195+
error instanceof Error &&
196+
error.message.includes("401") &&
197+
!error.message.includes(token),
198+
);
199+
});

0 commit comments

Comments
 (0)