Skip to content
761 linesCodeBlameRaw
1/**
2 * A repository's deployments, wherever they run, in one model: what any
3 * CI reports through the API, what g1t Actions makes for each job with an
4 * `environment:`, and g1t.page builds, which stay in `deployments` and are
5 * read in alongside (never copied, so nothing about building them changes).
6 *
7 * Reported deployments keep every status they were given; a build's
8 * statuses are read from its own timestamps. Each status also reports on
9 * the commit as `deploy / <environment>`, through the work service's
10 * commit statuses, so it shows with the commit's checks and a ruleset can
11 * require it. Every new deployment and status is published
12 * (`deployment.created`, `deployment_status.created`) for webhooks.
13 *
14 * The rules, apart from storage, are in environments.ts.
15 */
16
17import {
18 can,
19 eventsClient,
20 fail,
21 needs,
22 newId,
23 ok,
24 permission,
25 reposClient,
26 type Capability,
27 type DeploymentDetail,
28 type DeploymentEnvironment,
29 type DeploymentEnvironments,
30 type DeploymentFilter,
31 type DeploymentPage,
32 type DeploymentSource,
33 type DeploymentState,
34 type DeploymentStatus,
35 type NewDeployment,
36 type NewDeploymentStatus,
37 type Repo,
38 type RepoDeployment,
39 type RepoPath,
40 type Result,
41 type ServiceBinding,
42 type User,
43 type Viewer,
44} from "@g1t/contracts";
45
46import {
47 BUILD_STATE_SQL,
48 MAX_DESCRIPTION,
49 MAX_PER_PAGE,
50 MAX_TASK,
51 PER_PAGE,
52 actionsTransition,
53 address,
54 buildStatuses,
55 fromBuild,
56 commitDescription,
57 commitState,
58 compareEnvironments,
59 environmentName,
60 fullSha,
61 isState,
62 payloadOf,
63 shortRef,
64 stateDescription,
65 statusContext,
66 withoutPayload,
67} from "./environments";
68import { appUrl } from "./names";
69
70export { buildStatuses, fromBuild, type BuildRow } from "./environments";
71import type { BuildRow } from "./environments";
72
73export type RepoDeploymentsEnv = {
74 DB: D1Database;
75 REPOS: ServiceBinding;
76 WORK: ServiceBinding;
77 IDENTITY: ServiceBinding;
78 EVENTS?: ServiceBinding;
79 SITE: string;
80};
81
82const now = () => new Date().toISOString();
83
84type ReportedRow = {
85 id: string;
86 repo_id: string;
87 environment: string;
88 ref: string;
89 sha: string;
90 task: string;
91 description: string | null;
92 payload: string;
93 transient_environment: number;
94 production_environment: number;
95 state: DeploymentState;
96 environment_url: string | null;
97 log_url: string | null;
98 creator: string;
99 source: "api" | "actions";
100 run_id: string | null;
101 run_attempt: number | null;
102 run_url: string | null;
103 created_at: string;
104 updated_at: string;
105};
106
107type StatusRow = {
108 id: string;
109 deployment_id: string;
110 state: DeploymentState;
111 description: string | null;
112 environment_url: string | null;
113 log_url: string | null;
114 creator: string;
115 created_at: string;
116};
117
118/** What g1t Actions says about a run's deployment to one environment (`actions_deployment`). */
119export type ActionsReport = {
120 repoId: string;
121 repo: RepoPath;
122 runId: string;
123 attempt: number;
124 runUrl: string;
125 environment: string;
126 /** From the job's `environment.url`, when it gave a plain one. */
127 url: string | null;
128 ref: string;
129 sha: string;
130 state: DeploymentState;
131 /** The run finished: this is its outcome for the environment. */
132 final: boolean;
133 /** Who started the run, or null for g1t. */
134 creator: string | null;
135 workflow: string;
136};
137
138function toDeployment(row: ReportedRow): RepoDeployment {
139 let payload: Record<string, unknown> = {};
140 try {
141 payload = JSON.parse(row.payload || "{}") as Record<string, unknown>;
142 } catch {
143 payload = {};
144 }
145 return {
146 id: row.id,
147 environment: row.environment,
148 ref: row.ref,
149 sha: row.sha,
150 task: row.task,
151 description: row.description,
152 payload,
153 transient_environment: !!row.transient_environment,
154 production_environment: !!row.production_environment,
155 state: row.state,
156 environment_url: row.environment_url,
157 log_url: row.log_url,
158 creator: row.creator,
159 source: row.source,
160 run_id: row.run_id,
161 run_url: row.run_url,
162 project: null,
163 number: null,
164 created_at: row.created_at,
165 updated_at: row.updated_at,
166 };
167}
168
169function toStatus(row: StatusRow): DeploymentStatus {
170 return { ...row };
171}
172
173/** The shape every listing reads from: reported deployments and builds as one table. */
174const UNION = `
175 SELECT id, environment, ref, sha, task, state, source, creator, transient_environment, production_environment,
176 created_at, updated_at, 0 AS build
177 FROM reported_deployments WHERE repo_id = ?1
178 UNION ALL
179 SELECT id, kind, COALESCE(branch, ?2), commit_sha, 'deploy', ${BUILD_STATE_SQL}, 'g1t_page', created_by,
180 kind = 'preview', kind = 'production', created_at, COALESCE(finished_at, started_at, created_at), 1
181 FROM deployments WHERE repo_id = ?1`;
182
183type Listed = { id: string; build: number };
184
185export class RepoDeployments {
186 constructor(private readonly env: RepoDeploymentsEnv) {}
187
188 private get db() {
189 return this.env.DB;
190 }
191
192 /** The repository, if `viewer` may do `capability` in it: not found when they cannot read it. */
193 private async repoFor(path: RepoPath, viewer: Viewer, capability: Capability): Promise<Result<Repo>> {
194 if (!path?.namespace || !path?.name) return fail("invalid", "Give the repository as owner/name.");
195 const found = await reposClient(this.env.REPOS).get(path, viewer);
196 if (!found.ok) return found;
197 const ref = { id: found.value.id, namespace: found.value.namespace, isPrivate: found.value.isPrivate };
198 if (!permission(viewer, ref)) return fail("not_found", "There is no such repository.");
199 if (!can(viewer, ref, capability)) return fail("forbidden", needs(capability));
200 if (capability !== "read" && found.value.archivedAt) {
201 return fail("conflict", "The repository is archived: it is read-only until it is unarchived.");
202 }
203 return found;
204 }
205
206 /** Usernames for the builds' creators recorded by id. */
207 private async names(rows: BuildRow[]): Promise<Record<string, string>> {
208 const ids = [...new Set(rows.map((row) => row.created_by).filter((by) => by.startsWith("usr_")))];
209 if (ids.length === 0) return {};
210 const response = await this.env.IDENTITY.fetch("https://identity/rpc/usernames", {
211 method: "POST",
212 headers: { "content-type": "application/json" },
213 body: JSON.stringify({ ids }),
214 }).catch(() => null);
215 return response?.ok ? ((await response.json().catch(() => ({}))) as Record<string, string>) : {};
216 }
217
218 /** The deployments `listed` names, in that order. */
219 private async hydrate(listed: Listed[], repo: Repo): Promise<RepoDeployment[]> {
220 const reportedIds = listed.filter((row) => !row.build).map((row) => row.id);
221 const buildIds = listed.filter((row) => row.build).map((row) => row.id);
222 const marks = (ids: string[]) => ids.map(() => "?").join(", ");
223 const [reported, builds] = await Promise.all([
224 reportedIds.length
225 ? this.db.prepare(`SELECT * FROM reported_deployments WHERE id IN (${marks(reportedIds)})`).bind(...reportedIds).all<ReportedRow>()
226 : Promise.resolve({ results: [] as ReportedRow[] }),
227 buildIds.length
228 ? this.db.prepare(`SELECT * FROM deployments WHERE id IN (${marks(buildIds)})`).bind(...buildIds).all<BuildRow>()
229 : Promise.resolve({ results: [] as BuildRow[] }),
230 ]);
231 const names = await this.names(builds.results);
232 const byId = new Map<string, RepoDeployment>();
233 for (const row of reported.results) byId.set(row.id, toDeployment(row));
234 for (const row of builds.results) byId.set(row.id, fromBuild(row, repo.defaultBranch, this.env.SITE, appUrl, names));
235 return listed.map((row) => byId.get(row.id)).filter((found): found is RepoDeployment => !!found);
236 }
237
238 // ---- Reading -------------------------------------------------------
239
240 async list(a: { repo: RepoPath; viewer: Viewer } & DeploymentFilter): Promise<Result<DeploymentPage>> {
241 const found = await this.repoFor(a.repo, a.viewer, "read");
242 if (!found.ok) return found;
243 const repo = found.value;
244 const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Math.floor(Number(a.per_page) || PER_PAGE)));
245 const page = Math.max(1, Math.floor(Number(a.page) || 1));
246 if (a.state != null && !isState(a.state)) return fail("invalid", "`state` is queued, in_progress, success, failure, error or inactive.");
247 const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null);
248 const sha = text(a.sha)?.toLowerCase() ?? null;
249 const where = `WHERE (?3 IS NULL OR environment = ?3 COLLATE NOCASE)
250 AND (?4 IS NULL OR ref = ?4) AND (?5 IS NULL OR sha LIKE ?5 || '%') AND (?6 IS NULL OR task = ?6)
251 AND (?7 IS NULL OR state = ?7) AND (?8 IS NULL OR source = ?8) AND (?9 IS NULL OR creator = ?9 COLLATE NOCASE)`;
252 const binds = [
253 repo.id,
254 repo.defaultBranch,
255 text(a.environment),
256 text(a.ref) ? shortRef(text(a.ref)!) : null,
257 sha,
258 text(a.task),
259 a.state ?? null,
260 text(a.source),
261 text(a.creator),
262 ];
263 const [rows, count] = await Promise.all([
264 this.db
265 .prepare(`WITH d AS (${UNION}) SELECT id, build FROM d ${where} ORDER BY created_at DESC, id DESC LIMIT ?10 OFFSET ?11`)
266 .bind(...binds, perPage, (page - 1) * perPage)
267 .all<Listed>(),
268 this.db.prepare(`WITH d AS (${UNION}) SELECT COUNT(*) AS n FROM d ${where}`).bind(...binds).first<{ n: number }>(),
269 ]);
270 return ok({
271 deployments: await this.hydrate(rows.results, repo),
272 total_count: count?.n ?? 0,
273 page,
274 per_page: perPage,
275 });
276 }
277
278 async get(a: { repo: RepoPath; id: string; viewer: Viewer }): Promise<Result<DeploymentDetail>> {
279 const found = await this.repoFor(a.repo, a.viewer, "read");
280 if (!found.ok) return found;
281 return this.detail(found.value, String(a.id ?? ""));
282 }
283
284 private async detail(repo: Repo, id: string): Promise<Result<DeploymentDetail>> {
285 if (id.startsWith("dpl_")) {
286 const row = await this.db
287 .prepare("SELECT * FROM deployments WHERE id = ? AND repo_id = ?")
288 .bind(id, repo.id)
289 .first<BuildRow>();
290 if (!row) return fail("not_found", "There is no such deployment.");
291 const deployment = fromBuild(row, repo.defaultBranch, this.env.SITE, appUrl, await this.names([row]));
292 return ok({ ...deployment, statuses: buildStatuses(row, deployment) });
293 }
294 const row = await this.db
295 .prepare("SELECT * FROM reported_deployments WHERE id = ? AND repo_id = ?")
296 .bind(id, repo.id)
297 .first<ReportedRow>();
298 if (!row) return fail("not_found", "There is no such deployment.");
299 const statuses = await this.db
300 .prepare("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY created_at, id")
301 .bind(id)
302 .all<StatusRow>();
303 return ok({ ...toDeployment(row), statuses: statuses.results.map(toStatus) });
304 }
305
306 async statuses(a: { repo: RepoPath; id: string; viewer: Viewer }): Promise<Result<DeploymentStatus[]>> {
307 const found = await this.get(a);
308 // Newest first, as a list of statuses reads.
309 return found.ok ? ok([...found.value.statuses].reverse()) : found;
310 }
311
312 async environments(a: { repo: RepoPath; viewer: Viewer }): Promise<Result<DeploymentEnvironments>> {
313 const found = await this.repoFor(a.repo, a.viewer, "read");
314 if (!found.ok) return found;
315 const repo = found.value;
316 // Each environment's count, its newest deployment, and its newest
317 // that succeeded and is still active.
318 const [counts, latest, current] = await Promise.all([
319 this.db
320 .prepare(
321 `WITH d AS (${UNION}) SELECT environment, COUNT(*) AS n, MAX(updated_at) AS at,
322 MAX(transient_environment) AS transient, MAX(production_environment) AS production
323 FROM d GROUP BY environment COLLATE NOCASE ORDER BY at DESC LIMIT 100`,
324 )
325 .bind(repo.id, repo.defaultBranch)
326 .all<{ environment: string; n: number; at: string; transient: number; production: number }>(),
327 this.db
328 .prepare(
329 `WITH d AS (${UNION}) SELECT id, build FROM (
330 SELECT id, build, ROW_NUMBER() OVER (PARTITION BY lower(environment) ORDER BY created_at DESC, id DESC) AS rn FROM d
331 ) WHERE rn = 1`,
332 )
333 .bind(repo.id, repo.defaultBranch)
334 .all<Listed>(),
335 this.db
336 .prepare(
337 `WITH d AS (${UNION}) SELECT id, build FROM (
338 SELECT id, build, ROW_NUMBER() OVER (PARTITION BY lower(environment) ORDER BY created_at DESC, id DESC) AS rn
339 FROM d WHERE state = 'success'
340 ) WHERE rn = 1`,
341 )
342 .bind(repo.id, repo.defaultBranch)
343 .all<Listed>(),
344 ]);
345 const deployments = await this.hydrate([...latest.results, ...current.results], repo);
346 const pick = (ids: Listed[], name: string) =>
347 deployments.find((d) => ids.some((row) => row.id === d.id) && d.environment.toLowerCase() === name.toLowerCase()) ?? null;
348 const environments: DeploymentEnvironment[] = counts.results.map((row) => {
349 const newest = pick(latest.results, row.environment);
350 const live = pick(current.results, row.environment);
351 return {
352 name: newest?.environment ?? row.environment,
353 url: live?.environment_url ?? null,
354 production_environment: !!row.production,
355 transient_environment: !!row.transient,
356 deployments_count: row.n,
357 latest: newest,
358 current: live,
359 updated_at: row.at,
360 };
361 });
362 environments.sort(compareEnvironments);
363 return ok({ total_count: counts.results.reduce((sum, row) => sum + row.n, 0), environments });
364 }
365
366 async environment(a: { repo: RepoPath; name: string; viewer: Viewer }): Promise<Result<DeploymentEnvironment>> {
367 const all = await this.environments(a);
368 if (!all.ok) return all;
369 const found = all.value.environments.find((env) => env.name.toLowerCase() === String(a.name ?? "").trim().toLowerCase());
370 return found ? ok(found) : fail("not_found", `There is no environment called ${a.name}.`);
371 }
372
373 // ---- Reporting -----------------------------------------------------
374
375 /** The environment's name as first spelled, made on its first deployment. */
376 private async environmentFor(repoId: string, name: string): Promise<string> {
377 await this.db
378 .prepare("INSERT INTO environments (repo_id, name, created_at) VALUES (?, ?, ?) ON CONFLICT (repo_id, name) DO NOTHING")
379 .bind(repoId, name, now())
380 .run();
381 const row = await this.db
382 .prepare("SELECT name FROM environments WHERE repo_id = ? AND name = ?")
383 .bind(repoId, name)
384 .first<{ name: string }>();
385 // A g1t.page environment is spelled as g1t.page spells it.
386 const builtIn = ["production", "preview"].find((own) => own === name.toLowerCase());
387 return builtIn ?? row?.name ?? name;
388 }
389
390 async create(a: { repo: RepoPath; actor: User } & NewDeployment): Promise<Result<DeploymentDetail>> {
391 const found = await this.repoFor(a.repo, a.actor, "push");
392 if (!found.ok) return found;
393 const repo = found.value;
394 const environment = environmentName(a.environment);
395 if (typeof environment !== "string") return fail("invalid", environment.error);
396 const payload = payloadOf(a.payload);
397 if ("error" in payload) return fail("invalid", payload.error);
398 const environmentUrl = address(a.environment_url, "environment_url");
399 if (environmentUrl && typeof environmentUrl === "object") return fail("invalid", environmentUrl.error);
400 const logUrl = address(a.log_url, "log_url");
401 if (logUrl && typeof logUrl === "object") return fail("invalid", logUrl.error);
402 const state = a.state ?? "queued";
403 if (!isState(state)) return fail("invalid", "`state` is queued, in_progress, success, failure, error or inactive.");
404 const task = typeof a.task === "string" && a.task.trim() ? a.task.trim() : "deploy";
405 if (task.length > MAX_TASK) return fail("invalid", `\`task\` is at most ${MAX_TASK} characters.`);
406 const description = typeof a.description === "string" && a.description.trim() ? a.description.trim() : null;
407 if (description && description.length > MAX_DESCRIPTION) return fail("invalid", `\`description\` is at most ${MAX_DESCRIPTION} characters.`);
408 const givenRef = typeof a.ref === "string" ? a.ref.trim() : "";
409 const givenSha = typeof a.sha === "string" ? a.sha.trim().toLowerCase() : "";
410 if (!givenRef && !givenSha) return fail("invalid", "Give the `ref` deployed: a branch, a tag or a commit.");
411 let sha = fullSha(givenSha) ? givenSha : "";
412 if (!sha) {
413 const commit = await reposClient(this.env.REPOS).log(a.repo, a.actor, givenSha || givenRef, 1);
414 if (!commit.ok || commit.value.length === 0) {
415 return fail("invalid", `${givenSha || givenRef} is not a branch, tag or commit of ${repo.namespace}/${repo.name}.`);
416 }
417 sha = commit.value[0].hash;
418 }
419 const ref = givenRef ? shortRef(givenRef) : sha;
420 const name = await this.environmentFor(repo.id, environment);
421 const id = newId("dep");
422 const at = now();
423 const production = a.production_environment ?? name.toLowerCase() === "production";
424 await this.db
425 .prepare(
426 `INSERT INTO reported_deployments (id, repo_id, environment, ref, sha, task, description, payload,
427 transient_environment, production_environment, state, environment_url, log_url, creator, source, created_at, updated_at)
428 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'api', ?, ?)`,
429 )
430 .bind(
431 id,
432 repo.id,
433 name,
434 ref,
435 sha,
436 task,
437 description,
438 JSON.stringify(payload.value),
439 a.transient_environment ? 1 : 0,
440 production ? 1 : 0,
441 state,
442 environmentUrl,
443 logUrl,
444 a.actor.username,
445 at,
446 at,
447 )
448 .run();
449 await this.addStatus(repo, id, {
450 state,
451 description,
452 environment_url: environmentUrl,
453 log_url: logUrl,
454 creator: a.actor.username,
455 auto_inactive: true,
456 created: true,
457 actor: a.actor.kind === "system" ? null : a.actor.id,
458 causedByJob: a.actor.token?.job?.run_id ?? null,
459 });
460 return this.detail(repo, id);
461 }
462
463 async createStatus(a: { repo: RepoPath; actor: User; id: string } & NewDeploymentStatus): Promise<Result<DeploymentStatus>> {
464 const found = await this.repoFor(a.repo, a.actor, "push");
465 if (!found.ok) return found;
466 const repo = found.value;
467 const id = String(a.id ?? "");
468 if (id.startsWith("dpl_")) {
469 const build = await this.db.prepare("SELECT id FROM deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first();
470 if (build) return fail("conflict", "That is a g1t.page build: its statuses come from the build itself.");
471 }
472 const exists = await this.db.prepare("SELECT id FROM reported_deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first();
473 if (!exists) return fail("not_found", "There is no such deployment.");
474 if (!isState(a.state)) return fail("invalid", "`state` is queued, in_progress, success, failure, error or inactive.");
475 const environmentUrl = address(a.environment_url, "environment_url");
476 if (environmentUrl && typeof environmentUrl === "object") return fail("invalid", environmentUrl.error);
477 const logUrl = address(a.log_url, "log_url");
478 if (logUrl && typeof logUrl === "object") return fail("invalid", logUrl.error);
479 const description = typeof a.description === "string" && a.description.trim() ? a.description.trim() : null;
480 if (description && description.length > MAX_DESCRIPTION) return fail("invalid", `\`description\` is at most ${MAX_DESCRIPTION} characters.`);
481 const status = await this.addStatus(repo, id, {
482 state: a.state,
483 description,
484 environment_url: environmentUrl,
485 log_url: logUrl,
486 creator: a.actor.username,
487 auto_inactive: a.auto_inactive ?? true,
488 created: false,
489 actor: a.actor.kind === "system" ? null : a.actor.id,
490 causedByJob: a.actor.token?.job?.run_id ?? null,
491 });
492 return ok(status);
493 }
494
495 /**
496 * Records a status: the deployment takes its state and any address it
497 * gives, the commit hears of it, and webhooks are told. A success, with
498 * `auto_inactive`, makes the environment's older successes inactive.
499 */
500 private async addStatus(
501 repo: Pick<Repo, "id" | "namespace" | "name">,
502 deploymentId: string,
503 input: {
504 state: DeploymentState;
505 description: string | null;
506 environment_url: string | null;
507 log_url: string | null;
508 creator: string;
509 auto_inactive: boolean;
510 /** The deployment is new: `deployment.created` is published first. */
511 created: boolean;
512 /** The person who caused it, by id, for the event. */
513 actor: string | null;
514 /**
515 * The workflow run whose job made it, with its token or by deploying
516 * to an environment: its events start no workflows.
517 */
518 causedByJob: string | null;
519 },
520 ): Promise<DeploymentStatus> {
521 const at = now();
522 const status: DeploymentStatus = {
523 id: newId("dst"),
524 deployment_id: deploymentId,
525 state: input.state,
526 description: input.description,
527 environment_url: input.environment_url,
528 log_url: input.log_url,
529 creator: input.creator,
530 created_at: at,
531 };
532 const updated = await this.db.batch([
533 this.db
534 .prepare(
535 `INSERT INTO deployment_statuses (id, deployment_id, state, description, environment_url, log_url, creator, created_at)
536 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
537 )
538 .bind(status.id, deploymentId, status.state, status.description, status.environment_url, status.log_url, status.creator, at),
539 this.db
540 .prepare(
541 `UPDATE reported_deployments SET state = ?, environment_url = COALESCE(?, environment_url),
542 log_url = COALESCE(?, log_url), updated_at = ? WHERE id = ? RETURNING *`,
543 )
544 .bind(status.state, status.environment_url, status.log_url, at, deploymentId),
545 ]);
546 const row = (updated[1].results as ReportedRow[])[0];
547 if (!row) return status;
548 const deployment = toDeployment(row);
549 if (input.state === "success" && input.auto_inactive) await this.retireOlder(repo, deployment);
550 await this.reportOnCommit(repo, deployment, status);
551 const events = [];
552 const caused = input.causedByJob ? { causedByJob: input.causedByJob } : {};
553 if (input.created) {
554 events.push({
555 type: "deployment.created" as const,
556 source: "deployments",
557 repoId: repo.id,
558 actor: input.actor,
559 data: { repoId: repo.id, deployment: withoutPayload(deployment), ...caused },
560 });
561 }
562 events.push({
563 type: "deployment_status.created" as const,
564 source: "deployments",
565 repoId: repo.id,
566 actor: input.actor,
567 data: { repoId: repo.id, deployment: withoutPayload(deployment), deploymentStatus: status, ...caused },
568 });
569 await this.publish(events);
570 return status;
571 }
572
573 /** The environment's older deployments that succeeded are no longer what it serves. */
574 private async retireOlder(repo: Pick<Repo, "id">, deployment: RepoDeployment): Promise<void> {
575 const older = await this.db
576 .prepare(
577 `SELECT id FROM reported_deployments WHERE repo_id = ? AND environment = ? COLLATE NOCASE AND id != ? AND state = 'success'
578 AND created_at <= ?`,
579 )
580 .bind(repo.id, deployment.environment, deployment.id, deployment.created_at)
581 .all<{ id: string }>();
582 if (older.results.length === 0) return;
583 const at = now();
584 const statements = older.results.flatMap((row) => [
585 this.db
586 .prepare(
587 `INSERT INTO deployment_statuses (id, deployment_id, state, description, environment_url, log_url, creator, created_at)
588 VALUES (?, ?, 'inactive', ?, NULL, NULL, 'g1t', ?)`,
589 )
590 .bind(newId("dst"), row.id, `Replaced by ${deployment.id}`, at),
591 this.db.prepare("UPDATE reported_deployments SET state = 'inactive', updated_at = ? WHERE id = ?").bind(at, row.id),
592 ]);
593 await this.db.batch(statements);
594 }
595
596 /** `deploy / <environment>` on the commit, linked to the deployment's page. */
597 private async reportOnCommit(
598 repo: Pick<Repo, "id" | "namespace" | "name">,
599 deployment: RepoDeployment,
600 status: DeploymentStatus,
601 ): Promise<void> {
602 const state = commitState(status.state);
603 if (!state) return;
604 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
605 method: "POST",
606 headers: { "content-type": "application/json" },
607 body: JSON.stringify({
608 repoId: repo.id,
609 sha: deployment.sha,
610 context: statusContext(deployment.environment),
611 state,
612 description: (status.description ?? commitDescription(status.state, deployment.environment)).slice(0, 140),
613 targetUrl: `${this.env.SITE}/${repo.namespace}/${repo.name}/deployments/${deployment.id}`,
614 source: "deployments",
615 }),
616 }).catch((error: unknown) => console.error("deployment status not set on the commit", deployment.id, String(error)));
617 }
618
619 private async publish(events: Parameters<ReturnType<typeof eventsClient>["publish"]>[0]): Promise<void> {
620 if (!this.env.EVENTS || events.length === 0) return;
621 await eventsClient(this.env.EVENTS)
622 .publish(events)
623 .catch((error: unknown) => console.error("deployment events not published", String(error)));
624 }
625
626 // ---- g1t Actions ---------------------------------------------------
627
628 /**
629 * A g1t Actions run's deployment to one environment: made when its first
630 * job naming the environment starts, moved along as jobs fail, and
631 * settled when the run finishes (see `actionsTransition`). One per run,
632 * attempt and environment. For the actions service only.
633 */
634 async fromActions(a: ActionsReport): Promise<Result<{ id: string; state: DeploymentState } | null>> {
635 const environment = environmentName(a.environment);
636 if (typeof environment !== "string") return fail("invalid", environment.error);
637 if (!isState(a.state)) return fail("invalid", "Unknown state.");
638 const url = typeof address(a.url, "url") === "string" ? (a.url as string).trim() : null;
639 const repo = { id: a.repoId, namespace: a.repo.namespace, name: a.repo.name };
640 const existing = await this.db
641 .prepare("SELECT * FROM reported_deployments WHERE run_id = ? AND run_attempt = ? AND environment = ? COLLATE NOCASE")
642 .bind(a.runId, a.attempt, environment)
643 .first<ReportedRow>();
644 const creator = a.creator || "g1t";
645 const description = (state: DeploymentState) =>
646 state === "in_progress"
647 ? `${a.workflow} is deploying`
648 : state === "success"
649 ? `${a.workflow} deployed`
650 : state === "failure"
651 ? `${a.workflow} failed`
652 : state === "error"
653 ? `${a.workflow} was cancelled`
654 : stateDescription(state);
655 if (!existing) {
656 // A run that finished without deploying (every job that names the
657 // environment skipped) has nothing to report.
658 if (a.final && a.state !== "success" && a.state !== "failure" && a.state !== "error") return ok(null);
659 const name = await this.environmentFor(a.repoId, environment);
660 const id = newId("dep");
661 const at = now();
662 const inserted = await this.db
663 .prepare(
664 `INSERT INTO reported_deployments (id, repo_id, environment, ref, sha, task, description, payload,
665 transient_environment, production_environment, state, environment_url, log_url, creator, source,
666 run_id, run_attempt, run_url, created_at, updated_at)
667 VALUES (?, ?, ?, ?, ?, 'deploy', ?, '{}', 0, ?, ?, ?, ?, ?, 'actions', ?, ?, ?, ?, ?)
668 ON CONFLICT DO NOTHING RETURNING id`,
669 )
670 .bind(
671 id,
672 a.repoId,
673 name,
674 shortRef(a.ref),
675 a.sha,
676 `${a.workflow}`,
677 name.toLowerCase() === "production" ? 1 : 0,
678 a.state,
679 null,
680 a.runUrl,
681 creator,
682 a.runId,
683 a.attempt,
684 a.runUrl,
685 at,
686 at,
687 )
688 .first<{ id: string }>();
689 // Two jobs starting at once: the other made it; this one moves it along.
690 if (!inserted) return this.fromActions(a);
691 await this.addStatus(repo, id, {
692 state: a.state,
693 description: description(a.state),
694 environment_url: a.state === "success" || a.state === "in_progress" ? url : null,
695 log_url: a.runUrl,
696 creator,
697 auto_inactive: true,
698 created: true,
699 actor: null,
700 causedByJob: a.runId,
701 });
702 return ok({ id, state: a.state });
703 }
704 const next = actionsTransition(existing.state, a.state, a.final);
705 if (!next) return ok({ id: existing.id, state: existing.state });
706 await this.addStatus(repo, existing.id, {
707 state: next,
708 description: description(next),
709 environment_url: next === "success" || next === "in_progress" ? url : null,
710 log_url: a.runUrl,
711 creator,
712 auto_inactive: true,
713 created: false,
714 actor: null,
715 causedByJob: a.runId,
716 });
717 return ok({ id: existing.id, state: next });
718 }
719
720 // ---- g1t.page builds -----------------------------------------------
721
722 /**
723 * Publishes a g1t.page build's change as the same events a reported
724 * deployment's are: `deployment.created` when it is queued, then a
725 * `deployment_status.created` for each state it reaches.
726 */
727 async buildChanged(row: BuildRow, defaultBranch: string, created: boolean): Promise<void> {
728 if (!this.env.EVENTS) return;
729 const deployment = fromBuild(row, defaultBranch, this.env.SITE, appUrl, await this.names([row]));
730 const statuses = buildStatuses(row, deployment);
731 const status = statuses[statuses.length - 1];
732 const actor = row.created_by.startsWith("usr_") ? row.created_by : null;
733 const data = { repoId: row.repo_id, deployment: withoutPayload(deployment) };
734 await this.publish([
735 ...(created ? [{ type: "deployment.created" as const, source: "deployments", repoId: row.repo_id, actor, data }] : []),
736 {
737 type: "deployment_status.created" as const,
738 source: "deployments",
739 repoId: row.repo_id,
740 actor,
741 data: { ...data, deploymentStatus: status },
742 },
743 ]);
744 }
745
746 /** A purged repository's reported deployments go with it. */
747 async purge(repoId: string): Promise<void> {
748 await this.db.batch([
749 this.db.prepare(
750 "DELETE FROM deployment_statuses WHERE deployment_id IN (SELECT id FROM reported_deployments WHERE repo_id = ?)",
751 ).bind(repoId),
752 this.db.prepare("DELETE FROM reported_deployments WHERE repo_id = ?").bind(repoId),
753 this.db.prepare("DELETE FROM environments WHERE repo_id = ?").bind(repoId),
754 ]);
755 }
756}
757
758/** Which source a filter names, if it is one. */
759export function sourceOf(value: unknown): DeploymentSource | null {
760 return value === "api" || value === "actions" || value === "g1t_page" ? value : null;
761}