Skip to content

g1t/services/deployments/src/environments.ts

309 lines12,125 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 main: Deployments panel in the About, project homepage, both sides' operations1/**
2 * The rules of a repository's deployments, apart from storing them: what a
3 * reported deployment and status may say, how a g1t.page build reads as a
4 * deployment, what a status says on the commit, and how a g1t Actions
5 * run's jobs move its deployment along. Kept free of the service so its
6 * tests run on Node as they are. See repo-deployments.ts.
7 */
8
9import type { DeployKind, DeployStatus, DeploymentState, DeploymentStatus, RepoDeployment } from "@g1t/contracts";
10
11/** The states, as `DEPLOYMENT_STATES` in @g1t/contracts lists them (kept here so this runs on Node). */
12const STATES: readonly DeploymentState[] = ["queued", "in_progress", "success", "failure", "error", "inactive"];
13
14/** The environment a deployment goes to when none is named. */
15export const DEFAULT_ENVIRONMENT = "production";
16/** The longest environment name, task, description and address. */
17export const MAX_ENVIRONMENT = 255;
18export const MAX_TASK = 100;
19export const MAX_DESCRIPTION = 1000;
20export const MAX_URL = 2000;
21/** The most a payload may hold, as JSON. */
22export const MAX_PAYLOAD = 64 * 1024;
23/** How many deployments a page holds unless asked, and at most. */
24export const PER_PAGE = 30;
25export const MAX_PER_PAGE = 100;
26
27/** The commit status a deployment's statuses set on its commit. */
28export function statusContext(environment: string): string {
29 return `deploy / ${environment}`;
30}
31
32export function isState(value: unknown): value is DeploymentState {
33 return typeof value === "string" && (STATES as readonly string[]).includes(value);
34}
35
36/** Whether a state is one a deployment ends in (until something newer replaces it). */
37export function finished(state: DeploymentState): boolean {
38 return state === "success" || state === "failure" || state === "error" || state === "inactive";
39}
40
41/**
42 * An environment's name as given, trimmed: `Err` says what is wrong.
43 * Anything printable up to 255 characters, as names like `production`,
44 * `staging`, `review/feature-x` or `Production – web` are all in use.
45 */
46export function environmentName(value: unknown): string | { error: string } {
47 if (value == null || value === "") return DEFAULT_ENVIRONMENT;
48 if (typeof value !== "string") return { error: "`environment` is a string, such as production." };
49 const name = value.trim();
50 if (!name) return DEFAULT_ENVIRONMENT;
51 if (name.length > MAX_ENVIRONMENT) return { error: `\`environment\` is at most ${MAX_ENVIRONMENT} characters.` };
52 // eslint-disable-next-line no-control-regex
53 if (/[\u0000-\u001f\u007f]/.test(name)) return { error: "`environment` cannot hold control characters." };
54 return name;
55}
56
57/** An http(s) address, or null for none. `Err` says what is wrong. */
58export function address(value: unknown, field: string): string | null | { error: string } {
59 if (value == null || value === "") return null;
60 if (typeof value !== "string") return { error: `\`${field}\` is an address, such as https://example.com.` };
61 const text = value.trim();
62 if (text.length > MAX_URL) return { error: `\`${field}\` is at most ${MAX_URL} characters.` };
63 let url: URL;
64 try {
65 url = new URL(text);
66 } catch {
67 return { error: `\`${field}\` is not an address: give it as https://….` };
68 }
69 if (url.protocol !== "https:" && url.protocol !== "http:") return { error: `\`${field}\` must start with https:// or http://.` };
70 return text;
71}
72
73/** A payload: an object, or JSON text of one. `Err` says what is wrong. */
74export function payloadOf(value: unknown): { value: Record<string, unknown> } | { error: string } {
75 if (value == null || value === "") return { value: {} };
76 let parsed = value;
77 if (typeof value === "string") {
78 try {
79 parsed = JSON.parse(value);
80 } catch {
81 return { error: "`payload` is a JSON object." };
82 }
83 }
84 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { error: "`payload` is a JSON object." };
85 if (JSON.stringify(parsed).length > MAX_PAYLOAD) return { error: "`payload` is at most 64 KB as JSON." };
86 return { value: parsed as Record<string, unknown> };
87}
88
89/** A whole commit id: 40 (SHA-1) or 64 (SHA-256) hex digits. */
90export function fullSha(value: string): boolean {
91 return /^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
92}
93
94/** A branch or tag as a ref names it: `refs/heads/main` reads `main`. */
95export function shortRef(ref: string): string {
96 return ref.replace(/^refs\/(heads|tags)\//, "");
97}
98
99/** How a g1t.page build's status reads as a deployment's state. */
100export function buildState(status: DeployStatus): DeploymentState {
101 switch (status) {
102 case "queued":
103 return "queued";
104 case "building":
105 return "in_progress";
106 case "ready":
107 return "success";
108 case "failed":
109 return "failure";
110 case "skipped":
111 return "error";
112 case "replaced":
113 case "down":
114 return "inactive";
115 }
116}
117
118/** The same mapping in SQL, over a column of build statuses. */
119export const BUILD_STATE_SQL = `CASE status WHEN 'queued' THEN 'queued' WHEN 'building' THEN 'in_progress' WHEN 'ready' THEN 'success'
120 WHEN 'failed' THEN 'failure' WHEN 'skipped' THEN 'error' ELSE 'inactive' END`;
121
122/**
123 * What a deployment's state sets on its commit: the commit status's state,
124 * or null for none (an inactive deployment leaves what it said before).
125 */
126export function commitState(state: DeploymentState): "pending" | "success" | "failure" | "error" | null {
127 switch (state) {
128 case "queued":
129 case "in_progress":
130 return "pending";
131 case "success":
132 return "success";
133 case "failure":
134 return "failure";
135 case "error":
136 return "error";
137 case "inactive":
138 return null;
139 }
140}
141
142/** What the commit status says, when the status gave no description. */
143export function commitDescription(state: DeploymentState, environment: string): string {
144 switch (state) {
145 case "queued":
146 return `Waiting to deploy to ${environment}`;
147 case "in_progress":
148 return `Deploying to ${environment}`;
149 case "success":
150 return `Deployed to ${environment}`;
151 case "failure":
152 return `The deployment to ${environment} failed`;
153 case "error":
154 return `The deployment to ${environment} could not finish`;
155 case "inactive":
156 return `No longer active in ${environment}`;
157 }
158}
159
160/** How each state is said when nothing else is. */
161export function stateDescription(state: DeploymentState): string {
162 return {
163 queued: "Queued",
164 in_progress: "Deploying",
165 success: "Deployed",
166 failure: "Failed",
167 error: "Could not finish",
168 inactive: "Replaced by a newer deployment",
169 }[state];
170}
171
172/**
173 * Where a g1t Actions run's deployment to one environment goes next, given
174 * the state it is in and what the run says now; null leaves it as it is.
175 *
176 * - A job that names the environment starting: `in_progress`, unless the
177 * deployment already failed (a later job of the same run does not undo
178 * that) or ended.
179 * - A job that names it failing: `failure` at once.
180 * - The run finishing (`final`): its outcome for the environment's jobs,
181 * whatever came before, since it is the last word.
182 */
183export function actionsTransition(current: DeploymentState | null, next: DeploymentState, final: boolean): DeploymentState | null {
184 if (current === next) return null;
185 if (final || current == null) return next;
186 if (current === "failure" || current === "error" || current === "inactive") return null;
187 if (current === "success" && next === "in_progress") return null;
188 return next;
189}
190
191/**
192 * How a finished run went for one environment, from the conclusions of
193 * the jobs that named it: failed if any failed, cancelled as an error,
194 * else a success. Jobs that were skipped never deployed.
195 */
196export function runOutcome(conclusions: (string | null)[]): DeploymentState | null {
197 const ran = conclusions.filter((conclusion) => conclusion !== "skipped" && conclusion != null);
198 if (ran.length === 0) return null;
199 if (ran.some((conclusion) => conclusion === "failure" || conclusion === "timed_out")) return "failure";
200 if (ran.some((conclusion) => conclusion === "cancelled")) return "error";
201 return "success";
202}
203
204/**
205 * The order environments are shown in: those people use directly first
206 * (production by name before others), then the most recently deployed.
207 */
208export function compareEnvironments(
209 a: { name: string; production_environment: boolean; updated_at: string },
210 b: { name: string; production_environment: boolean; updated_at: string },
211): number {
212 const rank = (env: { name: string; production_environment: boolean }) =>
213 env.name.toLowerCase() === "production" ? 0 : env.production_environment ? 1 : 2;
214 return rank(a) - rank(b) || b.updated_at.localeCompare(a.updated_at) || a.name.localeCompare(b.name);
215}
216
217/** A deployment without its payload, as events carry it. */
218export function withoutPayload(deployment: RepoDeployment): Omit<RepoDeployment, "payload"> {
219 const { payload: _payload, ...rest } = deployment;
220 return rest;
221}
222
223/** A g1t.page build, as much of it as a deployment needs. */
224export type BuildRow = {
225 id: string;
226 workspace: string;
227 slug: string;
228 repo_id: string;
229 kind: DeployKind;
230 branch: string | null;
231 number: number | null;
232 commit_sha: string;
233 script: string;
234 status: DeployStatus;
235 error: string | null;
236 created_by: string;
237 created_at: string;
238 started_at?: string | null;
239 finished_at: string | null;
240};
241
242/**
243 * How a g1t.page build reads as a deployment. `appUrl` is where a script
244 * is served (names.ts); `names` turns a creator's id into a username.
245 */
246export function fromBuild(
247 row: BuildRow,
248 defaultBranch: string,
249 site: string,
250 appUrl: (script: string) => string,
251 names: Record<string, string> = {},
252): RepoDeployment {
253 const state = buildState(row.status);
254 const went = row.status === "ready" || row.status === "replaced" || row.status === "down";
255 return {
256 id: row.id,
257 environment: row.kind,
258 ref: row.branch ?? defaultBranch,
259 sha: row.commit_sha,
260 task: "deploy",
261 description: row.error ?? (row.kind === "production" ? "Production on g1t.page" : `Preview of ${row.branch ?? "a branch"} on g1t.page`),
262 payload: {},
263 transient_environment: row.kind === "preview",
264 production_environment: row.kind === "production",
265 state,
266 environment_url: went ? appUrl(row.script) : null,
267 log_url: `${site}/${row.workspace}/${row.slug}/deployments/${row.id}`,
268 creator: names[row.created_by] ?? (row.created_by.startsWith("usr_") ? "g1t" : row.created_by),
269 source: "g1t_page",
270 run_id: null,
271 run_url: null,
272 project: row.slug,
273 number: row.kind === "preview" ? row.number : null,
274 created_at: row.created_at,
275 updated_at: row.finished_at ?? row.started_at ?? row.created_at,
276 };
277}
278
279/** A build's statuses, from its own timestamps: queued, building, then how it ended. */
280export function buildStatuses(row: BuildRow, deployment: RepoDeployment): DeploymentStatus[] {
281 const status = (suffix: string, state: DeploymentState, at: string, description: string): DeploymentStatus => ({
282 id: `${row.id}:${suffix}`,
283 deployment_id: row.id,
284 state,
285 description,
286 environment_url: state === "success" ? deployment.environment_url : null,
287 log_url: deployment.log_url,
288 creator: deployment.creator,
289 created_at: at,
290 });
291 const out = [status("queued", "queued", row.created_at, "Waiting for a sandbox")];
292 if (row.started_at) out.push(status("building", "in_progress", row.started_at, "Building"));
293 const end = row.finished_at;
294 if (end && row.status !== "queued" && row.status !== "building") {
295 const state = row.status === "replaced" || row.status === "down" ? "success" : buildState(row.status);
296 out.push(status("finished", state, end, row.error ?? (state === "success" ? "Live" : stateDescription(state))));
297 }
298 if (row.status === "replaced" || row.status === "down") {
299 out.push(
300 status(
301 row.status,
302 "inactive",
303 end ?? row.created_at,
304 row.status === "replaced" ? "Replaced by a newer build" : "Taken down",
305 ),
306 );
307 }
308 return out;
309}

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