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.

627 lines · 34.1 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; reviewers: string | null; wait_timer: number; prevent_self_review: number; created_at: string; updated_at: string };
42
43type ReviewRow = { id: number; run_id: string; environment: string; state: "approved" | "rejected"; comment: string; user_id: string; user_login: string; created_at: string };
44
45export type Person = { id: string; login: string; kind: string };
46
47export type Deployment = {
48 id: number;
49 sha: string;
50 ref: string;
51 task: string;
52 environment: string;
53 originalEnvironment: string;
54 description: string | null;
55 /** The payload as JSON text (see parsePayload). */
56 payload: string;
57 transient: boolean;
58 production: boolean;
59 creator: Person;
60 createdAt: string;
61 updatedAt: string;
62 /** State of the newest status, if any. */
63 state: string | null;
64};
65
66export type DeploymentStatus = {
67 id: number;
68 deploymentId: number;
69 state: string;
70 description: string;
71 environment: string;
72 environmentUrl: string;
73 logUrl: string;
74 creator: Person;
75 createdAt: string;
76};
77
78/** Which refs may deploy to an environment (GitHub's deployment branch policy). */
79export type BranchPolicy = { protected_branches: boolean; custom_branch_policies: boolean; patterns: Array<{ id: number; name: string; type: "branch" | "tag" }> };
80
81/**
82 * Whether a job on `ref`, started by an actor of `actorKind`, may receive an
83 * environment's secrets. Pull request refs never do: their code is whatever the
84 * pull request says. Runs started by agents only do when the environment is
85 * limited to protected branches, where changes need review.
86 */
87export function secretsAllowedFor(policy: BranchPolicy | null, ref: string, actorKind: string, rules: { agentsMayPush?: boolean } | null): boolean {
88 if (ref.startsWith("refs/pull/")) return false;
89 // "Protected" has to mean agents can't push there themselves: a rule that lets them push
90 // straight to the branch would hand the environment's secrets to any agent run.
91 if (actorKind === "agent") return !!policy?.protected_branches && !!rules && !rules.agentsMayPush;
92 return true;
93}
94
95/** A required reviewer: a person with access to the repository (Diffinery has no teams). */
96export type Reviewer = { type: "User"; id: string; login: string };
97
98/** An environment's protection rules beyond its branch policy, as GitHub has them. */
99export type Protection = { reviewers: Reviewer[]; waitTimer: number; preventSelfReview: boolean };
100
101export type Environment = { id: number; name: string; policy: BranchPolicy | null; protection: Protection; createdAt: string; updatedAt: string };
102
103/** One required reviewer's decision on an environment for a workflow run. */
104export type DeploymentReview = { id: number; runId: string; environment: string; state: "approved" | "rejected"; comment: string; user: { id: string; login: string }; createdAt: string };
105
106/** GitHub's limits: up to 6 required reviewers, and a wait timer of up to 30 days. */
107export const MAX_REVIEWERS = 6;
108export const MAX_WAIT_TIMER = 43_200;
109/** A job waits this long for its environment's reviewers before it fails, as on GitHub. */
110export const APPROVAL_DAYS = 30;
111export const MAX_REVIEW_COMMENT = 4_000;
112
113const STATES = new Set(["error", "failure", "inactive", "in_progress", "queued", "pending", "success"]);
114const ENV_NAME = /^[^\x00-\x1f\x7f/\\]{1,255}$/;
115const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
116
117const person = (id: string, login: string, kind: string): Person => ({ id, login, kind });
118
119/** A deployment's payload: the JSON it was created with, or the raw string when it isn't JSON. */
120export function parsePayload(text: string): unknown {
121 try {
122 return JSON.parse(text);
123 } catch {
124 return text;
125 }
126}
127
128/**
129 * Deployments, deployment statuses and environments (with their own secrets
130 * and variables). Workflow jobs with `environment:` record a deployment and
131 * get that environment's secrets once its branch policy allows the ref.
132 */
133export class Deployments {
134 constructor(
135 private repo: RepoDO,
136 private env: Env,
137 ) {}
138
139 private get sql() {
140 return this.repo.sql;
141 }
142
143 private toDeployment(r: DeploymentRow): Deployment {
144 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];
145 return {
146 id: r.id,
147 sha: r.sha,
148 ref: r.ref,
149 task: r.task,
150 environment: r.environment,
151 originalEnvironment: r.original_environment,
152 description: r.description,
153 payload: r.payload,
154 transient: !!r.transient,
155 production: !!r.production,
156 creator: person(r.creator_id, r.creator_login, r.creator_kind),
157 createdAt: r.created_at,
158 updatedAt: r.updated_at,
159 state: latest?.state ?? null,
160 };
161 }
162
163 private toStatus = (s: StatusRow): DeploymentStatus => ({
164 id: s.id,
165 deploymentId: s.deployment_id,
166 state: s.state,
167 description: s.description,
168 environment: s.environment,
169 environmentUrl: s.environment_url,
170 logUrl: s.log_url,
171 creator: person(s.creator_id, s.creator_login, s.creator_kind),
172 createdAt: s.created_at,
173 });
174
175 private row(id: number): DeploymentRow {
176 const r = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = ?", id).toArray()[0];
177 if (!r) throw new HttpError(404, "deployment not found");
178 return r;
179 }
180
181 private emit(type: "deployment" | "deployment_status", action: string, payload: Record<string, unknown>, caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf">): void {
182 const m = this.repo.meta;
183 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() });
184 }
185
186 // ---------------------------------------------------------------- deployments
187
188 async create(
189 input: { ref?: string; task?: string; payload?: unknown; environment?: string; description?: string | null; transient_environment?: boolean; production_environment?: boolean; required_contexts?: string[] },
190 caller: Caller,
191 jobId: string | null = null,
192 ): Promise<Deployment> {
193 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployments");
194 const ref = (input.ref ?? "").trim();
195 if (!ref) throw new HttpError(422, "ref is required", { errors: [{ resource: "Deployment", code: "missing_field", field: "ref" }] });
196 const sha = await this.repo.resolve(ref);
197 if (!sha) throw new HttpError(422, `no ref found for: ${ref}`, { errors: [{ resource: "Deployment", code: "invalid", field: "ref" }] });
198 const rawEnvironment = (input.environment ?? "production").trim();
199 if (!ENV_NAME.test(rawEnvironment)) throw new HttpError(422, "invalid environment name");
200 // Environment names are case-insensitive, as on GitHub: reuse the existing spelling.
201 const environment = this.envRow(rawEnvironment)?.name ?? rawEnvironment;
202 // Like GitHub: without required_contexts every context on the commit must have passed; [] skips the check.
203 // Deployments a workflow job makes for its `environment:` are not checked (the job's own run is still going).
204 if (jobId === null && (input.required_contexts === undefined || input.required_contexts.length)) {
205 const entries = [...this.repo.statuses.asChecks(sha), ...this.repo.collab.checks(sha)];
206 const contexts = input.required_contexts ?? [...new Set(entries.filter((e) => e.creatorKind !== "agent").map((e) => e.name))];
207 const states = contexts.map((ctx) => ({ context: ctx, state: requiredCheck(ctx, entries).state }));
208 const failing = states.filter((x) => x.state !== "success");
209 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" })) });
210 }
211 this.ensureEnvironment(environment);
212 const t = now();
213 this.sql.exec(
214 "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
215 sha,
216 ref,
217 (input.task || "deploy").slice(0, 100),
218 environment,
219 environment,
220 input.description ? String(input.description).slice(0, 1000) : null,
221 typeof input.payload === "string" ? input.payload : JSON.stringify(input.payload ?? {}),
222 input.transient_environment ? 1 : 0,
223 (input.production_environment ?? environment.toLowerCase() === "production") ? 1 : 0,
224 caller.id,
225 caller.login,
226 caller.kind,
227 jobId,
228 t,
229 t,
230 );
231 const d = this.toDeployment(this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = last_insert_rowid()").one());
232 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);
233 return d;
234 }
235
236 list(filter: { sha?: string; ref?: string; task?: string; environment?: string; limit: number; offset: number }): { items: Deployment[]; total: number } {
237 const where: string[] = [];
238 const args: unknown[] = [];
239 for (const [col, v] of [["sha", filter.sha], ["ref", filter.ref], ["task", filter.task], ["environment", filter.environment]] as const) {
240 if (v) {
241 where.push(col === "environment" ? "environment = ? COLLATE NOCASE" : `${col} = ?`);
242 args.push(v);
243 }
244 }
245 const w = where.length ? `WHERE ${where.join(" AND ")}` : "";
246 const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM deployments ${w}`, ...args).one().n;
247 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();
248 return { items: rows.map((r) => this.toDeployment(r)), total };
249 }
250
251 get(id: number): Deployment {
252 return this.toDeployment(this.row(id));
253 }
254
255 remove(id: number, caller: Caller): void {
256 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to delete deployments");
257 const d = this.get(id);
258 const others = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM deployments WHERE environment = ? COLLATE NOCASE AND id != ?", d.environment, id).one().n;
259 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.");
260 this.sql.exec("DELETE FROM deployment_statuses WHERE deployment_id = ?", id);
261 this.sql.exec("DELETE FROM deployments WHERE id = ?", id);
262 }
263
264 createStatus(
265 id: number,
266 input: { state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean },
267 caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf" | "role">,
268 ): DeploymentStatus {
269 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployment statuses");
270 const d = this.row(id);
271 const state = String(input.state ?? "");
272 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" }] });
273 const urls = [input.log_url ?? input.target_url ?? "", input.environment_url ?? ""];
274 for (const u of urls) if (u && !/^https?:\/\//i.test(u)) throw new HttpError(422, "URLs must be http(s)");
275 let environment = input.environment?.trim() || d.environment;
276 if (environment.toLowerCase() === d.environment.toLowerCase()) environment = d.environment;
277 else {
278 if (!ENV_NAME.test(environment)) throw new HttpError(422, "invalid environment name");
279 environment = this.ensureEnvironment(environment).name;
280 this.sql.exec("UPDATE deployments SET environment = ? WHERE id = ?", environment, id);
281 }
282 const t = now();
283 this.sql.exec(
284 "INSERT INTO deployment_statuses (deployment_id, state, description, environment, environment_url, log_url, creator_id, creator_login, creator_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
285 id,
286 state,
287 (input.description ?? "").slice(0, 140),
288 environment,
289 urls[1],
290 urls[0],
291 caller.id,
292 caller.login,
293 caller.kind,
294 t,
295 );
296 const statusId = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id;
297 this.sql.exec("UPDATE deployments SET updated_at = ? WHERE id = ?", t, id);
298 if (state === "success" && input.auto_inactive !== false) {
299 // Older non-transient, non-production deployments to the same environment become inactive (newer ones stay as they are).
300 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();
301 for (const o of older) {
302 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];
303 if (last?.state === "success") {
304 this.sql.exec(
305 "INSERT INTO deployment_statuses (deployment_id, state, description, environment, creator_id, creator_login, creator_kind, created_at) VALUES (?, 'inactive', '', ?, ?, ?, ?, ?)",
306 o.id,
307 environment,
308 caller.id,
309 caller.login,
310 caller.kind,
311 t,
312 );
313 }
314 }
315 }
316 const status = this.status(id, statusId);
317 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);
318 return status;
319 }
320
321 statuses(id: number): DeploymentStatus[] {
322 this.row(id);
323 return this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC", id).toArray().map(this.toStatus);
324 }
325
326 status(id: number, statusId: number): DeploymentStatus {
327 const s = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? AND id = ?", id, statusId).toArray()[0];
328 if (!s) throw new HttpError(404, "deployment status not found");
329 return this.toStatus(s);
330 }
331
332 // ---------------------------------------------------------------- environments
333
334 /** Environments guard deployment secrets, so only people with admin access change them, never agents. */
335 private requireHumanAdmin(caller: Caller): void {
336 if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage environments");
337 }
338
339 /** Remember who last moved a tag: tags created by agents never satisfy environment tag patterns. */
340 noteRefUpdate(ref: string, newOid: string, caller: Pick<Caller, "id" | "kind">): void {
341 if (!ref.startsWith("refs/tags/")) return;
342 if (/^0+$/.test(newOid)) this.sql.exec("DELETE FROM ref_actors WHERE ref = ?", ref);
343 else this.sql.exec("INSERT OR REPLACE INTO ref_actors (ref, actor_id, actor_kind, updated_at) VALUES (?, ?, ?, ?)", ref, caller.id, caller.kind, now());
344 }
345
346 private tagByAgent(tag: string): boolean {
347 return this.sql.exec<{ actor_kind: string }>("SELECT actor_kind FROM ref_actors WHERE ref = ?", `refs/tags/${tag}`).toArray()[0]?.actor_kind === "agent";
348 }
349
350 private envRow(name: string): EnvironmentRow | undefined {
351 return this.sql.exec<EnvironmentRow>("SELECT * FROM environments WHERE name = ?", name).toArray()[0];
352 }
353
354 private toEnvironment = (r: EnvironmentRow): Environment => ({
355 id: r.id,
356 name: r.name,
357 policy: r.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null,
358 protection: { reviewers: r.reviewers ? (JSON.parse(r.reviewers) as Reviewer[]) : [], waitTimer: r.wait_timer ?? 0, preventSelfReview: !!r.prevent_self_review },
359 createdAt: r.created_at,
360 updatedAt: r.updated_at,
361 });
362
363 private ensureEnvironment(name: string): EnvironmentRow {
364 const existing = this.envRow(name);
365 if (existing) return existing;
366 const t = now();
367 this.sql.exec("INSERT INTO environments (name, created_at, updated_at) VALUES (?, ?, ?)", name, t, t);
368 return this.envRow(name)!;
369 }
370
371 environments(): Environment[] {
372 return this.sql.exec<EnvironmentRow>("SELECT * FROM environments ORDER BY name").toArray().map(this.toEnvironment);
373 }
374
375 environment(name: string): Environment {
376 const r = this.envRow(name);
377 if (!r) throw new HttpError(404, "environment not found");
378 return this.toEnvironment(r);
379 }
380
381 /**
382 * Create or update an environment. `reviewers` arrive resolved to people (http/environments.ts
383 * checks each is a person with access); a field left out keeps its value.
384 */
385 upsertEnvironment(
386 name: string,
387 input: { wait_timer?: unknown; reviewers?: Reviewer[] | null; prevent_self_review?: unknown; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null },
388 caller: Caller,
389 ): Environment {
390 this.requireHumanAdmin(caller);
391 if (!ENV_NAME.test(name)) throw new HttpError(422, "invalid environment name");
392 if (input.wait_timer !== undefined && input.wait_timer !== null && (typeof input.wait_timer !== "number" || !Number.isInteger(input.wait_timer) || input.wait_timer < 0 || input.wait_timer > MAX_WAIT_TIMER)) {
393 throw new HttpError(422, `wait_timer must be a whole number of minutes from 0 to ${MAX_WAIT_TIMER}`);
394 }
395 if (input.prevent_self_review !== undefined && input.prevent_self_review !== null && typeof input.prevent_self_review !== "boolean") throw new HttpError(422, "prevent_self_review must be true or false");
396 if (input.reviewers) {
397 if (input.reviewers.length > MAX_REVIEWERS) throw new HttpError(422, `an environment can have at most ${MAX_REVIEWERS} required reviewers`);
398 if (input.reviewers.some((r) => r.type !== "User" || typeof r.id !== "string" || typeof r.login !== "string")) throw new HttpError(422, "reviewers must be people");
399 }
400 const row = this.ensureEnvironment(name);
401 if (input.wait_timer !== undefined && input.wait_timer !== null) this.sql.exec("UPDATE environments SET wait_timer = ?, updated_at = ? WHERE id = ?", input.wait_timer, now(), row.id);
402 if (input.prevent_self_review !== undefined && input.prevent_self_review !== null) this.sql.exec("UPDATE environments SET prevent_self_review = ?, updated_at = ? WHERE id = ?", input.prevent_self_review ? 1 : 0, now(), row.id);
403 if (input.reviewers !== undefined) {
404 const unique = [...new Map((input.reviewers ?? []).map((r) => [r.id, { type: "User" as const, id: r.id, login: r.login }])).values()];
405 this.sql.exec("UPDATE environments SET reviewers = ?, updated_at = ? WHERE id = ?", unique.length ? JSON.stringify(unique) : null, now(), row.id);
406 }
407 if (input.deployment_branch_policy !== undefined) {
408 const p = input.deployment_branch_policy;
409 if (p && !!p.protected_branches === !!p.custom_branch_policies) throw new HttpError(422, "set exactly one of protected_branches and custom_branch_policies");
410 const old = row.branch_policy ? (JSON.parse(row.branch_policy) as BranchPolicy) : null;
411 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;
412 this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", policy ? JSON.stringify(policy) : null, now(), row.id);
413 }
414 return this.environment(name);
415 }
416
417 /** The environment's reviewers and wait timer, or null when it has neither (its jobs then start at once). */
418 protection(name: string): Protection | null {
419 const r = this.envRow(name);
420 if (!r) return null;
421 const p = this.toEnvironment(r).protection;
422 return p.reviewers.length || p.waitTimer > 0 ? p : null;
423 }
424
425 /** Record a reviewer's decision for a run's environment (the caller checked who may decide). */
426 addReview(runId: string, environment: string, state: "approved" | "rejected", comment: string, user: { id: string; login: string }): DeploymentReview {
427 const t = now();
428 this.sql.exec("INSERT INTO deployment_reviews (run_id, environment, state, comment, user_id, user_login, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", runId, environment, state, comment.slice(0, MAX_REVIEW_COMMENT), user.id, user.login, t);
429 return this.toReview(this.sql.exec<ReviewRow>("SELECT * FROM deployment_reviews WHERE id = last_insert_rowid()").one());
430 }
431
432 /** The decision on a run's environment so far: the first one counts, as on GitHub. */
433 decision(runId: string, environment: string): "approved" | "rejected" | null {
434 return this.sql.exec<{ state: "approved" | "rejected" }>("SELECT state FROM deployment_reviews WHERE run_id = ? AND environment = ? COLLATE NOCASE ORDER BY id LIMIT 1", runId, environment).toArray()[0]?.state ?? null;
435 }
436
437 /** The deployments workflow jobs recorded, newest first. */
438 jobDeployments(jobIds: string[]): Deployment[] {
439 if (!jobIds.length) return [];
440 return this.sql.exec<DeploymentRow>(`SELECT * FROM deployments WHERE job_id IN (${jobIds.map(() => "?").join(",")}) ORDER BY id DESC`, ...jobIds).toArray().map((r) => this.toDeployment(r));
441 }
442
443 reviews(runId: string): DeploymentReview[] {
444 return this.sql.exec<ReviewRow>("SELECT * FROM deployment_reviews WHERE run_id = ? ORDER BY id", runId).toArray().map((r) => this.toReview(r));
445 }
446
447 private toReview = (r: ReviewRow): DeploymentReview => ({ id: r.id, runId: r.run_id, environment: r.environment, state: r.state, comment: r.comment, user: { id: r.user_id, login: r.user_login }, createdAt: r.created_at });
448
449 deleteEnvironment(name: string, caller: Caller): void {
450 this.requireHumanAdmin(caller);
451 this.environment(name);
452 this.sql.exec("DELETE FROM environments WHERE name = ?", name);
453 this.sql.exec("DELETE FROM environment_secrets WHERE environment = ?", name);
454 this.sql.exec("DELETE FROM environment_variables WHERE environment = ?", name);
455 }
456
457 addBranchPattern(name: string, pattern: { name?: string; type?: string }, caller: Caller): { id: number; name: string; type: "branch" | "tag" } {
458 this.requireHumanAdmin(caller);
459 const env = this.environment(name);
460 if (!env.policy?.custom_branch_policies) throw new HttpError(422, "enable custom_branch_policies on the environment first");
461 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) };
462 if (!p.name) throw new HttpError(422, "name is required");
463 env.policy.patterns.push(p);
464 this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id);
465 return p;
466 }
467
468 removeBranchPattern(name: string, id: number, caller: Caller): void {
469 this.requireHumanAdmin(caller);
470 const env = this.environment(name);
471 if (!env.policy) throw new HttpError(404, "branch policy not found");
472 env.policy.patterns = env.policy.patterns.filter((x) => x.id !== id);
473 this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id);
474 }
475
476 /** Whether a run on `ref` may use the environment (and see its secrets). */
477 refAllowed(name: string, ref: string): boolean {
478 const r = this.envRow(name);
479 const policy = r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null;
480 if (!policy) return true;
481 const branch = ref.startsWith("refs/heads/") ? ref.slice(11) : null;
482 const tag = ref.startsWith("refs/tags/") ? ref.slice(10) : null;
483 if (policy.protected_branches) return !!branch && !!this.repo.protectionFor(branch);
484 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)));
485 }
486
487 /** Whether a job may receive the environment's secrets (it already passed refAllowed); see secretsAllowedFor. */
488 secretsAllowed(name: string, ref: string, actorKind: string): boolean {
489 const r = this.envRow(name);
490 const rules = ref.startsWith("refs/heads/") ? this.repo.protectionFor(ref.slice(11)) : null;
491 return secretsAllowedFor(r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, ref, actorKind, rules);
492 }
493
494 // ---------------------------------------------------------------- environment secrets and variables
495
496 private scope(name: string): string {
497 return `repo:${this.repo.meta.repoId}:env:${name.toLowerCase()}`;
498 }
499
500 async setSecret(envName: string, name: string, value: string, caller: Caller): Promise<void> {
501 if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets");
502 if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name");
503 const env = this.environment(envName);
504 // An environment holding secrets defaults to protected branches only, like a fresh GitHub environment should be.
505 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);
506 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());
507 }
508
509 deleteSecret(envName: string, name: string, caller: Caller): void {
510 if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets");
511 this.sql.exec("DELETE FROM environment_secrets WHERE environment = ? AND name = ?", envName, name.toUpperCase());
512 }
513
514 secretNames(envName: string): Array<{ name: string; updatedAt: string }> {
515 this.environment(envName);
516 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 }));
517 }
518
519 setVariable(envName: string, name: string, value: string | null, caller: Caller, mustExist = false): void {
520 if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to manage variables");
521 const env = this.environment(envName);
522 const key = name.toUpperCase();
523 if (value === null) {
524 this.sql.exec("DELETE FROM environment_variables WHERE environment = ? AND name = ?", env.name, key);
525 return;
526 }
527 if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid variable name");
528 const existing = this.sql.exec("SELECT 1 FROM environment_variables WHERE environment = ? AND name = ?", env.name, key).toArray().length > 0;
529 if (mustExist && !existing) throw new HttpError(404, "variable not found");
530 const t = now();
531 if (existing) this.sql.exec("UPDATE environment_variables SET value = ?, updated_at = ? WHERE environment = ? AND name = ?", value, t, env.name, key);
532 else this.sql.exec("INSERT INTO environment_variables (environment, name, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", env.name, key, value, t, t);
533 }
534
535 variables(envName: string): Array<{ name: string; value: string; createdAt: string; updatedAt: string }> {
536 this.environment(envName);
537 return this.sql
538 .exec<{ name: string; value: string; created_at: string; updated_at: string }>("SELECT * FROM environment_variables WHERE environment = ? ORDER BY name", envName)
539 .toArray()
540 .map((r) => ({ name: r.name, value: r.value, createdAt: r.created_at, updatedAt: r.updated_at }));
541 }
542
543 // ---------------------------------------------------------------- workflow jobs with `environment:`
544
545 /**
546 * Called when a job starts waiting on its environment's protection rules: records its
547 * deployment with a `waiting` status, as GitHub shows a deployment waiting for review.
548 */
549 jobWaiting(job: { id: string; ref: string; sha: string; logUrl: string; actor: Person }, name: string): void {
550 if (this.sql.exec("SELECT 1 FROM deployments WHERE job_id = ?", job.id).toArray().length) return;
551 const env = this.ensureEnvironment(name).name;
552 const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" };
553 const id = this.insertJobDeployment(job, env);
554 const t = now();
555 // Not a state the REST API accepts from clients; GitHub reports it the same way for deployments waiting for review.
556 this.sql.exec(
557 "INSERT INTO deployment_statuses (deployment_id, state, description, environment, log_url, creator_id, creator_login, creator_kind, created_at) VALUES (?, 'waiting', ?, ?, ?, ?, ?, ?, ?)",
558 id,
559 "Waiting for the environment's protection rules",
560 env,
561 job.logUrl,
562 system.id,
563 system.login,
564 system.kind,
565 t,
566 );
567 }
568
569 private insertJobDeployment(job: { id: string; ref: string; sha: string; actor: Person }, name: string): number {
570 const t = now();
571 const ref = job.ref.replace(/^refs\/(heads|tags)\//, "");
572 this.sql.exec(
573 "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', ?, ?, '{}', ?, ?, ?, ?, ?, ?, ?)",
574 job.sha,
575 ref,
576 name,
577 name,
578 name.toLowerCase() === "production" ? 1 : 0,
579 job.actor.id,
580 job.actor.login,
581 job.actor.kind,
582 job.id,
583 t,
584 t,
585 );
586 const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id;
587 const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" };
588 this.emit("deployment", "created", { deployment: { id, sha: job.sha, ref, task: "deploy", environment: name, description: null, payload: {}, created_at: t } }, system);
589 return id;
590 }
591
592 /**
593 * Called when a job with `environment:` starts: checks the environment's
594 * branch policy, records a deployment with an in_progress status, and
595 * returns the environment's secrets and variables for the job.
596 */
597 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> }> {
598 const raw = environment.name.trim();
599 if (!ENV_NAME.test(raw)) throw new HttpError(422, `invalid environment name '${raw}'`);
600 const name = this.ensureEnvironment(raw).name;
601 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.`);
602 const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" };
603 const existing = this.sql.exec<{ id: number }>("SELECT id FROM deployments WHERE job_id = ?", job.id).toArray()[0];
604 const environmentUrl = environment.url && /^https?:\/\//i.test(environment.url) ? environment.url : undefined;
605 if (!existing) this.createStatus(this.insertJobDeployment(job, name), { state: "in_progress", log_url: job.logUrl, environment_url: environmentUrl }, system);
606 else if (this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", existing.id).toArray()[0]?.state === "waiting") {
607 // Its protection rules passed: the deployment it recorded while waiting goes on.
608 this.createStatus(existing.id, { state: "in_progress", log_url: job.logUrl, environment_url: environmentUrl }, system);
609 }
610 const secrets: Record<string, string> = {};
611 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() : [];
612 for (const r of rows) {
613 secrets[r.name] = await decryptSecret(this.env.MASTER_KEY, this.scope(name), r.ciphertext);
614 }
615 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]));
616 return { secrets, vars };
617 }
618
619 /** Called when a job finishes: the deployment it recorded gets a final status. */
620 jobCompleted(jobId: string, conclusion: string | null): void {
621 const d = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE job_id = ?", jobId).toArray()[0];
622 if (!d) return;
623 const last = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", d.id).toArray()[0];
624 const state = conclusion === "success" ? "success" : conclusion === "cancelled" || conclusion === "skipped" ? "inactive" : "failure";
625 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" });
626 }
627}