g1t/services/deployments/src/environments.ts
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' operations | 1 | /** |
| 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 | ||
| 9 | import 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). */ | |
| 12 | const STATES: readonly DeploymentState[] = ["queued", "in_progress", "success", "failure", "error", "inactive"]; | |
| 13 | ||
| 14 | /** The environment a deployment goes to when none is named. */ | |
| 15 | export const DEFAULT_ENVIRONMENT = "production"; | |
| 16 | /** The longest environment name, task, description and address. */ | |
| 17 | export const MAX_ENVIRONMENT = 255; | |
| 18 | export const MAX_TASK = 100; | |
| 19 | export const MAX_DESCRIPTION = 1000; | |
| 20 | export const MAX_URL = 2000; | |
| 21 | /** The most a payload may hold, as JSON. */ | |
| 22 | export const MAX_PAYLOAD = 64 * 1024; | |
| 23 | /** How many deployments a page holds unless asked, and at most. */ | |
| 24 | export const PER_PAGE = 30; | |
| 25 | export const MAX_PER_PAGE = 100; | |
| 26 | ||
| 27 | /** The commit status a deployment's statuses set on its commit. */ | |
| 28 | export function statusContext(environment: string): string { | |
| 29 | return `deploy / ${environment}`; | |
| 30 | } | |
| 31 | ||
| 32 | export 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). */ | |
| 37 | export 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 | */ | |
| 46 | export 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. */ | |
| 58 | export 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. */ | |
| 74 | export 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. */ | |
| 90 | export 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`. */ | |
| 95 | export 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. */ | |
| 100 | export 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. */ | |
| 119 | export 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 | */ | |
| 126 | export 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. */ | |
| 143 | export 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. */ | |
| 161 | export 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 | */ | |
| 183 | export 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 | */ | |
| 196 | export 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 | */ | |
| 208 | export 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. */ | |
| 218 | export 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. */ | |
| 224 | export 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 | */ | |
| 246 | export 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. */ | |
| 280 | export 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.