pr_01m47d24b0e6n91zwymwxg0vpx/services/deployments/src/index.ts

1,215 lines50,442 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Deployments: a preview for every pull request, production on g1t.page1/**
Projects: what a workspace builds and runs, first on every page2 * The deployments service: a project's production, deployed from its
3 * default branch on every push, and a live preview of every branch with an
4 * open pull request, on g1t.page.
Deployments: a preview for every pull request, production on g1t.page5 *
6 * It reacts to events (a pull request opened, ready, pushed to, closed or
Projects: what a workspace builds and runs, first on every page7 * merged; a push to the default branch) for every project built from the
8 * repository, asks billing whether the workspace pays for Deployments, and
9 * asks the runner to build the commit in a sandbox. The sandbox reports
10 * back through the API with a token for that build alone; this service
11 * opens the upload of its files and puts the finished app in the Workers
12 * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by
13 * hostname.
Deployments: a preview for every pull request, production on g1t.page14 *
15 * Nothing here is free. A build is charged by the second; requests, CPU
16 * time and apps past the plan's allowance are charged once the month is
17 * over. A Worker runs only while it answers a request, so an app no one
18 * visits costs nothing, and a preview is taken down when its pull request
Projects: what a workspace builds and runs, first on every page19 * closes or after its project's idle days.
Deployments: a preview for every pull request, production on g1t.page20 *
21 * Reached through service bindings (`POST /rpc/<method>`) and, for a
22 * build's reports, through the API (`POST /jobs/<id>/<step>`).
23 */
24
25import {
26 DEPLOYMENTS_ALLOWANCE,
27 billingClient,
28 fail,
29 identityClient,
30 newId,
31 ok,
Projects: what a workspace builds and runs, first on every page32 projectsClient,
Deployments: a preview for every pull request, production on g1t.page33 reposClient,
34 workClient,
35 type DeployKind,
36 type DeploySettings,
37 type DeployStatus,
38 type DeployUsage,
39 type Deployment,
40 type G1tEvent,
41 type LiveApp,
Projects: what a workspace builds and runs, first on every page42 type Project,
43 type ProjectDeploys,
44 type ProjectRef,
Deployments: a preview for every pull request, production on g1t.page45 type RepoPath,
46 type Result,
47 type ServiceBinding,
48 type User,
49 type Viewer,
50} from "@g1t/contracts";
51
52import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Projects: what a workspace builds and runs, first on every page53import { appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page54
55type Env = {
56 DB: D1Database;
57 REPOS: ServiceBinding;
58 WORK: ServiceBinding;
59 IDENTITY: ServiceBinding;
60 BILLING: ServiceBinding;
61 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page62 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments63 /** Secrets and variables: the actions service holds the one store. */
64 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page65 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
66 CLOUDFLARE_API_TOKEN?: string;
67 CLOUDFLARE_ACCOUNT_ID: string;
68 DISPATCH_NAMESPACE: string;
69 SITE: string;
70};
71
72/** A build that has not reported in this long has died. */
73const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page74/** A script in the namespace that no app holds, older than this, is removed. */
75const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page76const LIST_LIMIT = 50;
77const STATUS_CONTEXT = "g1t / deploy";
78
79const now = () => new Date().toISOString();
80const month = (at = new Date()) => at.toISOString().slice(0, 7);
81
82async function sha256(text: string): Promise<string> {
83 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
84 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
85}
86
87function randomToken(): string {
88 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
89}
90
91function isMember(viewer: Viewer, slug: string): boolean {
92 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
93}
94
Projects: what a workspace builds and runs, first on every page95/** The repository a project builds from. */
96function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
97 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
98 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
99}
100
Deployments: a preview for every pull request, production on g1t.page101type SettingsRow = {
Projects: what a workspace builds and runs, first on every page102 project_id: string;
103 workspace: string;
104 slug: string;
Deployments: a preview for every pull request, production on g1t.page105 repo_id: string;
106 enabled: number;
107 previews: number;
108 production: number;
109 build_command: string | null;
110 output_dir: string | null;
111 idle_days: number;
112};
113
114type DeploymentRow = {
115 id: string;
Projects: what a workspace builds and runs, first on every page116 project_id: string;
117 workspace: string;
118 slug: string;
Deployments: a preview for every pull request, production on g1t.page119 repo_id: string;
Projects: what a workspace builds and runs, first on every page120 repo: string;
Deployments: a preview for every pull request, production on g1t.page121 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page122 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page123 number: number | null;
124 commit_sha: string;
125 script: string;
126 status: DeployStatus;
127 error: string | null;
128 warnings: string;
129 log: string | null;
130 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments131 trusted: number;
Deployments: a preview for every pull request, production on g1t.page132 build_seconds: number | null;
133 created_by: string;
134 created_at: string;
135 finished_at: string | null;
136};
137
138type AppRow = {
139 script: string;
Projects: what a workspace builds and runs, first on every page140 project_id: string;
141 workspace: string;
142 slug: string;
Deployments: a preview for every pull request, production on g1t.page143 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page144 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page145 number: number | null;
146 commit_sha: string;
147 deployed_at: string;
148 created_at: string;
149 last_request_at: string | null;
Usage limits: unpaid usage can only go so far150 /** Set while its workspace is over its limit; see `holdToLimits`. */
151 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page152};
153
154function toDeployment(row: DeploymentRow): Deployment {
155 return {
156 id: row.id,
157 kind: row.kind,
Projects: what a workspace builds and runs, first on every page158 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page159 number: row.number,
160 commit: row.commit_sha,
161 status: row.status,
162 url: appUrl(row.script),
163 error: row.error,
164 warnings: JSON.parse(row.warnings || "[]") as string[],
165 buildSeconds: row.build_seconds,
166 createdBy: row.created_by,
167 createdAt: row.created_at,
168 finishedAt: row.finished_at,
169 };
170}
171
Projects: what a workspace builds and runs, first on every page172function toLive(app: AppRow): LiveApp {
173 return {
174 kind: app.kind,
175 branch: app.branch,
176 number: app.number,
177 url: appUrl(app.script),
178 commit: app.commit_sha,
179 deployedAt: app.deployed_at,
180 };
181}
182
Deployments: a preview for every pull request, production on g1t.page183class Deployments {
184 constructor(private readonly env: Env) {}
185
186 private get cloudflare(): Cloudflare | null {
187 const token = this.env.CLOUDFLARE_API_TOKEN;
188 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
189 }
190
191 private get db() {
192 return this.env.DB;
193 }
194
Projects: what a workspace builds and runs, first on every page195 private get projects() {
196 return projectsClient(this.env.PROJECTS);
197 }
198
Deployments: a preview for every pull request, production on g1t.page199 /** The workspace itself, as the service acts for it. */
200 private async workspaceActor(slug: string): Promise<User | null> {
201 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
202 if (!workspace) return null;
203 return {
204 id: workspace.id,
205 username: workspace.slug,
206 kind: "workspace",
207 verified: true,
208 workspaces: [{ slug: workspace.slug, role: "member" }],
209 };
210 }
211
Secrets and variables: one list, rows per environment, for workflows and deployments212 /**
Projects: what a workspace builds and runs, first on every page213 * What the project's secrets and variables available to deployments give
214 * production or a preview: its build's environment, and the same again as
215 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments216 */
217 private async resolve(
Projects: what a workspace builds and runs, first on every page218 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments219 environment: DeployKind,
220 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know221 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments222 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know223 const [rows, references] = await Promise.all([
224 this.rows(project, environment, trusted),
225 this.references(project.id, environment === "preview" ? branch : null),
226 ]);
227 // The project's own rows win over a dependency's address of the same name.
228 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
229 }
230
231 /**
232 * Each dependency's address, under the name the dependency gives it:
233 * for a preview, the same branch's preview of it if one is up, else its
234 * production; for production, its production.
235 */
236 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
237 const graph = await this.projects.graph(projectId).catch(() => null);
238 const out: Record<string, string> = {};
239 for (const dependency of graph?.dependsOn ?? []) {
240 if (!dependency.as) continue;
241 const app =
242 (branch
243 ? await this.db
244 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
245 .bind(dependency.id, branch)
246 .first<{ script: string }>()
247 : null) ??
248 (await this.db
249 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
250 .bind(dependency.id)
251 .first<{ script: string }>());
252 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
253 }
254 return out;
255 }
256
257 private async rows(
258 project: { id: string; slug: string; repoId: string; repo: RepoPath },
259 environment: DeployKind,
260 trusted: boolean,
261 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments262 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
263 method: "POST",
264 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page265 body: JSON.stringify({
266 repoId: project.repoId,
267 repo: project.repo,
268 projectId: project.id,
269 projectSlug: project.slug,
270 consumer: "deployments",
271 environment,
272 trusted,
273 }),
Secrets and variables: one list, rows per environment, for workflows and deployments274 });
275 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
276 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
277 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
278 }
279
280 /**
Projects: what a workspace builds and runs, first on every page281 * Whether a pull request's author is trusted with the project's secrets:
282 * g1t's agent, or a member of the workspace. Someone from outside gets a
283 * preview built without them, as their workflows run.
Secrets and variables: one list, rows per environment, for workflows and deployments284 */
285 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
286 if (author.kind === "agent" || author.username === "g1t-agent") return true;
287 // On a private repository only members can open one at all.
288 const found = await reposClient(this.env.REPOS).get(repo, actor);
289 if (found.ok && found.value.isPrivate) return true;
290 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
291 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
292 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
293 }
294
Projects: what a workspace builds and runs, first on every page295 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
296 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page297 }
298
Projects: what a workspace builds and runs, first on every page299 /** The name an app gets, unique among apps: production, or a branch's preview. */
300 private async scriptFor(project: Project, branch: string | null): Promise<string> {
301 const base = await label(project.workspace, project.slug, branch);
302 const holder = await this.db
303 .prepare(
304 `SELECT project_id, branch FROM apps WHERE script = ?1
305 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
306 )
307 .bind(base)
308 .first<{ project_id: string; branch: string | null }>();
309 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
310 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
311 }
312
313 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page314 return {
315 enabled: !!row?.enabled,
316 previews: row ? !!row.previews : true,
317 production: row ? !!row.production : true,
318 buildCommand: row?.build_command ?? null,
319 outputDir: row?.output_dir ?? null,
320 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page321 productionUrl: appUrl(await this.scriptFor(project, null)),
Deployments: a preview for every pull request, production on g1t.page322 };
323 }
324
Projects: what a workspace builds and runs, first on every page325 /** The project, if `viewer` belongs to its workspace. */
326 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
327 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
328 return this.projects.get(ref.workspace, ref.slug, viewer);
Deployments: a preview for every pull request, production on g1t.page329 }
330
331 // ---- Methods for the site and the API ------------------------------
332
Projects: what a workspace builds and runs, first on every page333 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
334 const project = await this.memberProject(a.project, a.viewer);
335 if (!project.ok) return project;
336 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page337 }
338
339 async updateSettings(a: {
340 actor: User;
Projects: what a workspace builds and runs, first on every page341 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page342 changes: Partial<DeploySettings>;
343 }): Promise<Result<DeploySettings>> {
Projects: what a workspace builds and runs, first on every page344 const found = await this.memberProject(a.project, a.actor);
345 if (!found.ok) return found;
346 const project = found.value;
347 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page348 const next = { ...before, ...a.changes };
349 if (next.enabled && !before.enabled) {
350 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page351 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page352 if (!plan.ok) return plan;
353 }
354 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
355 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
356 await this.db
357 .prepare(
Projects: what a workspace builds and runs, first on every page358 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
359 output_dir, idle_days, updated_by, updated_at)
360 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
361 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
362 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
Deployments: a preview for every pull request, production on g1t.page363 )
364 .bind(
Projects: what a workspace builds and runs, first on every page365 project.id,
366 project.workspace,
367 project.slug,
368 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page369 next.enabled ? 1 : 0,
370 next.previews ? 1 : 0,
371 next.production ? 1 : 0,
372 clip(next.buildCommand),
373 clip(next.outputDir),
374 idleDays,
375 a.actor.username,
376 now(),
377 )
378 .run();
379 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page380 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page381 else {
Projects: what a workspace builds and runs, first on every page382 if (!next.previews) await this.takeDownWhere(project.id, "preview");
383 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page384 }
385 // Turned on: production goes up from the default branch at once.
386 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page387 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page388 }
Projects: what a workspace builds and runs, first on every page389 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page390 }
391
Projects: what a workspace builds and runs, first on every page392 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
393 const project = await this.memberProject(a.project, a.viewer);
394 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page395 const [deployments, apps] = await Promise.all([
396 this.db
Projects: what a workspace builds and runs, first on every page397 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
398 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page399 .all<DeploymentRow>(),
400 this.db
Projects: what a workspace builds and runs, first on every page401 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
402 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page403 .all<AppRow>(),
404 ]);
Projects: what a workspace builds and runs, first on every page405 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page406 }
407
Projects: what a workspace builds and runs, first on every page408 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
409 const project = await this.memberProject(a.project, a.viewer);
410 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page411 const row = await this.db
Projects: what a workspace builds and runs, first on every page412 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
413 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page414 .first<DeploymentRow>();
415 if (!row) return fail("not_found", "No such deployment.");
416 return ok({ ...toDeployment(row), log: row.log });
417 }
418
Projects: what a workspace builds and runs, first on every page419 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
420 const found = await this.memberProject(a.project, a.actor);
421 if (!found.ok) return found;
422 const project = found.value;
423 const settings = await this.settingsRow(project.id);
424 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
425 if (a.branch == null) {
426 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
427 }
428 // A branch's preview comes from its pull request.
429 const app = await this.db
430 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
431 .bind(project.id, a.branch)
432 .first<{ number: number }>();
433 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
434 return (await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open.");
Deployments: a preview for every pull request, production on g1t.page435 }
436
Project dependencies: addresses, preview stacks, Affects, and agents who know437 /**
438 * A preview stack: the projects that use this one get previews of their
439 * own default branch, under the same branch name, so each reaches this
440 * branch's preview through its dependency's variable. A change to an API
441 * can then be clicked through in the apps that call it.
442 */
443 async stack(
444 a: { actor: User; project: ProjectRef; branch: string },
445 background: (work: Promise<unknown>) => void,
446 ): Promise<Result<string[]>> {
447 const found = await this.memberProject(a.project, a.actor);
448 if (!found.ok) return found;
449 const upstream = await this.db
450 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
451 .bind(found.value.id, a.branch)
452 .first();
453 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
454 const graph = await this.projects.graph(found.value.id);
455 const ready: { project: Project; settings: SettingsRow }[] = [];
456 for (const dependent of graph.usedBy) {
457 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
458 if (!project.ok) continue;
459 const settings = await this.settingsRow(project.value.id);
460 if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
461 }
462 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
463 // The builds start after the answer: a person moving on from the page
464 // does not stop them.
465 background(
466 (async () => {
467 for (const { project, settings } of ready) {
468 const actor = await this.workspaceActor(project.workspace);
469 if (!actor) continue;
470 const repo = repoOf(project);
471 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
472 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
473 if (!head) continue;
474 await this.start({
475 project,
476 kind: "preview",
477 branch: a.branch,
478 number: null,
479 commit: head,
480 source: repo.path,
481 reader: actor,
482 createdBy: a.actor.username,
483 settings,
484 // Its own default branch, asked for by a member.
485 trusted: true,
486 });
487 }
488 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
489 );
490 return ok(ready.map(({ project }) => project.name));
491 }
492
Projects: what a workspace builds and runs, first on every page493 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
494 const project = await this.memberProject(a.project, a.actor);
495 if (!project.ok) return project;
496 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page497 return ok(true);
498 }
499
Projects: what a workspace builds and runs, first on every page500 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
501 const workspace = a.workspace.toLowerCase();
502 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
503 const [settings, apps, latest] = await Promise.all([
504 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
505 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
506 this.db
507 .prepare(
508 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
509 )
510 .bind(workspace)
511 .all<DeploymentRow>(),
512 ]);
513 return ok(
514 settings.results.map((row) => {
515 const own = apps.results.filter((app) => app.slug === row.slug);
516 const production = own.find((app) => app.kind === "production");
517 const newest = latest.results.find((d) => d.slug === row.slug);
518 return {
519 slug: row.slug,
520 enabled: !!row.enabled,
521 production: production ? toLive(production) : null,
522 previews: own.filter((app) => app.kind === "preview").length,
523 latest: newest ? toDeployment(newest) : null,
524 };
525 }),
526 );
527 }
528
Deployments: a preview for every pull request, production on g1t.page529 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
530 const slug = a.workspace.toLowerCase();
531 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
532 const [meter, apps] = await Promise.all([
533 this.db
534 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
535 .bind(slug, month())
536 .first<{
537 requests: number;
538 cpu_ms: number;
539 peak_apps: number;
540 build_seconds: number;
541 build_micros: number;
542 counted_at: string | null;
543 }>(),
Projects: what a workspace builds and runs, first on every page544 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
Deployments: a preview for every pull request, production on g1t.page545 ]);
546 return ok({
547 month: month(),
548 requests: meter?.requests ?? 0,
549 cpuMs: meter?.cpu_ms ?? 0,
550 apps: apps?.n ?? 0,
551 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
552 buildSeconds: meter?.build_seconds ?? 0,
553 buildMicros: meter?.build_micros ?? 0,
554 countedAt: meter?.counted_at ?? null,
555 });
556 }
557
558 // ---- Starting builds -----------------------------------------------
559
560 /**
561 * Opens a deployment and starts its build. Skipped, with the reason
562 * recorded, when the workspace's plan is off.
563 */
564 private async start(input: {
Projects: what a workspace builds and runs, first on every page565 project: Project;
Deployments: a preview for every pull request, production on g1t.page566 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page567 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page568 number: number | null;
569 commit: string;
570 source: RepoPath;
571 reader: User;
572 createdBy: string;
573 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page574 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments575 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page576 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page577 const { project } = input;
578 const repo = repoOf(project);
579 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page580 const id = newId("dpl");
581 const token = randomToken();
Projects: what a workspace builds and runs, first on every page582 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far583 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page584 const cloudflare = this.cloudflare;
585 const refused = !plan.ok
586 ? plan.error.message
Usage limits: unpaid usage can only go so far587 : limit.ok && limit.value.state === "stopped"
588 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page589 : !cloudflare
590 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
591 : null;
592 await this.db
593 .prepare(
Projects: what a workspace builds and runs, first on every page594 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
595 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
596 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page597 )
598 .bind(
599 id,
Projects: what a workspace builds and runs, first on every page600 project.id,
601 project.workspace,
602 project.slug,
603 repo.id,
604 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page605 input.kind,
Projects: what a workspace builds and runs, first on every page606 input.branch,
Deployments: a preview for every pull request, production on g1t.page607 input.number,
608 input.commit,
609 script,
610 refused ? "skipped" : "queued",
611 refused,
612 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments613 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page614 input.createdBy,
615 now(),
616 refused ? now() : null,
617 )
618 .run();
619 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
620 // Older builds of the same app are replaced by this one.
621 await this.db
622 .prepare(
623 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
624 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
625 )
626 .bind(now(), script, id)
627 .run();
Projects: what a workspace builds and runs, first on every page628 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
629 // What the project's secrets and variables give builds of this kind.
630 const build = await this.resolve(
631 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
632 input.kind,
633 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know634 input.branch,
Projects: what a workspace builds and runs, first on every page635 );
Deployments: a preview for every pull request, production on g1t.page636 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
637 method: "POST",
638 headers: { "content-type": "application/json" },
639 body: JSON.stringify({
640 deployId: id,
641 token,
642 actor: input.reader,
643 source: input.source,
644 commit: input.commit,
Projects: what a workspace builds and runs, first on every page645 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page646 buildCommand: input.settings.build_command,
647 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments648 buildEnv: build.variables,
649 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page650 }),
651 });
652 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
653 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
654 return ok(toDeployment((await this.deploymentRow(id))!));
655 }
656
Projects: what a workspace builds and runs, first on every page657 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
658 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page659 if (!settings?.enabled || !settings.production) return null;
Projects: what a workspace builds and runs, first on every page660 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page661 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page662 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page663 let head = commit;
664 if (!head) {
Projects: what a workspace builds and runs, first on every page665 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
666 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page667 }
668 if (!head) return null;
669 return this.start({
Projects: what a workspace builds and runs, first on every page670 project,
Deployments: a preview for every pull request, production on g1t.page671 kind: "production",
Projects: what a workspace builds and runs, first on every page672 branch: null,
Deployments: a preview for every pull request, production on g1t.page673 number: null,
674 commit: head,
Projects: what a workspace builds and runs, first on every page675 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page676 reader: actor,
677 createdBy,
678 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments679 // The default branch only moves by people and agents with access.
680 trusted: true,
Deployments: a preview for every pull request, production on g1t.page681 });
682 }
683
Projects: what a workspace builds and runs, first on every page684 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
685 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page686 if (!settings?.enabled || !settings.previews) return null;
Projects: what a workspace builds and runs, first on every page687 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page688 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page689 const repo = repoOf(project);
690 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page691 if (!detail.ok) return null;
692 const { pull } = detail.value;
693 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page694 // A pull request from a fork (as g1t's agents work) has no branch here.
695 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page696 if (!force) {
697 // Already built, or being built, at this commit.
698 const same = await this.db
699 .prepare(
Projects: what a workspace builds and runs, first on every page700 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
Deployments: a preview for every pull request, production on g1t.page701 AND status IN ('queued', 'building', 'ready')`,
702 )
Projects: what a workspace builds and runs, first on every page703 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page704 .first();
705 if (same) return null;
706 }
707 return this.start({
Projects: what a workspace builds and runs, first on every page708 project,
Deployments: a preview for every pull request, production on g1t.page709 kind: "preview",
Projects: what a workspace builds and runs, first on every page710 branch,
Deployments: a preview for every pull request, production on g1t.page711 number,
712 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page713 source: pull.fork ?? repo.path,
Deployments: a preview for every pull request, production on g1t.page714 // The pull request's fork may be private: read it as its author.
715 reader: pull.author,
716 createdBy,
717 settings,
Projects: what a workspace builds and runs, first on every page718 trusted: await this.insider(repo.path, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page719 });
720 }
721
722 // ---- A build's reports ---------------------------------------------
723
724 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
725 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
726 }
727
728 /** The build, if `token` is its own and it is still under way. */
729 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
730 const row = await this.deploymentRow(id);
731 if (!row?.token_hash || typeof token !== "string") return null;
732 if (row.token_hash !== (await sha256(token))) return null;
733 return row.status === "queued" || row.status === "building" ? row : null;
734 }
735
736 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run737 // Each report, for the logs: a build's own failure says why.
738 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page739 const row = await this.building(id, body.token);
740 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
741 const cloudflare = this.cloudflare;
742 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
743 switch (step) {
744 case "started":
745 await this.db
746 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
747 .bind(now(), id)
748 .run();
749 return Response.json(ok(true));
750 case "session": {
751 const manifest = body.manifest as Manifest | undefined;
752 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
753 const session = await cloudflare.openUpload(row.script, manifest);
754 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
755 }
756 case "finish": {
757 const worker = (body.worker ?? {}) as BuiltWorker;
758 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page759 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page760 try {
Secrets and variables: one list, rows per environment, for workflows and deployments761 // Running apps' secrets and variables are bound here, by g1t:
762 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page763 const runtime = await this.resolve(
764 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
765 row.kind,
766 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know767 row.branch,
Projects: what a workspace builds and runs, first on every page768 );
Deployments: a preview for every pull request, production on g1t.page769 await cloudflare.putScript(
770 row.script,
771 worker,
772 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page773 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments774 runtime,
Deployments: a preview for every pull request, production on g1t.page775 );
776 } catch (error) {
777 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
778 return Response.json(ok(false));
779 }
780 const at = now();
781 await this.db.batch([
782 this.db
783 .prepare(
784 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
785 WHERE id = ?`,
786 )
787 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
788 this.db
789 .prepare(
Projects: what a workspace builds and runs, first on every page790 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
791 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far792 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`,
Deployments: a preview for every pull request, production on g1t.page793 )
Projects: what a workspace builds and runs, first on every page794 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
Deployments: a preview for every pull request, production on g1t.page795 ]);
796 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page797 await this.notePeak(row.workspace);
798 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
Deployments: a preview for every pull request, production on g1t.page799 return Response.json(ok(true));
800 }
801 case "fail":
802 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
803 return Response.json(ok(true));
804 default:
805 return Response.json(fail("not_found", "No such step."), { status: 404 });
806 }
807 }
808
809 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
810 const row = await this.deploymentRow(id);
811 if (!row || (row.status !== "queued" && row.status !== "building")) return;
812 await this.db
813 .prepare(
814 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
815 WHERE id = ?`,
816 )
817 .bind(message.slice(0, 2000), log, seconds, now(), id)
818 .run();
819 // A failed build still used its sandbox.
820 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page821 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Deployments: a preview for every pull request, production on g1t.page822 }
823
824 /** Each build is charged by the second at the container price plus the margin. */
825 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays826 const costs = await this.costs();
827 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page828 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page829 const what =
830 row.kind === "preview"
831 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
832 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page833 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page834 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page835 feature: "deployments",
836 costMicros: cost,
837 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page838 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page839 reference: `deploy/${row.id}`,
840 });
841 await this.db
842 .prepare(
843 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
844 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
845 )
Projects: what a workspace builds and runs, first on every page846 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page847 .run();
848 }
849
Prices keep themselves current with what g1t pays850 /**
851 * What each unit costs g1t now, from billing's price book, which follows
852 * what Cloudflare bills. The plan's figures if billing cannot say.
853 */
854 private async costs(): Promise<{ buildSecond: number; millionRequests: number; millionCpuMs: number; appMonth: number }> {
855 const a = DEPLOYMENTS_ALLOWANCE;
856 const book = await billingClient(this.env.BILLING)
857 .prices()
858 .catch(() => null);
859 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
860 return {
861 buildSecond: cost("build_second", a.microsPerBuildSecond),
862 millionRequests: cost("app_requests", a.microsPerMillionRequests),
863 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
864 appMonth: cost("app_month", a.microsPerAppMonth),
865 };
866 }
867
Deployments: a preview for every pull request, production on g1t.page868 /** Remembers the most apps the workspace had up at once this month. */
Projects: what a workspace builds and runs, first on every page869 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page870 await this.db
871 .prepare(
872 `INSERT INTO meters (namespace, month, peak_apps)
Projects: what a workspace builds and runs, first on every page873 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))
Deployments: a preview for every pull request, production on g1t.page874 ON CONFLICT (namespace, month) DO UPDATE SET
Projects: what a workspace builds and runs, first on every page875 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))`,
Deployments: a preview for every pull request, production on g1t.page876 )
Projects: what a workspace builds and runs, first on every page877 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page878 .run();
879 }
880
Projects: what a workspace builds and runs, first on every page881 /**
882 * The check on the commit: `g1t / deploy`, or, for one of several
883 * projects on a repository, `g1t / deploy (<project>)`.
884 */
885 private async status(
886 repoId: string,
887 sha: string,
888 project: { slug: string; primary: boolean },
889 state: string,
890 description: string,
891 targetUrl: string,
892 ): Promise<void> {
893 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page894 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
895 method: "POST",
896 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page897 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page898 }).catch(() => undefined);
899 }
900
Projects: what a workspace builds and runs, first on every page901 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
902 const projects = await this.projects.byRepo(row.repo_id);
903 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
904 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
905 }
906
Deployments: a preview for every pull request, production on g1t.page907 // ---- Taking apps down ----------------------------------------------
908
909 private async removeApp(script: string): Promise<void> {
910 await this.cloudflare?.deleteScript(script);
911 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
912 }
913
Projects: what a workspace builds and runs, first on every page914 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page915 const apps = await this.db
916 .prepare(
Projects: what a workspace builds and runs, first on every page917 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
Deployments: a preview for every pull request, production on g1t.page918 )
Projects: what a workspace builds and runs, first on every page919 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page920 .all<{ script: string }>();
921 for (const app of apps.results) await this.removeApp(app.script);
922 }
923
924 // ---- Events --------------------------------------------------------
925
926 async onEvent(event: G1tEvent): Promise<void> {
927 switch (event.type) {
928 case "pull.opened":
929 case "pull.ready":
Projects: what a workspace builds and runs, first on every page930 case "pull.updated":
931 for (const project of await this.projects.byRepo(event.data.repoId)) {
932 await this.deployPreview(project, event.data.number, "g1t");
933 }
Deployments: a preview for every pull request, production on g1t.page934 break;
935 case "pull.closed":
936 case "pull.merged":
Projects: what a workspace builds and runs, first on every page937 for (const project of await this.projects.byRepo(event.data.repoId)) {
938 const apps = await this.db
939 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
940 .bind(project.id, event.data.number)
941 .all<{ script: string }>();
942 for (const app of apps.results) await this.removeApp(app.script);
943 }
Deployments: a preview for every pull request, production on g1t.page944 break;
Projects: what a workspace builds and runs, first on every page945 case "git.push":
Deployments: a preview for every pull request, production on g1t.page946 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page947 for (const project of await this.projects.byRepo(event.data.repoId)) {
948 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
949 }
Deployments: a preview for every pull request, production on g1t.page950 break;
951 }
952 }
953
954 // ---- The sweep -----------------------------------------------------
955
956 /**
957 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page958 * previews, the apps of workspaces whose plan ended, and scripts no app
959 * holds come down; and a month that is over is charged past its
960 * allowance.
Deployments: a preview for every pull request, production on g1t.page961 */
962 async sweep(): Promise<void> {
963 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
964 const stuck = await this.db
965 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
966 .bind(cutoff)
967 .all<{ id: string }>();
968 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
969
970 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
Projects: what a workspace builds and runs, first on every page971 const workspaces = [...new Set(apps.map((app) => app.workspace))];
Deployments: a preview for every pull request, production on g1t.page972
973 // Apps of workspaces whose plan has ended come down.
974 const billing = billingClient(this.env.BILLING);
Usage limits: unpaid usage can only go so far975 await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page976 for (const workspace of workspaces) {
977 const plan = await billing.hasFeature(workspace, "deployments");
978 if (!plan.ok && plan.error.code === "payment_required") {
Projects: what a workspace builds and runs, first on every page979 for (const app of apps.filter((a) => a.workspace === workspace)) await this.removeApp(app.script);
Deployments: a preview for every pull request, production on g1t.page980 }
981 }
982
Projects: what a workspace builds and runs, first on every page983 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page984 await this.count(apps).catch((error) => console.error("could not count usage", error));
985 await this.takeDownIdle();
986 await this.chargeMonths();
987 }
988
Usage limits: unpaid usage can only go so far989 /**
990 * Pauses the apps of workspaces that reached their limit for usage not
991 * yet paid for, and rebuilds them from the same commit once they are
992 * under it again. Paused apps answer with a notice and run nothing.
993 */
994 private async holdToLimits(apps: AppRow[]): Promise<void> {
995 const cloudflare = this.cloudflare;
996 if (!cloudflare) return;
997 const billing = billingClient(this.env.BILLING);
998 for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
999 const limit = await billing.checkLimit(workspace);
1000 if (!limit.ok) continue;
1001 const theirs = apps.filter((app) => app.workspace === workspace);
1002 if (limit.value.state === "stopped") {
1003 for (const app of theirs.filter((a) => !a.paused_at)) {
1004 await cloudflare.pauseScript(app.script);
1005 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1006 }
1007 continue;
1008 }
1009 const paused = theirs.filter((a) => a.paused_at);
1010 if (paused.length === 0) continue;
1011 const actor = await this.workspaceActor(workspace);
1012 if (!actor) continue;
1013 for (const app of paused) {
1014 const project = await this.projects.get(workspace, app.slug, actor);
1015 if (!project.ok) continue;
1016 // A failed or refused rebuild leaves it paused, to try again next time.
1017 const rebuilt =
1018 app.kind === "production"
1019 ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1020 : app.number != null
1021 ? await this.deployPreview(project.value, app.number, "g1t", true)
1022 : null;
1023 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1024 }
1025 }
1026 }
1027
Projects: what a workspace builds and runs, first on every page1028 /** Scripts in the namespace that no app holds, such as ones renamed. */
1029 private async removeOrphans(apps: AppRow[]): Promise<void> {
1030 const cloudflare = this.cloudflare;
1031 if (!cloudflare) return;
1032 const held = new Set(apps.map((app) => app.script));
1033 const building = await this.db
1034 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1035 .all<{ script: string }>();
1036 for (const row of building.results) held.add(row.script);
1037 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1038 for (const script of await cloudflare.listScripts()) {
1039 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1040 }
1041 }
1042
Deployments: a preview for every pull request, production on g1t.page1043 /** Counts this month's requests and CPU time per workspace, from analytics. */
1044 private async count(apps: AppRow[]): Promise<void> {
1045 const cloudflare = this.cloudflare;
1046 if (!cloudflare || apps.length === 0) return;
1047 const start = `${month()}-01T00:00:00Z`;
1048 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1049 // Analytics only counts apps that are up; the meter keeps what earlier
1050 // apps used by never going down.
1051 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1052 for (const app of apps) {
1053 const used = totals.get(app.script);
1054 if (!used) continue;
Projects: what a workspace builds and runs, first on every page1055 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page1056 sum.requests += used.requests;
1057 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page1058 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page1059 }
1060 const at = now();
Projects: what a workspace builds and runs, first on every page1061 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page1062 await this.db
1063 .prepare(
1064 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1065 ON CONFLICT (namespace, month) DO UPDATE SET
1066 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1067 )
Projects: what a workspace builds and runs, first on every page1068 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1069 .run();
1070 }
1071 // When each preview last answered anyone, for the idle sweep.
1072 const recent = await cloudflare.usage(
1073 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1074 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1075 at,
1076 );
1077 for (const [script, used] of recent) {
1078 if (used.requests > 0) {
1079 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1080 }
1081 }
Projects: what a workspace builds and runs, first on every page1082 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
Prices keep themselves current with what g1t pays1083 // What this month's traffic past the plan will cost, so the workspace's
1084 // limit counts it now rather than when the month closes.
1085 const costs = await this.costs();
1086 const a = DEPLOYMENTS_ALLOWANCE;
1087 for (const workspace of perWorkspace.keys()) {
1088 const meter = await this.db
1089 .prepare("SELECT requests, cpu_ms, peak_apps FROM meters WHERE namespace = ? AND month = ?")
1090 .bind(workspace, month())
1091 .first<{ requests: number; cpu_ms: number; peak_apps: number }>();
1092 if (!meter) continue;
1093 const cost = Math.ceil(
1094 (Math.max(0, meter.requests - a.requests) / 1_000_000) * costs.millionRequests +
1095 (Math.max(0, meter.cpu_ms - a.cpuMs) / 1_000_000) * costs.millionCpuMs +
1096 Math.max(0, meter.peak_apps - a.apps) * costs.appMonth,
1097 );
1098 await billingClient(this.env.BILLING)
1099 .notePending(workspace, "deployments", cost)
1100 .catch((error) => console.error("could not note pending usage", error));
1101 }
Deployments: a preview for every pull request, production on g1t.page1102 }
1103
Projects: what a workspace builds and runs, first on every page1104 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page1105 private async takeDownIdle(): Promise<void> {
1106 const idle = await this.db
1107 .prepare(
Projects: what a workspace builds and runs, first on every page1108 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page1109 WHERE apps.kind = 'preview'
1110 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1111 )
1112 .all<{ script: string }>();
1113 for (const { script } of idle.results) await this.removeApp(script);
1114 }
1115
1116 /** Charges each month that is over for what it used past the allowance, once. */
1117 private async chargeMonths(): Promise<void> {
1118 const due = await this.db
1119 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1120 .bind(month())
1121 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
1122 const a = DEPLOYMENTS_ALLOWANCE;
Prices keep themselves current with what g1t pays1123 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page1124 for (const meter of due.results) {
1125 const extraRequests = Math.max(0, meter.requests - a.requests);
1126 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1127 const extraApps = Math.max(0, meter.peak_apps - a.apps);
1128 const cost = Math.ceil(
Prices keep themselves current with what g1t pays1129 (extraRequests / 1_000_000) * costs.millionRequests +
1130 (extraCpu / 1_000_000) * costs.millionCpuMs +
1131 extraApps * costs.appMonth,
Deployments: a preview for every pull request, production on g1t.page1132 );
1133 if (cost > 0) {
1134 const parts = [
1135 extraApps && `${extraApps} extra apps`,
1136 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1137 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1138 ].filter(Boolean);
1139 const charged = await billingClient(this.env.BILLING).chargeFeature({
1140 workspace: meter.namespace,
1141 feature: "deployments",
1142 costMicros: cost,
1143 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1144 reference: `deployments/${meter.namespace}/${meter.month}`,
1145 });
1146 if (!charged.ok) continue;
1147 }
1148 await this.db
1149 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1150 .bind(now(), meter.namespace, meter.month)
1151 .run();
1152 }
1153 }
1154}
1155
1156/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know1157async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page1158 switch (method) {
1159 case "settings":
1160 return service.settings(args);
1161 case "update_settings":
1162 return service.updateSettings(args);
1163 case "list":
1164 return service.list(args);
1165 case "get":
1166 return service.get(args);
1167 case "redeploy":
1168 return service.redeploy(args);
1169 case "take_down":
1170 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know1171 case "stack":
1172 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page1173 case "overview":
1174 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page1175 case "usage":
1176 return service.usage(args);
1177 default:
1178 return undefined;
1179 }
1180}
1181
1182export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know1183 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page1184 const { pathname } = new URL(request.url);
1185 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1186 const service = new Deployments(env);
1187 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1188 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1189 if (rpcMatch) {
Project dependencies: addresses, preview stacks, Affects, and agents who know1190 const result = await rpc(service, rpcMatch[1], body, ctx);
Deployments: a preview for every pull request, production on g1t.page1191 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1192 }
1193 // A build's reports, forwarded by the API.
1194 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1195 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1196 return new Response("Not found\n", { status: 404 });
1197 },
1198
1199 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1200 const service = new Deployments(env);
1201 for (const message of batch.messages) {
1202 try {
1203 await service.onEvent(message.body);
1204 message.ack();
1205 } catch (error) {
1206 console.error("deployments could not handle", message.body.type, error);
1207 message.retry();
1208 }
1209 }
1210 },
1211
1212 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1213 await new Deployments(env).sweep();
1214 },
1215} satisfies ExportedHandler<Env, G1tEvent>;