Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

517 lines · 27.0 KBRaw History
1import type { Env } from "../env.js";
2import { decryptSecret, encryptSecret } from "../lib/crypto.js";
3import { HttpError, now } from "../lib/util.js";
4import type { RepoDO } from "./RepoDO.js";
5import { requiredCheck } from "./required.js";
6import { atLeast, globToRegExp, type Caller } from "./types.js";
7
8type 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
27type 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
41type EnvironmentRow = { id: number; name: string; branch_policy: string | null; created_at: string; updated_at: string };
42
43export type Person = { id: string; login: string; kind: string };
44
45export 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
64export 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). */
77export 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 */
85export 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
91export type Environment = { id: number; name: string; policy: BranchPolicy | null; createdAt: string; updatedAt: string };
92
93const STATES = new Set(["error", "failure", "inactive", "in_progress", "queued", "pending", "success"]);
94const ENV_NAME = /^[^\x00-\x1f\x7f/\\]{1,255}$/;
95const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
96
97const 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. */
100export 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 */
113export 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}