-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathpreview-controller.ts
More file actions
459 lines (428 loc) · 16.3 KB
/
Copy pathpreview-controller.ts
File metadata and controls
459 lines (428 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
import { requiredEnv, requiredPositiveInteger } from "./lib/env.ts";
type ApiMethod<T> = (params: Record<string, unknown>) => Promise<{ data: T }>;
interface PullRequest {
readonly state: string;
readonly draft: boolean;
readonly html_url: string;
readonly title: string;
readonly author_association: string;
readonly labels: readonly { readonly name: string }[];
readonly base: { readonly ref: string };
readonly head: {
readonly ref: string;
readonly sha: string;
readonly repo?: { readonly full_name: string } | null;
};
}
interface PullRequestFile {
readonly filename: string;
}
interface Deployment {
readonly environment: string;
readonly id: number;
readonly sha: string;
}
interface RetirementOptions {
readonly description?: string;
readonly forceStatus?: boolean;
readonly keepDeploymentId?: number;
readonly keepRecords?: boolean;
}
export interface GitHubApi {
readonly paginate: <T>(endpoint: ApiMethod<T[]>, params: Record<string, unknown>) => Promise<T[]>;
readonly rest: {
readonly pulls: {
readonly get: ApiMethod<PullRequest>;
};
readonly repos: {
readonly compareCommitsWithBasehead: ApiMethod<{ files?: PullRequestFile[] }>;
readonly createDeployment: ApiMethod<Deployment>;
readonly createDeploymentStatus: ApiMethod<unknown>;
readonly deleteDeployment: ApiMethod<unknown>;
readonly listDeployments: ApiMethod<Deployment[]>;
readonly listDeploymentStatuses: ApiMethod<
{ readonly description?: string | null; readonly state: string }[]
>;
};
};
}
interface ActionsContext {
readonly repo: { readonly owner: string; readonly repo: string };
readonly payload: {
readonly repository: { readonly default_branch: string };
readonly pull_request?: { readonly number: number };
};
}
interface ActionsCore {
readonly notice: (message: string) => void;
readonly setFailed: (message: string) => void;
readonly setOutput: (name: string, value: string) => void;
}
interface ControllerInput {
readonly github: GitHubApi;
readonly context: ActionsContext;
readonly core: ActionsCore;
}
const PREVIEW_LABEL = "preview";
const TRUSTED_ASSOCIATIONS = new Set(["COLLABORATOR", "MEMBER", "OWNER"]);
// GitHub's comparison endpoint reports at most this many files and gives no truncation flag.
const COMPARE_FILE_LIMIT = 300;
const DEFAULT_MAX_ACTIVE = 3;
/** One night's sweep. Reached only if teardown has been failing, which is when a bound matters. */
const RECONCILE_LIMIT = 100;
const LIVE_STATES = new Set(["in_progress", "pending", "queued", "success"]);
const TEARDOWN_REQUESTED_DESCRIPTION =
"Preview teardown requested; awaiting Coolify reconciliation.";
const hasPreviewLabel = (pull: PullRequest): boolean =>
pull.labels.some((label) => label.name === PREVIEW_LABEL);
const maxActivePreviews = (): number =>
process.env.PREVIEW_MAX_ACTIVE
? requiredPositiveInteger(process.env, "PREVIEW_MAX_ACTIVE")
: DEFAULT_MAX_ACTIVE;
/**
* Environments still holding a slot. A pull request whose teardown has been requested does not hold
* one: the request is the same close event Coolify acts on, and the nightly reconcile re-sends it if
* that was missed. Nothing here reads the host, so a slot is freed on the request, not on proof.
*/
const occupiedEnvironments = async (
github: GitHubApi,
owner: string,
repo: string,
): Promise<string[]> => {
const deployments = await github.paginate(github.rest.repos.listDeployments, {
owner,
repo,
task: "deploy:preview",
per_page: 100,
});
const occupied = new Set<string>();
for (const deployment of deployments) {
if (occupied.has(deployment.environment)) continue;
const statuses = await github.rest.repos.listDeploymentStatuses({
owner,
repo,
deployment_id: deployment.id,
per_page: 1,
});
const latest = statuses.data[0]?.state;
// A requested teardown has been handed to Coolify and a failed deploy never reached the host,
// so neither holds anything — counting them would report a full host that is empty.
if (latest === "failure" || latest === "error") continue;
if (latest === "inactive" && statuses.data[0]?.description === TEARDOWN_REQUESTED_DESCRIPTION) {
continue;
}
occupied.add(deployment.environment);
}
return [...occupied].toSorted();
};
const resolve = async ({ github, context, core }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
if (!context.payload.pull_request) throw new Error("Pull request payload is incomplete.");
const number = context.payload.pull_request.number;
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number });
const defaultBranch = context.payload.repository.default_branch;
const environment = `preview/pr-${number}`;
const labelled = hasPreviewLabel(pull);
core.setOutput("pr_number", String(number));
core.setOutput("environment", environment);
core.setOutput("announce", "false");
// Only a labelled pull request, and only for a reason someone can act on, earns a status comment.
const skip = (reason: string, quiet = false): void => {
core.notice(reason);
core.setOutput("eligible", "false");
core.setOutput("reason", reason);
core.setOutput("announce", String(labelled && !quiet));
};
if (!labelled) return skip(`PR #${number} does not carry the \`${PREVIEW_LABEL}\` label.`);
if (pull.state !== "open") return skip(`PR #${number} is closed.`);
if (pull.draft) return skip(`PR #${number} is a draft. Mark it ready for review to deploy.`);
if (pull.head.repo?.full_name !== `${owner}/${repo}`) {
return skip(
`PR #${number} comes from a fork. Previews run only for branches in this repository.`,
);
}
// Coolify is handed this association and refuses an untrusted one. Checking it here turns that
// into a skip reason on the pull request instead of a failure after the deployment is announced.
if (!TRUSTED_ASSOCIATIONS.has(pull.author_association)) {
return skip(
`PR #${number} was opened by a ${pull.author_association.toLowerCase()}, not a repository collaborator.`,
);
}
// Compared against the default branch rather than this pull request's own base: a stacked layer's
// diff hides whatever the layers beneath it changed, and those commits are in the head that
// Coolify deploys.
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${defaultBranch}...${pull.head.sha}`,
});
const files = comparison.data.files ?? [];
if (files.length >= COMPARE_FILE_LIMIT) {
return skip(
`PR #${number} changes ${files.length}+ files, too many for GitHub to report in one comparison, so deployment policy cannot be verified.`,
);
}
const protectedFile = files.find(
(file) =>
file.filename.startsWith("docker/preview/") ||
file.filename.startsWith(".github/workflows/") ||
file.filename.startsWith(".github/actions/"),
);
if (protectedFile) {
return skip(
`PR #${number} changes trusted deployment policy (\`${protectedFile.filename}\`), so it cannot deploy until that change is merged.`,
);
}
const deployments = await github.rest.repos.listDeployments({
owner,
repo,
environment,
per_page: 1,
});
const current = deployments.data[0];
if (current?.sha === pull.head.sha) {
const statuses = await github.rest.repos.listDeploymentStatuses({
owner,
repo,
deployment_id: current.id,
per_page: 1,
});
if (LIVE_STATES.has(statuses.data[0]?.state ?? "")) {
return skip(`PR #${number} already has a current preview deployment.`, true);
}
}
const maxActive = maxActivePreviews();
const occupied = await occupiedEnvironments(github, owner, repo);
if (!occupied.includes(environment) && occupied.length >= maxActive) {
const holders = occupied.map((slot) => `#${slot.replace("preview/pr-", "")}`).join(", ");
return skip(
`The preview host is full (${occupied.length}/${maxActive}). Remove the \`${PREVIEW_LABEL}\` label from ${holders} to free a slot.`,
);
}
const previewTemplate = requiredEnv(process.env, "COOLIFY_PREVIEW_URL_TEMPLATE");
if (!previewTemplate.includes("{pr}")) {
throw new Error("COOLIFY_PREVIEW_URL_TEMPLATE must contain {pr}.");
}
const coolifyUrl = new URL(requiredEnv(process.env, "COOLIFY_URL"));
const previewUrl = new URL(previewTemplate.replace("{pr}", String(number)));
if (coolifyUrl.protocol !== "https:" || previewUrl.protocol !== "https:") {
throw new Error("Coolify and preview URLs must use HTTPS.");
}
core.setOutput("eligible", "true");
core.setOutput("pr_url", pull.html_url);
core.setOutput("pr_title", pull.title);
core.setOutput("author_association", pull.author_association);
core.setOutput("head_ref", pull.head.ref);
// Coolify selects the preview application by the webhook's base ref, and that application is
// configured for the default branch. It is a routing key here, not a description of the stack.
core.setOutput("base_ref", defaultBranch);
core.setOutput("head_sha", pull.head.sha);
core.setOutput("preview_url", previewUrl.href);
};
/**
* Last check before the approved head is handed to Coolify. Losing the race is normal — someone
* dropped the label, or pushed again — and neither is a fault, so this stops the deployment through
* `proceed` rather than failing the run and leaving a red mark the author cannot act on.
*/
const recheck = async ({ github, context, core }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
const number = requiredPositiveInteger(process.env, "PR_NUMBER");
const headSha = requiredEnv(process.env, "HEAD_SHA");
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number });
const halt = (reason: string): void => {
core.notice(reason);
core.setOutput("proceed", "false");
};
if (pull.state !== "open" || pull.draft || !hasPreviewLabel(pull)) {
return halt(`PR #${number} opted out while deploying; cleanup takes it from here.`);
}
if (pull.head.sha !== headSha) {
return halt(`PR #${number} moved to a newer head; its own CI run will deploy it.`);
}
if (pull.head.repo?.full_name !== `${owner}/${repo}`) {
core.setFailed(
`PR #${number} became a fork pull request during preflight; refusing to deploy.`,
);
return;
}
core.setOutput("proceed", "true");
};
const create = async ({ github, context, core }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
const headSha = requiredEnv(process.env, "HEAD_SHA");
const environment = requiredEnv(process.env, "ENVIRONMENT");
const response = await github.rest.repos.createDeployment({
owner,
repo,
ref: headSha,
task: "deploy:preview",
auto_merge: false,
required_contexts: [],
environment,
description: `Coolify preview for PR #${requiredEnv(process.env, "PR_NUMBER")}`,
transient_environment: true,
production_environment: false,
});
if (response.data.sha !== headSha) {
throw new Error("GitHub did not register the deployment against the requested head SHA.");
}
const deploymentId = response.data.id;
core.setOutput("deployment_id", String(deploymentId));
await github.rest.repos.createDeploymentStatus({
owner,
repo,
deployment_id: deploymentId,
state: "queued",
description: "Admission reserved; Coolify queue follows.",
environment,
environment_url: requiredEnv(process.env, "PREVIEW_URL"),
log_url: requiredEnv(process.env, "SOURCE_RUN_URL"),
});
};
const finalize = async ({ github, context, core }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
const deploymentId = requiredPositiveInteger(process.env, "DEPLOYMENT_ID");
const environment = requiredEnv(process.env, "ENVIRONMENT");
const previewUrl = requiredEnv(process.env, "PREVIEW_URL");
const sourceRunUrl = requiredEnv(process.env, "SOURCE_RUN_URL");
const allowedStates = new Set(["error", "failure", "success"]);
const finalState = requiredEnv(process.env, "FINAL_STATE");
let state = allowedStates.has(finalState) ? finalState : "error";
let description = requiredEnv(process.env, "DESCRIPTION");
const pull = await github.rest.pulls.get({
owner,
repo,
pull_number: requiredPositiveInteger(process.env, "PR_NUMBER"),
});
if (pull.data.state !== "open" || !hasPreviewLabel(pull.data)) {
state = "inactive";
description = "Preview opted out while deploying; cleanup owns the final state.";
}
const isHttps = (value: string): boolean => {
try {
return new URL(value).protocol === "https:";
} catch {
return false;
}
};
await github.rest.repos.createDeploymentStatus({
owner,
repo,
deployment_id: deploymentId,
state,
description: description.slice(0, 140),
environment,
environment_url: previewUrl,
log_url: isHttps(requiredEnv(process.env, "LOG_URL"))
? requiredEnv(process.env, "LOG_URL")
: sourceRunUrl,
});
core.setOutput("final_state", state);
if (state !== "success") return;
await retireDeployments(github, owner, repo, environment, { keepDeploymentId: deploymentId });
};
async function retireDeployments(
github: GitHubApi,
owner: string,
repo: string,
environment: string,
options: RetirementOptions = {},
): Promise<void> {
const {
description = "Preview resources are absent or this deployment was superseded.",
forceStatus = false,
keepDeploymentId,
keepRecords = false,
} = options;
const deployments = await github.paginate(github.rest.repos.listDeployments, {
owner,
repo,
environment,
per_page: 100,
});
for (const deployment of deployments) {
if (deployment.id === keepDeploymentId) continue;
const statuses = await github.rest.repos.listDeploymentStatuses({
owner,
repo,
deployment_id: deployment.id,
per_page: 1,
});
if (forceStatus || statuses.data[0]?.state !== "inactive") {
await github.rest.repos.createDeploymentStatus({
owner,
repo,
deployment_id: deployment.id,
state: "inactive",
description,
environment,
});
}
if (!keepRecords) {
await github.rest.repos.deleteDeployment({ owner, repo, deployment_id: deployment.id });
}
}
}
const inactivate = async ({ github, context }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
await retireDeployments(github, owner, repo, requiredEnv(process.env, "ENVIRONMENT"), {
description: TEARDOWN_REQUESTED_DESCRIPTION,
forceStatus: true,
keepRecords: true,
});
};
/** Whether a preview found on the host or in GitHub's records should still be holding its slot. */
const assess = async ({ github, context, core }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
const number = requiredPositiveInteger(process.env, "PR_NUMBER");
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number });
const stale = pull.state !== "open" || pull.draft || !hasPreviewLabel(pull);
core.setOutput("stale", String(stale));
if (!stale) {
core.notice(`PR #${number} still wants its preview; leaving it untouched.`);
return;
}
core.setOutput("url", pull.html_url);
core.setOutput("title", pull.title);
core.setOutput("association", pull.author_association);
core.setOutput("head_ref", pull.head.ref);
core.setOutput("head_sha", pull.head.sha);
core.setOutput("base_ref", context.payload.repository.default_branch);
};
/**
* Candidates for the nightly sweep: the environments admission still believes are occupied. A
* preview whose teardown was already recorded holds nothing, so re-sending its close event would
* ask Coolify to remove a stack that is gone — every night, for every preview ever deployed. Worse,
* the sweep is bounded, and a list that only grows would fill that bound with previews already dealt
* with, leaving a genuinely leaked one unreached.
*/
const inventory = async ({ github, context, core }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
const occupied = await occupiedEnvironments(github, owner, repo);
const numbers = occupied
.map((environment) => Number(environment.slice("preview/pr-".length)))
.filter((number) => Number.isSafeInteger(number) && number > 0)
.toSorted((left, right) => left - right);
core.setOutput("previews", JSON.stringify(numbers.slice(0, RECONCILE_LIMIT)));
if (numbers.length > RECONCILE_LIMIT) {
core.notice(
`Found ${numbers.length} previews still holding a slot; reconciling the oldest ${RECONCILE_LIMIT}.`,
);
}
};
const retire = async ({ github, context }: ControllerInput): Promise<void> => {
const { owner, repo } = context.repo;
await retireDeployments(github, owner, repo, requiredEnv(process.env, "ENVIRONMENT"));
};
export {
TEARDOWN_REQUESTED_DESCRIPTION,
PREVIEW_LABEL,
assess,
create,
inventory,
finalize,
inactivate,
recheck,
resolve,
retire,
};