| 1 | import type { Env } from "../env.js"; |
| 2 | import { decryptSecret, encryptSecret } from "../lib/crypto.js"; |
| 3 | import { HttpError, now } from "../lib/util.js"; |
| 4 | import type { RepoDO } from "./RepoDO.js"; |
| 5 | import { requiredCheck } from "./required.js"; |
| 6 | import { atLeast, globToRegExp, type Caller } from "./types.js"; |
| 7 | |
| 8 | type DeploymentRow = { |
| 9 | id: number; |
| 10 | sha: string; |
| 11 | ref: string; |
| 12 | task: string; |
| 13 | environment: string; |
| 14 | original_environment: string; |
| 15 | description: string | null; |
| 16 | payload: string; |
| 17 | transient: number; |
| 18 | production: number; |
| 19 | creator_id: string; |
| 20 | creator_login: string; |
| 21 | creator_kind: string; |
| 22 | job_id: string | null; |
| 23 | created_at: string; |
| 24 | updated_at: string; |
| 25 | }; |
| 26 | |
| 27 | type StatusRow = { |
| 28 | id: number; |
| 29 | deployment_id: number; |
| 30 | state: string; |
| 31 | description: string; |
| 32 | environment: string; |
| 33 | environment_url: string; |
| 34 | log_url: string; |
| 35 | creator_id: string; |
| 36 | creator_login: string; |
| 37 | creator_kind: string; |
| 38 | created_at: string; |
| 39 | }; |
| 40 | |
| 41 | type EnvironmentRow = { id: number; name: string; branch_policy: string | null; created_at: string; updated_at: string }; |
| 42 | |
| 43 | export type Person = { id: string; login: string; kind: string }; |
| 44 | |
| 45 | export type Deployment = { |
| 46 | id: number; |
| 47 | sha: string; |
| 48 | ref: string; |
| 49 | task: string; |
| 50 | environment: string; |
| 51 | originalEnvironment: string; |
| 52 | description: string | null; |
| 53 | /** The payload as JSON text (see parsePayload). */ |
| 54 | payload: string; |
| 55 | transient: boolean; |
| 56 | production: boolean; |
| 57 | creator: Person; |
| 58 | createdAt: string; |
| 59 | updatedAt: string; |
| 60 | /** State of the newest status, if any. */ |
| 61 | state: string | null; |
| 62 | }; |
| 63 | |
| 64 | export type DeploymentStatus = { |
| 65 | id: number; |
| 66 | deploymentId: number; |
| 67 | state: string; |
| 68 | description: string; |
| 69 | environment: string; |
| 70 | environmentUrl: string; |
| 71 | logUrl: string; |
| 72 | creator: Person; |
| 73 | createdAt: string; |
| 74 | }; |
| 75 | |
| 76 | /** Which refs may deploy to an environment (GitHub's deployment branch policy). */ |
| 77 | export type BranchPolicy = { protected_branches: boolean; custom_branch_policies: boolean; patterns: Array<{ id: number; name: string; type: "branch" | "tag" }> }; |
| 78 | |
| 79 | /** |
| 80 | * Whether a job on `ref`, started by an actor of `actorKind`, may receive an |
| 81 | * environment's secrets. Pull request refs never do: their code is whatever the |
| 82 | * pull request says. Runs started by agents only do when the environment is |
| 83 | * limited to protected branches, where changes need review. |
| 84 | */ |
| 85 | export function secretsAllowedFor(policy: BranchPolicy | null, ref: string, actorKind: string): boolean { |
| 86 | if (ref.startsWith("refs/pull/")) return false; |
| 87 | if (actorKind === "agent") return !!policy?.protected_branches; |
| 88 | return true; |
| 89 | } |
| 90 | |
| 91 | export type Environment = { id: number; name: string; policy: BranchPolicy | null; createdAt: string; updatedAt: string }; |
| 92 | |
| 93 | const STATES = new Set(["error", "failure", "inactive", "in_progress", "queued", "pending", "success"]); |
| 94 | const ENV_NAME = /^[^\x00-\x1f\x7f/\\]{1,255}$/; |
| 95 | const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; |
| 96 | |
| 97 | const person = (id: string, login: string, kind: string): Person => ({ id, login, kind }); |
| 98 | |
| 99 | /** A deployment's payload: the JSON it was created with, or the raw string when it isn't JSON. */ |
| 100 | export function parsePayload(text: string): unknown { |
| 101 | try { |
| 102 | return JSON.parse(text); |
| 103 | } catch { |
| 104 | return text; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Deployments, deployment statuses and environments (with their own secrets |
| 110 | * and variables). Workflow jobs with `environment:` record a deployment and |
| 111 | * get that environment's secrets once its branch policy allows the ref. |
| 112 | */ |
| 113 | export class Deployments { |
| 114 | constructor( |
| 115 | private repo: RepoDO, |
| 116 | private env: Env, |
| 117 | ) {} |
| 118 | |
| 119 | private get sql() { |
| 120 | return this.repo.sql; |
| 121 | } |
| 122 | |
| 123 | private toDeployment(r: DeploymentRow): Deployment { |
| 124 | const latest = this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", r.id).toArray()[0]; |
| 125 | return { |
| 126 | id: r.id, |
| 127 | sha: r.sha, |
| 128 | ref: r.ref, |
| 129 | task: r.task, |
| 130 | environment: r.environment, |
| 131 | originalEnvironment: r.original_environment, |
| 132 | description: r.description, |
| 133 | payload: r.payload, |
| 134 | transient: !!r.transient, |
| 135 | production: !!r.production, |
| 136 | creator: person(r.creator_id, r.creator_login, r.creator_kind), |
| 137 | createdAt: r.created_at, |
| 138 | updatedAt: r.updated_at, |
| 139 | state: latest?.state ?? null, |
| 140 | }; |
| 141 | } |
| 142 | |
| 143 | private toStatus = (s: StatusRow): DeploymentStatus => ({ |
| 144 | id: s.id, |
| 145 | deploymentId: s.deployment_id, |
| 146 | state: s.state, |
| 147 | description: s.description, |
| 148 | environment: s.environment, |
| 149 | environmentUrl: s.environment_url, |
| 150 | logUrl: s.log_url, |
| 151 | creator: person(s.creator_id, s.creator_login, s.creator_kind), |
| 152 | createdAt: s.created_at, |
| 153 | }); |
| 154 | |
| 155 | private row(id: number): DeploymentRow { |
| 156 | const r = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = ?", id).toArray()[0]; |
| 157 | if (!r) throw new HttpError(404, "deployment not found"); |
| 158 | return r; |
| 159 | } |
| 160 | |
| 161 | private emit(type: "deployment" | "deployment_status", action: string, payload: Record<string, unknown>, caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf">): void { |
| 162 | const m = this.repo.meta; |
| 163 | this.repo.emit({ type, action, repoId: m.repoId, owner: m.owner, repo: m.name, payload, actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() }); |
| 164 | } |
| 165 | |
| 166 | // ---------------------------------------------------------------- deployments |
| 167 | |
| 168 | async create( |
| 169 | input: { ref?: string; task?: string; payload?: unknown; environment?: string; description?: string | null; transient_environment?: boolean; production_environment?: boolean; required_contexts?: string[] }, |
| 170 | caller: Caller, |
| 171 | jobId: string | null = null, |
| 172 | ): Promise<Deployment> { |
| 173 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployments"); |
| 174 | const ref = (input.ref ?? "").trim(); |
| 175 | if (!ref) throw new HttpError(422, "ref is required", { errors: [{ resource: "Deployment", code: "missing_field", field: "ref" }] }); |
| 176 | const sha = await this.repo.resolve(ref); |
| 177 | if (!sha) throw new HttpError(422, `no ref found for: ${ref}`, { errors: [{ resource: "Deployment", code: "invalid", field: "ref" }] }); |
| 178 | const rawEnvironment = (input.environment ?? "production").trim(); |
| 179 | if (!ENV_NAME.test(rawEnvironment)) throw new HttpError(422, "invalid environment name"); |
| 180 | // Environment names are case-insensitive, as on GitHub: reuse the existing spelling. |
| 181 | const environment = this.envRow(rawEnvironment)?.name ?? rawEnvironment; |
| 182 | // Like GitHub: without required_contexts every context on the commit must have passed; [] skips the check. |
| 183 | // Deployments a workflow job makes for its `environment:` are not checked (the job's own run is still going). |
| 184 | if (jobId === null && (input.required_contexts === undefined || input.required_contexts.length)) { |
| 185 | const entries = [...this.repo.statuses.asChecks(sha), ...this.repo.collab.checks(sha)]; |
| 186 | const contexts = input.required_contexts ?? [...new Set(entries.filter((e) => e.creatorKind !== "agent").map((e) => e.name))]; |
| 187 | const states = contexts.map((ctx) => ({ context: ctx, state: requiredCheck(ctx, entries).state })); |
| 188 | const failing = states.filter((x) => x.state !== "success"); |
| 189 | if (failing.length) throw new HttpError(409, `Conflict: Commit status checks failed for ${ref}.`, { errors: failing.map((x) => ({ contexts: [x], resource: "Deployment", field: "required_contexts" })) }); |
| 190 | } |
| 191 | this.ensureEnvironment(environment); |
| 192 | const t = now(); |
| 193 | this.sql.exec( |
| 194 | "INSERT INTO deployments (sha, ref, task, environment, original_environment, description, payload, transient, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 195 | sha, |
| 196 | ref, |
| 197 | (input.task || "deploy").slice(0, 100), |
| 198 | environment, |
| 199 | environment, |
| 200 | input.description ? String(input.description).slice(0, 1000) : null, |
| 201 | typeof input.payload === "string" ? input.payload : JSON.stringify(input.payload ?? {}), |
| 202 | input.transient_environment ? 1 : 0, |
| 203 | (input.production_environment ?? environment.toLowerCase() === "production") ? 1 : 0, |
| 204 | caller.id, |
| 205 | caller.login, |
| 206 | caller.kind, |
| 207 | jobId, |
| 208 | t, |
| 209 | t, |
| 210 | ); |
| 211 | const d = this.toDeployment(this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = last_insert_rowid()").one()); |
| 212 | this.emit("deployment", "created", { deployment: { id: d.id, sha, ref, task: d.task, environment, description: d.description, payload: parsePayload(d.payload), created_at: t } }, caller); |
| 213 | return d; |
| 214 | } |
| 215 | |
| 216 | list(filter: { sha?: string; ref?: string; task?: string; environment?: string; limit: number; offset: number }): { items: Deployment[]; total: number } { |
| 217 | const where: string[] = []; |
| 218 | const args: unknown[] = []; |
| 219 | for (const [col, v] of [["sha", filter.sha], ["ref", filter.ref], ["task", filter.task], ["environment", filter.environment]] as const) { |
| 220 | if (v) { |
| 221 | where.push(col === "environment" ? "environment = ? COLLATE NOCASE" : `${col} = ?`); |
| 222 | args.push(v); |
| 223 | } |
| 224 | } |
| 225 | const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; |
| 226 | const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM deployments ${w}`, ...args).one().n; |
| 227 | const rows = this.sql.exec<DeploymentRow>(`SELECT * FROM deployments ${w} ORDER BY id DESC LIMIT ? OFFSET ?`, ...args, Math.min(filter.limit, 100), filter.offset).toArray(); |
| 228 | return { items: rows.map((r) => this.toDeployment(r)), total }; |
| 229 | } |
| 230 | |
| 231 | get(id: number): Deployment { |
| 232 | return this.toDeployment(this.row(id)); |
| 233 | } |
| 234 | |
| 235 | remove(id: number, caller: Caller): void { |
| 236 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to delete deployments"); |
| 237 | const d = this.get(id); |
| 238 | const others = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM deployments WHERE environment = ? COLLATE NOCASE AND id != ?", d.environment, id).one().n; |
| 239 | if (d.state === "success" && others > 0) throw new HttpError(422, "We cannot delete an active deployment unless it is the only deployment in a given environment."); |
| 240 | this.sql.exec("DELETE FROM deployment_statuses WHERE deployment_id = ?", id); |
| 241 | this.sql.exec("DELETE FROM deployments WHERE id = ?", id); |
| 242 | } |
| 243 | |
| 244 | createStatus( |
| 245 | id: number, |
| 246 | input: { state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }, |
| 247 | caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf" | "role">, |
| 248 | ): DeploymentStatus { |
| 249 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployment statuses"); |
| 250 | const d = this.row(id); |
| 251 | const state = String(input.state ?? ""); |
| 252 | if (!STATES.has(state)) throw new HttpError(422, "state must be one of error, failure, inactive, in_progress, queued, pending, success", { errors: [{ resource: "DeploymentStatus", code: "invalid", field: "state" }] }); |
| 253 | const urls = [input.log_url ?? input.target_url ?? "", input.environment_url ?? ""]; |
| 254 | for (const u of urls) if (u && !/^https?:\/\//i.test(u)) throw new HttpError(422, "URLs must be http(s)"); |
| 255 | let environment = input.environment?.trim() || d.environment; |
| 256 | if (environment.toLowerCase() === d.environment.toLowerCase()) environment = d.environment; |
| 257 | else { |
| 258 | if (!ENV_NAME.test(environment)) throw new HttpError(422, "invalid environment name"); |
| 259 | environment = this.ensureEnvironment(environment).name; |
| 260 | this.sql.exec("UPDATE deployments SET environment = ? WHERE id = ?", environment, id); |
| 261 | } |
| 262 | const t = now(); |
| 263 | this.sql.exec( |
| 264 | "INSERT INTO deployment_statuses (deployment_id, state, description, environment, environment_url, log_url, creator_id, creator_login, creator_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 265 | id, |
| 266 | state, |
| 267 | (input.description ?? "").slice(0, 140), |
| 268 | environment, |
| 269 | urls[1], |
| 270 | urls[0], |
| 271 | caller.id, |
| 272 | caller.login, |
| 273 | caller.kind, |
| 274 | t, |
| 275 | ); |
| 276 | const statusId = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; |
| 277 | this.sql.exec("UPDATE deployments SET updated_at = ? WHERE id = ?", t, id); |
| 278 | if (state === "success" && input.auto_inactive !== false) { |
| 279 | // Older non-transient, non-production deployments to the same environment become inactive (newer ones stay as they are). |
| 280 | const older = this.sql.exec<{ id: number }>("SELECT id FROM deployments WHERE environment = ? COLLATE NOCASE AND id < ? AND transient = 0 AND production = 0", environment, id).toArray(); |
| 281 | for (const o of older) { |
| 282 | const last = this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", o.id).toArray()[0]; |
| 283 | if (last?.state === "success") { |
| 284 | this.sql.exec( |
| 285 | "INSERT INTO deployment_statuses (deployment_id, state, description, environment, creator_id, creator_login, creator_kind, created_at) VALUES (?, 'inactive', '', ?, ?, ?, ?, ?)", |
| 286 | o.id, |
| 287 | environment, |
| 288 | caller.id, |
| 289 | caller.login, |
| 290 | caller.kind, |
| 291 | t, |
| 292 | ); |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | const status = this.status(id, statusId); |
| 297 | this.emit("deployment_status", "created", { deployment_status: { id: statusId, state, description: status.description, environment, environment_url: status.environmentUrl, log_url: status.logUrl, created_at: t }, deployment: { id, sha: d.sha, ref: d.ref, task: d.task, environment } }, caller); |
| 298 | return status; |
| 299 | } |
| 300 | |
| 301 | statuses(id: number): DeploymentStatus[] { |
| 302 | this.row(id); |
| 303 | return this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC", id).toArray().map(this.toStatus); |
| 304 | } |
| 305 | |
| 306 | status(id: number, statusId: number): DeploymentStatus { |
| 307 | const s = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? AND id = ?", id, statusId).toArray()[0]; |
| 308 | if (!s) throw new HttpError(404, "deployment status not found"); |
| 309 | return this.toStatus(s); |
| 310 | } |
| 311 | |
| 312 | // ---------------------------------------------------------------- environments |
| 313 | |
| 314 | /** Environments guard deployment secrets, so only people with admin access change them, never agents. */ |
| 315 | private requireHumanAdmin(caller: Caller): void { |
| 316 | if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage environments"); |
| 317 | } |
| 318 | |
| 319 | /** Remember who last moved a tag: tags created by agents never satisfy environment tag patterns. */ |
| 320 | noteRefUpdate(ref: string, newOid: string, caller: Pick<Caller, "id" | "kind">): void { |
| 321 | if (!ref.startsWith("refs/tags/")) return; |
| 322 | if (/^0+$/.test(newOid)) this.sql.exec("DELETE FROM ref_actors WHERE ref = ?", ref); |
| 323 | else this.sql.exec("INSERT OR REPLACE INTO ref_actors (ref, actor_id, actor_kind, updated_at) VALUES (?, ?, ?, ?)", ref, caller.id, caller.kind, now()); |
| 324 | } |
| 325 | |
| 326 | private tagByAgent(tag: string): boolean { |
| 327 | return this.sql.exec<{ actor_kind: string }>("SELECT actor_kind FROM ref_actors WHERE ref = ?", `refs/tags/${tag}`).toArray()[0]?.actor_kind === "agent"; |
| 328 | } |
| 329 | |
| 330 | private envRow(name: string): EnvironmentRow | undefined { |
| 331 | return this.sql.exec<EnvironmentRow>("SELECT * FROM environments WHERE name = ?", name).toArray()[0]; |
| 332 | } |
| 333 | |
| 334 | private toEnvironment = (r: EnvironmentRow): Environment => ({ id: r.id, name: r.name, policy: r.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, createdAt: r.created_at, updatedAt: r.updated_at }); |
| 335 | |
| 336 | private ensureEnvironment(name: string): EnvironmentRow { |
| 337 | const existing = this.envRow(name); |
| 338 | if (existing) return existing; |
| 339 | const t = now(); |
| 340 | this.sql.exec("INSERT INTO environments (name, created_at, updated_at) VALUES (?, ?, ?)", name, t, t); |
| 341 | return this.envRow(name)!; |
| 342 | } |
| 343 | |
| 344 | environments(): Environment[] { |
| 345 | return this.sql.exec<EnvironmentRow>("SELECT * FROM environments ORDER BY name").toArray().map(this.toEnvironment); |
| 346 | } |
| 347 | |
| 348 | environment(name: string): Environment { |
| 349 | const r = this.envRow(name); |
| 350 | if (!r) throw new HttpError(404, "environment not found"); |
| 351 | return this.toEnvironment(r); |
| 352 | } |
| 353 | |
| 354 | /** |
| 355 | * Create or update an environment. Required reviewers and wait timers are not |
| 356 | * enforced yet, so they are refused rather than silently ignored. |
| 357 | */ |
| 358 | upsertEnvironment(name: string, input: { wait_timer?: number; reviewers?: unknown[] | null; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null }, caller: Caller): Environment { |
| 359 | this.requireHumanAdmin(caller); |
| 360 | if (!ENV_NAME.test(name)) throw new HttpError(422, "invalid environment name"); |
| 361 | if ((input.wait_timer ?? 0) > 0 || (input.reviewers?.length ?? 0) > 0) throw new HttpError(422, "required reviewers and wait timers are not supported yet"); |
| 362 | const row = this.ensureEnvironment(name); |
| 363 | if (input.deployment_branch_policy !== undefined) { |
| 364 | const p = input.deployment_branch_policy; |
| 365 | if (p && !!p.protected_branches === !!p.custom_branch_policies) throw new HttpError(422, "set exactly one of protected_branches and custom_branch_policies"); |
| 366 | const old = row.branch_policy ? (JSON.parse(row.branch_policy) as BranchPolicy) : null; |
| 367 | const policy: BranchPolicy | null = p ? { protected_branches: !!p.protected_branches, custom_branch_policies: !!p.custom_branch_policies, patterns: p.custom_branch_policies ? (old?.patterns ?? []) : [] } : null; |
| 368 | this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", policy ? JSON.stringify(policy) : null, now(), row.id); |
| 369 | } |
| 370 | return this.environment(name); |
| 371 | } |
| 372 | |
| 373 | deleteEnvironment(name: string, caller: Caller): void { |
| 374 | this.requireHumanAdmin(caller); |
| 375 | this.environment(name); |
| 376 | this.sql.exec("DELETE FROM environments WHERE name = ?", name); |
| 377 | this.sql.exec("DELETE FROM environment_secrets WHERE environment = ?", name); |
| 378 | this.sql.exec("DELETE FROM environment_variables WHERE environment = ?", name); |
| 379 | } |
| 380 | |
| 381 | addBranchPattern(name: string, pattern: { name?: string; type?: string }, caller: Caller): { id: number; name: string; type: "branch" | "tag" } { |
| 382 | this.requireHumanAdmin(caller); |
| 383 | const env = this.environment(name); |
| 384 | if (!env.policy?.custom_branch_policies) throw new HttpError(422, "enable custom_branch_policies on the environment first"); |
| 385 | const p = { id: Math.max(0, ...env.policy.patterns.map((x) => x.id)) + 1, name: String(pattern.name ?? "").trim(), type: pattern.type === "tag" ? ("tag" as const) : ("branch" as const) }; |
| 386 | if (!p.name) throw new HttpError(422, "name is required"); |
| 387 | env.policy.patterns.push(p); |
| 388 | this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id); |
| 389 | return p; |
| 390 | } |
| 391 | |
| 392 | removeBranchPattern(name: string, id: number, caller: Caller): void { |
| 393 | this.requireHumanAdmin(caller); |
| 394 | const env = this.environment(name); |
| 395 | if (!env.policy) throw new HttpError(404, "branch policy not found"); |
| 396 | env.policy.patterns = env.policy.patterns.filter((x) => x.id !== id); |
| 397 | this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id); |
| 398 | } |
| 399 | |
| 400 | /** Whether a run on `ref` may use the environment (and see its secrets). */ |
| 401 | refAllowed(name: string, ref: string): boolean { |
| 402 | const r = this.envRow(name); |
| 403 | const policy = r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null; |
| 404 | if (!policy) return true; |
| 405 | const branch = ref.startsWith("refs/heads/") ? ref.slice(11) : null; |
| 406 | const tag = ref.startsWith("refs/tags/") ? ref.slice(10) : null; |
| 407 | if (policy.protected_branches) return !!branch && !!this.repo.protectionFor(branch); |
| 408 | return policy.patterns.some((p) => (p.type === "tag" ? tag !== null && !this.tagByAgent(tag) && globToRegExp(p.name).test(tag) : branch !== null && globToRegExp(p.name).test(branch))); |
| 409 | } |
| 410 | |
| 411 | /** Whether a job may receive the environment's secrets (it already passed refAllowed); see secretsAllowedFor. */ |
| 412 | secretsAllowed(name: string, ref: string, actorKind: string): boolean { |
| 413 | const r = this.envRow(name); |
| 414 | return secretsAllowedFor(r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, ref, actorKind); |
| 415 | } |
| 416 | |
| 417 | // ---------------------------------------------------------------- environment secrets and variables |
| 418 | |
| 419 | private scope(name: string): string { |
| 420 | return `repo:${this.repo.meta.repoId}:env:${name.toLowerCase()}`; |
| 421 | } |
| 422 | |
| 423 | async setSecret(envName: string, name: string, value: string, caller: Caller): Promise<void> { |
| 424 | if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets"); |
| 425 | if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name"); |
| 426 | const env = this.environment(envName); |
| 427 | // An environment holding secrets defaults to protected branches only, like a fresh GitHub environment should be. |
| 428 | if (!env.policy) this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify({ protected_branches: true, custom_branch_policies: false, patterns: [] } satisfies BranchPolicy), now(), env.id); |
| 429 | this.sql.exec("INSERT OR REPLACE INTO environment_secrets (environment, name, ciphertext, updated_at) VALUES (?, ?, ?, ?)", env.name, name.toUpperCase(), await encryptSecret(this.env.MASTER_KEY, this.scope(env.name), value), now()); |
| 430 | } |
| 431 | |
| 432 | deleteSecret(envName: string, name: string, caller: Caller): void { |
| 433 | if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets"); |
| 434 | this.sql.exec("DELETE FROM environment_secrets WHERE environment = ? AND name = ?", envName, name.toUpperCase()); |
| 435 | } |
| 436 | |
| 437 | secretNames(envName: string): Array<{ name: string; updatedAt: string }> { |
| 438 | this.environment(envName); |
| 439 | return this.sql.exec<{ name: string; updated_at: string }>("SELECT name, updated_at FROM environment_secrets WHERE environment = ? ORDER BY name", envName).toArray().map((r) => ({ name: r.name, updatedAt: r.updated_at })); |
| 440 | } |
| 441 | |
| 442 | setVariable(envName: string, name: string, value: string | null, caller: Caller, mustExist = false): void { |
| 443 | if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to manage variables"); |
| 444 | const env = this.environment(envName); |
| 445 | const key = name.toUpperCase(); |
| 446 | if (value === null) { |
| 447 | this.sql.exec("DELETE FROM environment_variables WHERE environment = ? AND name = ?", env.name, key); |
| 448 | return; |
| 449 | } |
| 450 | if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid variable name"); |
| 451 | const existing = this.sql.exec("SELECT 1 FROM environment_variables WHERE environment = ? AND name = ?", env.name, key).toArray().length > 0; |
| 452 | if (mustExist && !existing) throw new HttpError(404, "variable not found"); |
| 453 | const t = now(); |
| 454 | if (existing) this.sql.exec("UPDATE environment_variables SET value = ?, updated_at = ? WHERE environment = ? AND name = ?", value, t, env.name, key); |
| 455 | else this.sql.exec("INSERT INTO environment_variables (environment, name, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", env.name, key, value, t, t); |
| 456 | } |
| 457 | |
| 458 | variables(envName: string): Array<{ name: string; value: string; createdAt: string; updatedAt: string }> { |
| 459 | this.environment(envName); |
| 460 | return this.sql |
| 461 | .exec<{ name: string; value: string; created_at: string; updated_at: string }>("SELECT * FROM environment_variables WHERE environment = ? ORDER BY name", envName) |
| 462 | .toArray() |
| 463 | .map((r) => ({ name: r.name, value: r.value, createdAt: r.created_at, updatedAt: r.updated_at })); |
| 464 | } |
| 465 | |
| 466 | // ---------------------------------------------------------------- workflow jobs with `environment:` |
| 467 | |
| 468 | /** |
| 469 | * Called when a job with `environment:` starts: checks the environment's |
| 470 | * branch policy, records a deployment with an in_progress status, and |
| 471 | * returns the environment's secrets and variables for the job. |
| 472 | */ |
| 473 | async jobStarted(job: { id: string; ref: string; sha: string; logUrl: string; actor: Person }, environment: { name: string; url?: string }): Promise<{ secrets: Record<string, string>; vars: Record<string, string> }> { |
| 474 | const raw = environment.name.trim(); |
| 475 | if (!ENV_NAME.test(raw)) throw new HttpError(422, `invalid environment name '${raw}'`); |
| 476 | const name = this.ensureEnvironment(raw).name; |
| 477 | if (!this.refAllowed(name, job.ref)) throw new HttpError(403, `${job.ref.replace(/^refs\/(heads|tags)\//, "")} is not allowed to deploy to ${name} due to environment protection rules.`); |
| 478 | const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; |
| 479 | if (!this.sql.exec("SELECT 1 FROM deployments WHERE job_id = ?", job.id).toArray().length) { |
| 480 | const t = now(); |
| 481 | this.sql.exec( |
| 482 | "INSERT INTO deployments (sha, ref, task, environment, original_environment, payload, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, 'deploy', ?, ?, '{}', ?, ?, ?, ?, ?, ?, ?)", |
| 483 | job.sha, |
| 484 | job.ref.replace(/^refs\/(heads|tags)\//, ""), |
| 485 | name, |
| 486 | name, |
| 487 | name.toLowerCase() === "production" ? 1 : 0, |
| 488 | job.actor.id, |
| 489 | job.actor.login, |
| 490 | job.actor.kind, |
| 491 | job.id, |
| 492 | t, |
| 493 | t, |
| 494 | ); |
| 495 | const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; |
| 496 | const ref = job.ref.replace(/^refs\/(heads|tags)\//, ""); |
| 497 | this.emit("deployment", "created", { deployment: { id, sha: job.sha, ref, task: "deploy", environment: name, description: null, payload: {}, created_at: t } }, system); |
| 498 | this.createStatus(id, { state: "in_progress", log_url: job.logUrl, environment_url: environment.url && /^https?:\/\//i.test(environment.url) ? environment.url : undefined }, system); |
| 499 | } |
| 500 | const secrets: Record<string, string> = {}; |
| 501 | const rows = this.secretsAllowed(name, job.ref, job.actor.kind) ? this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM environment_secrets WHERE environment = ?", name).toArray() : []; |
| 502 | for (const r of rows) { |
| 503 | secrets[r.name] = await decryptSecret(this.env.MASTER_KEY, this.scope(name), r.ciphertext); |
| 504 | } |
| 505 | const vars = Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM environment_variables WHERE environment = ?", name).toArray().map((r) => [r.name, r.value])); |
| 506 | return { secrets, vars }; |
| 507 | } |
| 508 | |
| 509 | /** Called when a job finishes: the deployment it recorded gets a final status. */ |
| 510 | jobCompleted(jobId: string, conclusion: string | null): void { |
| 511 | const d = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE job_id = ?", jobId).toArray()[0]; |
| 512 | if (!d) return; |
| 513 | const last = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", d.id).toArray()[0]; |
| 514 | const state = conclusion === "success" ? "success" : conclusion === "cancelled" || conclusion === "skipped" ? "inactive" : "failure"; |
| 515 | this.createStatus(d.id, { state, log_url: last?.log_url || undefined, environment_url: last?.environment_url || undefined }, { id: d.creator_id, login: d.creator_login, kind: d.creator_kind as Caller["kind"], role: "write" }); |
| 516 | } |
| 517 | } |