Skip to content

g1t/services/deployments/src/repo-deployments.ts

745 lines30,771 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971/**
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 });
459 return this.detail(repo, id);
460 }
461
462 async createStatus(a: { repo: RepoPath; actor: User; id: string } & NewDeploymentStatus): Promise<Result<DeploymentStatus>> {
463 const found = await this.repoFor(a.repo, a.actor, "push");
464 if (!found.ok) return found;
465 const repo = found.value;
466 const id = String(a.id ?? "");
467 if (id.startsWith("dpl_")) {
468 const build = await this.db.prepare("SELECT id FROM deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first();
469 if (build) return fail("conflict", "That is a g1t.page build: its statuses come from the build itself.");
470 }
471 const exists = await this.db.prepare("SELECT id FROM reported_deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first();
472 if (!exists) return fail("not_found", "There is no such deployment.");
473 if (!isState(a.state)) return fail("invalid", "`state` is queued, in_progress, success, failure, error or inactive.");
474 const environmentUrl = address(a.environment_url, "environment_url");
475 if (environmentUrl && typeof environmentUrl === "object") return fail("invalid", environmentUrl.error);
476 const logUrl = address(a.log_url, "log_url");
477 if (logUrl && typeof logUrl === "object") return fail("invalid", logUrl.error);
478 const description = typeof a.description === "string" && a.description.trim() ? a.description.trim() : null;
479 if (description && description.length > MAX_DESCRIPTION) return fail("invalid", `\`description\` is at most ${MAX_DESCRIPTION} characters.`);
480 const status = await this.addStatus(repo, id, {
481 state: a.state,
482 description,
483 environment_url: environmentUrl,
484 log_url: logUrl,
485 creator: a.actor.username,
486 auto_inactive: a.auto_inactive ?? true,
487 created: false,
488 actor: a.actor.kind === "system" ? null : a.actor.id,
489 });
490 return ok(status);
491 }
492
493 /**
494 * Records a status: the deployment takes its state and any address it
495 * gives, the commit hears of it, and webhooks are told. A success, with
496 * `auto_inactive`, makes the environment's older successes inactive.
497 */
498 private async addStatus(
499 repo: Pick<Repo, "id" | "namespace" | "name">,
500 deploymentId: string,
501 input: {
502 state: DeploymentState;
503 description: string | null;
504 environment_url: string | null;
505 log_url: string | null;
506 creator: string;
507 auto_inactive: boolean;
508 /** The deployment is new: `deployment.created` is published first. */
509 created: boolean;
510 /** The person who caused it, by id, for the event. */
511 actor: string | null;
512 },
513 ): Promise<DeploymentStatus> {
514 const at = now();
515 const status: DeploymentStatus = {
516 id: newId("dst"),
517 deployment_id: deploymentId,
518 state: input.state,
519 description: input.description,
520 environment_url: input.environment_url,
521 log_url: input.log_url,
522 creator: input.creator,
523 created_at: at,
524 };
525 const updated = await this.db.batch([
526 this.db
527 .prepare(
528 `INSERT INTO deployment_statuses (id, deployment_id, state, description, environment_url, log_url, creator, created_at)
529 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
530 )
531 .bind(status.id, deploymentId, status.state, status.description, status.environment_url, status.log_url, status.creator, at),
532 this.db
533 .prepare(
534 `UPDATE reported_deployments SET state = ?, environment_url = COALESCE(?, environment_url),
535 log_url = COALESCE(?, log_url), updated_at = ? WHERE id = ? RETURNING *`,
536 )
537 .bind(status.state, status.environment_url, status.log_url, at, deploymentId),
538 ]);
539 const row = (updated[1].results as ReportedRow[])[0];
540 if (!row) return status;
541 const deployment = toDeployment(row);
542 if (input.state === "success" && input.auto_inactive) await this.retireOlder(repo, deployment);
543 await this.reportOnCommit(repo, deployment, status);
544 const events = [];
545 if (input.created) {
546 events.push({ type: "deployment.created" as const, source: "deployments", repoId: repo.id, actor: input.actor, data: { repoId: repo.id, deployment: withoutPayload(deployment) } });
547 }
548 events.push({
549 type: "deployment_status.created" as const,
550 source: "deployments",
551 repoId: repo.id,
552 actor: input.actor,
553 data: { repoId: repo.id, deployment: withoutPayload(deployment), deploymentStatus: status },
554 });
555 await this.publish(events);
556 return status;
557 }
558
559 /** The environment's older deployments that succeeded are no longer what it serves. */
560 private async retireOlder(repo: Pick<Repo, "id">, deployment: RepoDeployment): Promise<void> {
561 const older = await this.db
562 .prepare(
563 `SELECT id FROM reported_deployments WHERE repo_id = ? AND environment = ? COLLATE NOCASE AND id != ? AND state = 'success'
564 AND created_at <= ?`,
565 )
566 .bind(repo.id, deployment.environment, deployment.id, deployment.created_at)
567 .all<{ id: string }>();
568 if (older.results.length === 0) return;
569 const at = now();
570 const statements = older.results.flatMap((row) => [
571 this.db
572 .prepare(
573 `INSERT INTO deployment_statuses (id, deployment_id, state, description, environment_url, log_url, creator, created_at)
574 VALUES (?, ?, 'inactive', ?, NULL, NULL, 'g1t', ?)`,
575 )
576 .bind(newId("dst"), row.id, `Replaced by ${deployment.id}`, at),
577 this.db.prepare("UPDATE reported_deployments SET state = 'inactive', updated_at = ? WHERE id = ?").bind(at, row.id),
578 ]);
579 await this.db.batch(statements);
580 }
581
582 /** `deploy / <environment>` on the commit, linked to the deployment's page. */
583 private async reportOnCommit(
584 repo: Pick<Repo, "id" | "namespace" | "name">,
585 deployment: RepoDeployment,
586 status: DeploymentStatus,
587 ): Promise<void> {
588 const state = commitState(status.state);
589 if (!state) return;
590 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
591 method: "POST",
592 headers: { "content-type": "application/json" },
593 body: JSON.stringify({
594 repoId: repo.id,
595 sha: deployment.sha,
596 context: statusContext(deployment.environment),
597 state,
598 description: (status.description ?? commitDescription(status.state, deployment.environment)).slice(0, 140),
599 targetUrl: `${this.env.SITE}/${repo.namespace}/${repo.name}/deployments/${deployment.id}`,
600 source: "deployments",
601 }),
602 }).catch((error: unknown) => console.error("deployment status not set on the commit", deployment.id, String(error)));
603 }
604
605 private async publish(events: Parameters<ReturnType<typeof eventsClient>["publish"]>[0]): Promise<void> {
606 if (!this.env.EVENTS || events.length === 0) return;
607 await eventsClient(this.env.EVENTS)
608 .publish(events)
609 .catch((error: unknown) => console.error("deployment events not published", String(error)));
610 }
611
612 // ---- g1t Actions ---------------------------------------------------
613
614 /**
615 * A g1t Actions run's deployment to one environment: made when its first
616 * job naming the environment starts, moved along as jobs fail, and
617 * settled when the run finishes (see `actionsTransition`). One per run,
618 * attempt and environment. For the actions service only.
619 */
620 async fromActions(a: ActionsReport): Promise<Result<{ id: string; state: DeploymentState } | null>> {
621 const environment = environmentName(a.environment);
622 if (typeof environment !== "string") return fail("invalid", environment.error);
623 if (!isState(a.state)) return fail("invalid", "Unknown state.");
624 const url = typeof address(a.url, "url") === "string" ? (a.url as string).trim() : null;
625 const repo = { id: a.repoId, namespace: a.repo.namespace, name: a.repo.name };
626 const existing = await this.db
627 .prepare("SELECT * FROM reported_deployments WHERE run_id = ? AND run_attempt = ? AND environment = ? COLLATE NOCASE")
628 .bind(a.runId, a.attempt, environment)
629 .first<ReportedRow>();
630 const creator = a.creator || "g1t";
631 const description = (state: DeploymentState) =>
632 state === "in_progress"
633 ? `${a.workflow} is deploying`
634 : state === "success"
635 ? `${a.workflow} deployed`
636 : state === "failure"
637 ? `${a.workflow} failed`
638 : state === "error"
639 ? `${a.workflow} was cancelled`
640 : stateDescription(state);
641 if (!existing) {
642 // A run that finished without deploying (every job that names the
643 // environment skipped) has nothing to report.
644 if (a.final && a.state !== "success" && a.state !== "failure" && a.state !== "error") return ok(null);
645 const name = await this.environmentFor(a.repoId, environment);
646 const id = newId("dep");
647 const at = now();
648 const inserted = await this.db
649 .prepare(
650 `INSERT INTO reported_deployments (id, repo_id, environment, ref, sha, task, description, payload,
651 transient_environment, production_environment, state, environment_url, log_url, creator, source,
652 run_id, run_attempt, run_url, created_at, updated_at)
653 VALUES (?, ?, ?, ?, ?, 'deploy', ?, '{}', 0, ?, ?, ?, ?, ?, 'actions', ?, ?, ?, ?, ?)
654 ON CONFLICT DO NOTHING RETURNING id`,
655 )
656 .bind(
657 id,
658 a.repoId,
659 name,
660 shortRef(a.ref),
661 a.sha,
662 `${a.workflow}`,
663 name.toLowerCase() === "production" ? 1 : 0,
664 a.state,
665 null,
666 a.runUrl,
667 creator,
668 a.runId,
669 a.attempt,
670 a.runUrl,
671 at,
672 at,
673 )
674 .first<{ id: string }>();
675 // Two jobs starting at once: the other made it; this one moves it along.
676 if (!inserted) return this.fromActions(a);
677 await this.addStatus(repo, id, {
678 state: a.state,
679 description: description(a.state),
680 environment_url: a.state === "success" || a.state === "in_progress" ? url : null,
681 log_url: a.runUrl,
682 creator,
683 auto_inactive: true,
684 created: true,
685 actor: null,
686 });
687 return ok({ id, state: a.state });
688 }
689 const next = actionsTransition(existing.state, a.state, a.final);
690 if (!next) return ok({ id: existing.id, state: existing.state });
691 await this.addStatus(repo, existing.id, {
692 state: next,
693 description: description(next),
694 environment_url: next === "success" || next === "in_progress" ? url : null,
695 log_url: a.runUrl,
696 creator,
697 auto_inactive: true,
698 created: false,
699 actor: null,
700 });
701 return ok({ id: existing.id, state: next });
702 }
703
704 // ---- g1t.page builds -----------------------------------------------
705
706 /**
707 * Publishes a g1t.page build's change as the same events a reported
708 * deployment's are: `deployment.created` when it is queued, then a
709 * `deployment_status.created` for each state it reaches.
710 */
711 async buildChanged(row: BuildRow, defaultBranch: string, created: boolean): Promise<void> {
712 if (!this.env.EVENTS) return;
713 const deployment = fromBuild(row, defaultBranch, this.env.SITE, appUrl, await this.names([row]));
714 const statuses = buildStatuses(row, deployment);
715 const status = statuses[statuses.length - 1];
716 const actor = row.created_by.startsWith("usr_") ? row.created_by : null;
717 const data = { repoId: row.repo_id, deployment: withoutPayload(deployment) };
718 await this.publish([
719 ...(created ? [{ type: "deployment.created" as const, source: "deployments", repoId: row.repo_id, actor, data }] : []),
720 {
721 type: "deployment_status.created" as const,
722 source: "deployments",
723 repoId: row.repo_id,
724 actor,
725 data: { ...data, deploymentStatus: status },
726 },
727 ]);
728 }
729
730 /** A purged repository's reported deployments go with it. */
731 async purge(repoId: string): Promise<void> {
732 await this.db.batch([
733 this.db.prepare(
734 "DELETE FROM deployment_statuses WHERE deployment_id IN (SELECT id FROM reported_deployments WHERE repo_id = ?)",
735 ).bind(repoId),
736 this.db.prepare("DELETE FROM reported_deployments WHERE repo_id = ?").bind(repoId),
737 this.db.prepare("DELETE FROM environments WHERE repo_id = ?").bind(repoId),
738 ]);
739 }
740}
741
742/** Which source a filter names, if it is one. */
743export function sourceOf(value: unknown): DeploymentSource | null {
744 return value === "api" || value === "actions" || value === "g1t_page" ? value : null;
745}

This file's history is long; its oldest lines are credited to the oldest commit read.