Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

758 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
226 .prepare(`SELECT * FROM reported_deployments WHERE id IN (${marks(reportedIds)})`)
227 .bind(...reportedIds)
228 .all<ReportedRow>()
229 : Promise.resolve({ results: [] as ReportedRow[] }),
230 buildIds.length
231 ? this.db
232 .prepare(`SELECT * FROM deployments WHERE id IN (${marks(buildIds)})`)
233 .bind(...buildIds)
234 .all<BuildRow>()
235 : Promise.resolve({ results: [] as BuildRow[] }),
236 ]);
237 const names = await this.names(builds.results);
238 const byId = new Map<string, RepoDeployment>();
239 for (const row of reported.results) byId.set(row.id, toDeployment(row));
240 for (const row of builds.results) byId.set(row.id, fromBuild(row, repo.defaultBranch, this.env.SITE, appUrl, names));
241 return listed.map((row) => byId.get(row.id)).filter((found): found is RepoDeployment => !!found);
242 }
243
244 // ---- Reading -------------------------------------------------------
245
246 async list(a: { repo: RepoPath; viewer: Viewer } & DeploymentFilter): Promise<Result<DeploymentPage>> {
247 const found = await this.repoFor(a.repo, a.viewer, "read");
248 if (!found.ok) return found;
249 const repo = found.value;
250 const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Math.floor(Number(a.per_page) || PER_PAGE)));
251 const page = Math.max(1, Math.floor(Number(a.page) || 1));
252 if (a.state != null && !isState(a.state)) return fail("invalid", "`state` is queued, in_progress, success, failure, error or inactive.");
253 const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null);
254 const sha = text(a.sha)?.toLowerCase() ?? null;
255 const where = `WHERE (?3 IS NULL OR environment = ?3 COLLATE NOCASE)
256 AND (?4 IS NULL OR ref = ?4) AND (?5 IS NULL OR sha LIKE ?5 || '%') AND (?6 IS NULL OR task = ?6)
257 AND (?7 IS NULL OR state = ?7) AND (?8 IS NULL OR source = ?8) AND (?9 IS NULL OR creator = ?9 COLLATE NOCASE)`;
258 const binds = [
259 repo.id,
260 repo.defaultBranch,
261 text(a.environment),
262 text(a.ref) ? shortRef(text(a.ref)!) : null,
263 sha,
264 text(a.task),
265 a.state ?? null,
266 text(a.source),
267 text(a.creator),
268 ];
269 const [rows, count] = await Promise.all([
270 this.db
271 .prepare(`WITH d AS (${UNION}) SELECT id, build FROM d ${where} ORDER BY created_at DESC, id DESC LIMIT ?10 OFFSET ?11`)
272 .bind(...binds, perPage, (page - 1) * perPage)
273 .all<Listed>(),
274 this.db
275 .prepare(`WITH d AS (${UNION}) SELECT COUNT(*) AS n FROM d ${where}`)
276 .bind(...binds)
277 .first<{ n: number }>(),
278 ]);
279 return ok({
280 deployments: await this.hydrate(rows.results, repo),
281 total_count: count?.n ?? 0,
282 page,
283 per_page: perPage,
284 });
285 }
286
287 async get(a: { repo: RepoPath; id: string; viewer: Viewer }): Promise<Result<DeploymentDetail>> {
288 const found = await this.repoFor(a.repo, a.viewer, "read");
289 if (!found.ok) return found;
290 return this.detail(found.value, String(a.id ?? ""));
291 }
292
293 private async detail(repo: Repo, id: string): Promise<Result<DeploymentDetail>> {
294 if (id.startsWith("dpl_")) {
295 const row = await this.db.prepare("SELECT * FROM deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first<BuildRow>();
296 if (!row) return fail("not_found", "There is no such deployment.");
297 const deployment = fromBuild(row, repo.defaultBranch, this.env.SITE, appUrl, await this.names([row]));
298 return ok({ ...deployment, statuses: buildStatuses(row, deployment) });
299 }
300 const row = await this.db.prepare("SELECT * FROM reported_deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first<ReportedRow>();
301 if (!row) return fail("not_found", "There is no such deployment.");
302 const statuses = await this.db.prepare("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY created_at, id").bind(id).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(
370 (env) =>
371 env.name.toLowerCase() ===
372 String(a.name ?? "")
373 .trim()
374 .toLowerCase(),
375 );
376 return found ? ok(found) : fail("not_found", `There is no environment called ${a.name}.`);
377 }
378
379 // ---- Reporting -----------------------------------------------------
380
381 /** The environment's name as first spelled, made on its first deployment. */
382 private async environmentFor(repoId: string, name: string): Promise<string> {
383 await this.db
384 .prepare("INSERT INTO environments (repo_id, name, created_at) VALUES (?, ?, ?) ON CONFLICT (repo_id, name) DO NOTHING")
385 .bind(repoId, name, now())
386 .run();
387 const row = await this.db.prepare("SELECT name FROM environments WHERE repo_id = ? AND name = ?").bind(repoId, name).first<{ name: string }>();
388 // A g1t.page environment is spelled as g1t.page spells it.
389 const builtIn = ["production", "preview"].find((own) => own === name.toLowerCase());
390 return builtIn ?? row?.name ?? name;
391 }
392
393 async create(a: { repo: RepoPath; actor: User } & NewDeployment): Promise<Result<DeploymentDetail>> {
394 const found = await this.repoFor(a.repo, a.actor, "push");
395 if (!found.ok) return found;
396 const repo = found.value;
397 const environment = environmentName(a.environment);
398 if (typeof environment !== "string") return fail("invalid", environment.error);
399 const payload = payloadOf(a.payload);
400 if ("error" in payload) return fail("invalid", payload.error);
401 const environmentUrl = address(a.environment_url, "environment_url");
402 if (environmentUrl && typeof environmentUrl === "object") return fail("invalid", environmentUrl.error);
403 const logUrl = address(a.log_url, "log_url");
404 if (logUrl && typeof logUrl === "object") return fail("invalid", logUrl.error);
405 const state = a.state ?? "queued";
406 if (!isState(state)) return fail("invalid", "`state` is queued, in_progress, success, failure, error or inactive.");
407 const task = typeof a.task === "string" && a.task.trim() ? a.task.trim() : "deploy";
408 if (task.length > MAX_TASK) return fail("invalid", `\`task\` is at most ${MAX_TASK} characters.`);
409 const description = typeof a.description === "string" && a.description.trim() ? a.description.trim() : null;
410 if (description && description.length > MAX_DESCRIPTION) return fail("invalid", `\`description\` is at most ${MAX_DESCRIPTION} characters.`);
411 const givenRef = typeof a.ref === "string" ? a.ref.trim() : "";
412 const givenSha = typeof a.sha === "string" ? a.sha.trim().toLowerCase() : "";
413 if (!givenRef && !givenSha) return fail("invalid", "Give the `ref` deployed: a branch, a tag or a commit.");
414 let sha = fullSha(givenSha) ? givenSha : "";
415 if (!sha) {
416 const commit = await reposClient(this.env.REPOS).log(a.repo, a.actor, givenSha || givenRef, 1);
417 if (!commit.ok || commit.value.length === 0) {
418 return fail("invalid", `${givenSha || givenRef} is not a branch, tag or commit of ${repo.namespace}/${repo.name}.`);
419 }
420 sha = commit.value[0].hash;
421 }
422 const ref = givenRef ? shortRef(givenRef) : sha;
423 const name = await this.environmentFor(repo.id, environment);
424 const id = newId("dep");
425 const at = now();
426 const production = a.production_environment ?? name.toLowerCase() === "production";
427 await this.db
428 .prepare(
429 `INSERT INTO reported_deployments (id, repo_id, environment, ref, sha, task, description, payload,
430 transient_environment, production_environment, state, environment_url, log_url, creator, source, created_at, updated_at)
431 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'api', ?, ?)`,
432 )
433 .bind(
434 id,
435 repo.id,
436 name,
437 ref,
438 sha,
439 task,
440 description,
441 JSON.stringify(payload.value),
442 a.transient_environment ? 1 : 0,
443 production ? 1 : 0,
444 state,
445 environmentUrl,
446 logUrl,
447 a.actor.username,
448 at,
449 at,
450 )
451 .run();
452 await this.addStatus(repo, id, {
453 state,
454 description,
455 environment_url: environmentUrl,
456 log_url: logUrl,
457 creator: a.actor.username,
458 auto_inactive: true,
459 created: true,
460 actor: a.actor.kind === "system" ? null : a.actor.id,
461 causedByJob: a.actor.token?.job?.run_id ?? null,
462 });
463 return this.detail(repo, id);
464 }
465
466 async createStatus(a: { repo: RepoPath; actor: User; id: string } & NewDeploymentStatus): Promise<Result<DeploymentStatus>> {
467 const found = await this.repoFor(a.repo, a.actor, "push");
468 if (!found.ok) return found;
469 const repo = found.value;
470 const id = String(a.id ?? "");
471 if (id.startsWith("dpl_")) {
472 const build = await this.db.prepare("SELECT id FROM deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first();
473 if (build) return fail("conflict", "That is a g1t.page build: its statuses come from the build itself.");
474 }
475 const exists = await this.db.prepare("SELECT id FROM reported_deployments WHERE id = ? AND repo_id = ?").bind(id, repo.id).first();
476 if (!exists) return fail("not_found", "There is no such deployment.");
477 if (!isState(a.state)) return fail("invalid", "`state` is queued, in_progress, success, failure, error or inactive.");
478 const environmentUrl = address(a.environment_url, "environment_url");
479 if (environmentUrl && typeof environmentUrl === "object") return fail("invalid", environmentUrl.error);
480 const logUrl = address(a.log_url, "log_url");
481 if (logUrl && typeof logUrl === "object") return fail("invalid", logUrl.error);
482 const description = typeof a.description === "string" && a.description.trim() ? a.description.trim() : null;
483 if (description && description.length > MAX_DESCRIPTION) return fail("invalid", `\`description\` is at most ${MAX_DESCRIPTION} characters.`);
484 const status = await this.addStatus(repo, id, {
485 state: a.state,
486 description,
487 environment_url: environmentUrl,
488 log_url: logUrl,
489 creator: a.actor.username,
490 auto_inactive: a.auto_inactive ?? true,
491 created: false,
492 actor: a.actor.kind === "system" ? null : a.actor.id,
493 causedByJob: a.actor.token?.job?.run_id ?? null,
494 });
495 return ok(status);
496 }
497
498 /**
499 * Records a status: the deployment takes its state and any address it
500 * gives, the commit hears of it, and webhooks are told. A success, with
501 * `auto_inactive`, makes the environment's older successes inactive.
502 */
503 private async addStatus(
504 repo: Pick<Repo, "id" | "namespace" | "name">,
505 deploymentId: string,
506 input: {
507 state: DeploymentState;
508 description: string | null;
509 environment_url: string | null;
510 log_url: string | null;
511 creator: string;
512 auto_inactive: boolean;
513 /** The deployment is new: `deployment.created` is published first. */
514 created: boolean;
515 /** The person who caused it, by id, for the event. */
516 actor: string | null;
517 /**
518 * The workflow run whose job made it, with its token or by deploying
519 * to an environment: its events start no workflows.
520 */
521 causedByJob: string | null;
522 },
523 ): Promise<DeploymentStatus> {
524 const at = now();
525 const status: DeploymentStatus = {
526 id: newId("dst"),
527 deployment_id: deploymentId,
528 state: input.state,
529 description: input.description,
530 environment_url: input.environment_url,
531 log_url: input.log_url,
532 creator: input.creator,
533 created_at: at,
534 };
535 const updated = await this.db.batch([
536 this.db
537 .prepare(
538 `INSERT INTO deployment_statuses (id, deployment_id, state, description, environment_url, log_url, creator, created_at)
539 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
540 )
541 .bind(status.id, deploymentId, status.state, status.description, status.environment_url, status.log_url, status.creator, at),
542 this.db
543 .prepare(
544 `UPDATE reported_deployments SET state = ?, environment_url = COALESCE(?, environment_url),
545 log_url = COALESCE(?, log_url), updated_at = ? WHERE id = ? RETURNING *`,
546 )
547 .bind(status.state, status.environment_url, status.log_url, at, deploymentId),
548 ]);
549 const row = (updated[1].results as ReportedRow[])[0];
550 if (!row) return status;
551 const deployment = toDeployment(row);
552 if (input.state === "success" && input.auto_inactive) await this.retireOlder(repo, deployment);
553 await this.reportOnCommit(repo, deployment, status);
554 const events = [];
555 const caused = input.causedByJob ? { causedByJob: input.causedByJob } : {};
556 if (input.created) {
557 events.push({
558 type: "deployment.created" as const,
559 source: "deployments",
560 repoId: repo.id,
561 actor: input.actor,
562 data: { repoId: repo.id, deployment: withoutPayload(deployment), ...caused },
563 });
564 }
565 events.push({
566 type: "deployment_status.created" as const,
567 source: "deployments",
568 repoId: repo.id,
569 actor: input.actor,
570 data: { repoId: repo.id, deployment: withoutPayload(deployment), deploymentStatus: status, ...caused },
571 });
572 await this.publish(events);
573 return status;
574 }
575
576 /** The environment's older deployments that succeeded are no longer what it serves. */
577 private async retireOlder(repo: Pick<Repo, "id">, deployment: RepoDeployment): Promise<void> {
578 const older = await this.db
579 .prepare(
580 `SELECT id FROM reported_deployments WHERE repo_id = ? AND environment = ? COLLATE NOCASE AND id != ? AND state = 'success'
581 AND created_at <= ?`,
582 )
583 .bind(repo.id, deployment.environment, deployment.id, deployment.created_at)
584 .all<{ id: string }>();
585 if (older.results.length === 0) return;
586 const at = now();
587 const statements = older.results.flatMap((row) => [
588 this.db
589 .prepare(
590 `INSERT INTO deployment_statuses (id, deployment_id, state, description, environment_url, log_url, creator, created_at)
591 VALUES (?, ?, 'inactive', ?, NULL, NULL, 'g1t', ?)`,
592 )
593 .bind(newId("dst"), row.id, `Replaced by ${deployment.id}`, at),
594 this.db.prepare("UPDATE reported_deployments SET state = 'inactive', updated_at = ? WHERE id = ?").bind(at, row.id),
595 ]);
596 await this.db.batch(statements);
597 }
598
599 /** `deploy / <environment>` on the commit, linked to the deployment's page. */
600 private async reportOnCommit(repo: Pick<Repo, "id" | "namespace" | "name">, deployment: RepoDeployment, status: DeploymentStatus): Promise<void> {
601 const state = commitState(status.state);
602 if (!state) return;
603 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
604 method: "POST",
605 headers: { "content-type": "application/json" },
606 body: JSON.stringify({
607 repoId: repo.id,
608 sha: deployment.sha,
609 context: statusContext(deployment.environment),
610 state,
611 description: (status.description ?? commitDescription(status.state, deployment.environment)).slice(0, 140),
612 targetUrl: `${this.env.SITE}/${repo.namespace}/${repo.name}/deployments/${deployment.id}`,
613 source: "deployments",
614 }),
615 }).catch((error: unknown) => console.error("deployment status not set on the commit", deployment.id, String(error)));
616 }
617
618 private async publish(events: Parameters<ReturnType<typeof eventsClient>["publish"]>[0]): Promise<void> {
619 if (!this.env.EVENTS || events.length === 0) return;
620 await eventsClient(this.env.EVENTS)
621 .publish(events)
622 .catch((error: unknown) => console.error("deployment events not published", String(error)));
623 }
624
625 // ---- g1t Actions ---------------------------------------------------
626
627 /**
628 * A g1t Actions run's deployment to one environment: made when its first
629 * job naming the environment starts, moved along as jobs fail, and
630 * settled when the run finishes (see `actionsTransition`). One per run,
631 * attempt and environment. For the actions service only.
632 */
633 async fromActions(a: ActionsReport): Promise<Result<{ id: string; state: DeploymentState } | null>> {
634 const environment = environmentName(a.environment);
635 if (typeof environment !== "string") return fail("invalid", environment.error);
636 if (!isState(a.state)) return fail("invalid", "Unknown state.");
637 const url = typeof address(a.url, "url") === "string" ? (a.url as string).trim() : null;
638 const repo = { id: a.repoId, namespace: a.repo.namespace, name: a.repo.name };
639 const existing = await this.db
640 .prepare("SELECT * FROM reported_deployments WHERE run_id = ? AND run_attempt = ? AND environment = ? COLLATE NOCASE")
641 .bind(a.runId, a.attempt, environment)
642 .first<ReportedRow>();
643 const creator = a.creator || "g1t";
644 const description = (state: DeploymentState) =>
645 state === "in_progress"
646 ? `${a.workflow} is deploying`
647 : state === "success"
648 ? `${a.workflow} deployed`
649 : state === "failure"
650 ? `${a.workflow} failed`
651 : state === "error"
652 ? `${a.workflow} was cancelled`
653 : stateDescription(state);
654 if (!existing) {
655 // A run that finished without deploying (every job that names the
656 // environment skipped) has nothing to report.
657 if (a.final && a.state !== "success" && a.state !== "failure" && a.state !== "error") return ok(null);
658 const name = await this.environmentFor(a.repoId, environment);
659 const id = newId("dep");
660 const at = now();
661 const inserted = await this.db
662 .prepare(
663 `INSERT INTO reported_deployments (id, repo_id, environment, ref, sha, task, description, payload,
664 transient_environment, production_environment, state, environment_url, log_url, creator, source,
665 run_id, run_attempt, run_url, created_at, updated_at)
666 VALUES (?, ?, ?, ?, ?, 'deploy', ?, '{}', 0, ?, ?, ?, ?, ?, 'actions', ?, ?, ?, ?, ?)
667 ON CONFLICT DO NOTHING RETURNING id`,
668 )
669 .bind(
670 id,
671 a.repoId,
672 name,
673 shortRef(a.ref),
674 a.sha,
675 `${a.workflow}`,
676 name.toLowerCase() === "production" ? 1 : 0,
677 a.state,
678 null,
679 a.runUrl,
680 creator,
681 a.runId,
682 a.attempt,
683 a.runUrl,
684 at,
685 at,
686 )
687 .first<{ id: string }>();
688 // Two jobs starting at once: the other made it; this one moves it along.
689 if (!inserted) return this.fromActions(a);
690 await this.addStatus(repo, id, {
691 state: a.state,
692 description: description(a.state),
693 environment_url: a.state === "success" || a.state === "in_progress" ? url : null,
694 log_url: a.runUrl,
695 creator,
696 auto_inactive: true,
697 created: true,
698 actor: null,
699 causedByJob: a.runId,
700 });
701 return ok({ id, state: a.state });
702 }
703 const next = actionsTransition(existing.state, a.state, a.final);
704 if (!next) return ok({ id: existing.id, state: existing.state });
705 await this.addStatus(repo, existing.id, {
706 state: next,
707 description: description(next),
708 environment_url: next === "success" || next === "in_progress" ? url : null,
709 log_url: a.runUrl,
710 creator,
711 auto_inactive: true,
712 created: false,
713 actor: null,
714 causedByJob: a.runId,
715 });
716 return ok({ id: existing.id, state: next });
717 }
718
719 // ---- g1t.page builds -----------------------------------------------
720
721 /**
722 * Publishes a g1t.page build's change as the same events a reported
723 * deployment's are: `deployment.created` when it is queued, then a
724 * `deployment_status.created` for each state it reaches.
725 */
726 async buildChanged(row: BuildRow, defaultBranch: string, created: boolean): Promise<void> {
727 if (!this.env.EVENTS) return;
728 const deployment = fromBuild(row, defaultBranch, this.env.SITE, appUrl, await this.names([row]));
729 const statuses = buildStatuses(row, deployment);
730 const status = statuses[statuses.length - 1];
731 const actor = row.created_by.startsWith("usr_") ? row.created_by : null;
732 const data = { repoId: row.repo_id, deployment: withoutPayload(deployment) };
733 await this.publish([
734 ...(created ? [{ type: "deployment.created" as const, source: "deployments", repoId: row.repo_id, actor, data }] : []),
735 {
736 type: "deployment_status.created" as const,
737 source: "deployments",
738 repoId: row.repo_id,
739 actor,
740 data: { ...data, deploymentStatus: status },
741 },
742 ]);
743 }
744
745 /** A purged repository's reported deployments go with it. */
746 async purge(repoId: string): Promise<void> {
747 await this.db.batch([
748 this.db.prepare("DELETE FROM deployment_statuses WHERE deployment_id IN (SELECT id FROM reported_deployments WHERE repo_id = ?)").bind(repoId),
749 this.db.prepare("DELETE FROM reported_deployments WHERE repo_id = ?").bind(repoId),
750 this.db.prepare("DELETE FROM environments WHERE repo_id = ?").bind(repoId),
751 ]);
752 }
753}
754
755/** Which source a filter names, if it is one. */
756export function sourceOf(value: unknown): DeploymentSource | null {
757 return value === "api" || value === "actions" || value === "g1t_page" ? value : null;
758}