flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/deployments/src/index.ts

1,131 lines46,373 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;
150};
151
152function toDeployment(row: DeploymentRow): Deployment {
153 return {
154 id: row.id,
155 kind: row.kind,
Projects: what a workspace builds and runs, first on every page156 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page157 number: row.number,
158 commit: row.commit_sha,
159 status: row.status,
160 url: appUrl(row.script),
161 error: row.error,
162 warnings: JSON.parse(row.warnings || "[]") as string[],
163 buildSeconds: row.build_seconds,
164 createdBy: row.created_by,
165 createdAt: row.created_at,
166 finishedAt: row.finished_at,
167 };
168}
169
Projects: what a workspace builds and runs, first on every page170function toLive(app: AppRow): LiveApp {
171 return {
172 kind: app.kind,
173 branch: app.branch,
174 number: app.number,
175 url: appUrl(app.script),
176 commit: app.commit_sha,
177 deployedAt: app.deployed_at,
178 };
179}
180
Deployments: a preview for every pull request, production on g1t.page181class Deployments {
182 constructor(private readonly env: Env) {}
183
184 private get cloudflare(): Cloudflare | null {
185 const token = this.env.CLOUDFLARE_API_TOKEN;
186 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
187 }
188
189 private get db() {
190 return this.env.DB;
191 }
192
Projects: what a workspace builds and runs, first on every page193 private get projects() {
194 return projectsClient(this.env.PROJECTS);
195 }
196
Deployments: a preview for every pull request, production on g1t.page197 /** The workspace itself, as the service acts for it. */
198 private async workspaceActor(slug: string): Promise<User | null> {
199 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
200 if (!workspace) return null;
201 return {
202 id: workspace.id,
203 username: workspace.slug,
204 kind: "workspace",
205 verified: true,
206 workspaces: [{ slug: workspace.slug, role: "member" }],
207 };
208 }
209
Secrets and variables: one list, rows per environment, for workflows and deployments210 /**
Projects: what a workspace builds and runs, first on every page211 * What the project's secrets and variables available to deployments give
212 * production or a preview: its build's environment, and the same again as
213 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments214 */
215 private async resolve(
Projects: what a workspace builds and runs, first on every page216 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments217 environment: DeployKind,
218 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know219 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments220 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know221 const [rows, references] = await Promise.all([
222 this.rows(project, environment, trusted),
223 this.references(project.id, environment === "preview" ? branch : null),
224 ]);
225 // The project's own rows win over a dependency's address of the same name.
226 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
227 }
228
229 /**
230 * Each dependency's address, under the name the dependency gives it:
231 * for a preview, the same branch's preview of it if one is up, else its
232 * production; for production, its production.
233 */
234 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
235 const graph = await this.projects.graph(projectId).catch(() => null);
236 const out: Record<string, string> = {};
237 for (const dependency of graph?.dependsOn ?? []) {
238 if (!dependency.as) continue;
239 const app =
240 (branch
241 ? await this.db
242 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
243 .bind(dependency.id, branch)
244 .first<{ script: string }>()
245 : null) ??
246 (await this.db
247 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
248 .bind(dependency.id)
249 .first<{ script: string }>());
250 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
251 }
252 return out;
253 }
254
255 private async rows(
256 project: { id: string; slug: string; repoId: string; repo: RepoPath },
257 environment: DeployKind,
258 trusted: boolean,
259 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments260 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
261 method: "POST",
262 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page263 body: JSON.stringify({
264 repoId: project.repoId,
265 repo: project.repo,
266 projectId: project.id,
267 projectSlug: project.slug,
268 consumer: "deployments",
269 environment,
270 trusted,
271 }),
Secrets and variables: one list, rows per environment, for workflows and deployments272 });
273 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
274 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
275 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
276 }
277
278 /**
Projects: what a workspace builds and runs, first on every page279 * Whether a pull request's author is trusted with the project's secrets:
280 * g1t's agent, or a member of the workspace. Someone from outside gets a
281 * preview built without them, as their workflows run.
Secrets and variables: one list, rows per environment, for workflows and deployments282 */
283 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
284 if (author.kind === "agent" || author.username === "g1t-agent") return true;
285 // On a private repository only members can open one at all.
286 const found = await reposClient(this.env.REPOS).get(repo, actor);
287 if (found.ok && found.value.isPrivate) return true;
288 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
289 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
290 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
291 }
292
Projects: what a workspace builds and runs, first on every page293 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
294 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page295 }
296
Projects: what a workspace builds and runs, first on every page297 /** The name an app gets, unique among apps: production, or a branch's preview. */
298 private async scriptFor(project: Project, branch: string | null): Promise<string> {
299 const base = await label(project.workspace, project.slug, branch);
300 const holder = await this.db
301 .prepare(
302 `SELECT project_id, branch FROM apps WHERE script = ?1
303 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
304 )
305 .bind(base)
306 .first<{ project_id: string; branch: string | null }>();
307 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
308 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
309 }
310
311 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page312 return {
313 enabled: !!row?.enabled,
314 previews: row ? !!row.previews : true,
315 production: row ? !!row.production : true,
316 buildCommand: row?.build_command ?? null,
317 outputDir: row?.output_dir ?? null,
318 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page319 productionUrl: appUrl(await this.scriptFor(project, null)),
Deployments: a preview for every pull request, production on g1t.page320 };
321 }
322
Projects: what a workspace builds and runs, first on every page323 /** The project, if `viewer` belongs to its workspace. */
324 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
325 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
326 return this.projects.get(ref.workspace, ref.slug, viewer);
Deployments: a preview for every pull request, production on g1t.page327 }
328
329 // ---- Methods for the site and the API ------------------------------
330
Projects: what a workspace builds and runs, first on every page331 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
332 const project = await this.memberProject(a.project, a.viewer);
333 if (!project.ok) return project;
334 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page335 }
336
337 async updateSettings(a: {
338 actor: User;
Projects: what a workspace builds and runs, first on every page339 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page340 changes: Partial<DeploySettings>;
341 }): Promise<Result<DeploySettings>> {
Projects: what a workspace builds and runs, first on every page342 const found = await this.memberProject(a.project, a.actor);
343 if (!found.ok) return found;
344 const project = found.value;
345 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page346 const next = { ...before, ...a.changes };
347 if (next.enabled && !before.enabled) {
348 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page349 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page350 if (!plan.ok) return plan;
351 }
352 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
353 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
354 await this.db
355 .prepare(
Projects: what a workspace builds and runs, first on every page356 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
357 output_dir, idle_days, updated_by, updated_at)
358 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
359 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
360 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.page361 )
362 .bind(
Projects: what a workspace builds and runs, first on every page363 project.id,
364 project.workspace,
365 project.slug,
366 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page367 next.enabled ? 1 : 0,
368 next.previews ? 1 : 0,
369 next.production ? 1 : 0,
370 clip(next.buildCommand),
371 clip(next.outputDir),
372 idleDays,
373 a.actor.username,
374 now(),
375 )
376 .run();
377 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page378 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page379 else {
Projects: what a workspace builds and runs, first on every page380 if (!next.previews) await this.takeDownWhere(project.id, "preview");
381 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page382 }
383 // Turned on: production goes up from the default branch at once.
384 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page385 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page386 }
Projects: what a workspace builds and runs, first on every page387 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page388 }
389
Projects: what a workspace builds and runs, first on every page390 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
391 const project = await this.memberProject(a.project, a.viewer);
392 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page393 const [deployments, apps] = await Promise.all([
394 this.db
Projects: what a workspace builds and runs, first on every page395 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
396 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page397 .all<DeploymentRow>(),
398 this.db
Projects: what a workspace builds and runs, first on every page399 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
400 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page401 .all<AppRow>(),
402 ]);
Projects: what a workspace builds and runs, first on every page403 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page404 }
405
Projects: what a workspace builds and runs, first on every page406 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
407 const project = await this.memberProject(a.project, a.viewer);
408 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page409 const row = await this.db
Projects: what a workspace builds and runs, first on every page410 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
411 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page412 .first<DeploymentRow>();
413 if (!row) return fail("not_found", "No such deployment.");
414 return ok({ ...toDeployment(row), log: row.log });
415 }
416
Projects: what a workspace builds and runs, first on every page417 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
418 const found = await this.memberProject(a.project, a.actor);
419 if (!found.ok) return found;
420 const project = found.value;
421 const settings = await this.settingsRow(project.id);
422 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
423 if (a.branch == null) {
424 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
425 }
426 // A branch's preview comes from its pull request.
427 const app = await this.db
428 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
429 .bind(project.id, a.branch)
430 .first<{ number: number }>();
431 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
432 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.page433 }
434
Project dependencies: addresses, preview stacks, Affects, and agents who know435 /**
436 * A preview stack: the projects that use this one get previews of their
437 * own default branch, under the same branch name, so each reaches this
438 * branch's preview through its dependency's variable. A change to an API
439 * can then be clicked through in the apps that call it.
440 */
441 async stack(
442 a: { actor: User; project: ProjectRef; branch: string },
443 background: (work: Promise<unknown>) => void,
444 ): Promise<Result<string[]>> {
445 const found = await this.memberProject(a.project, a.actor);
446 if (!found.ok) return found;
447 const upstream = await this.db
448 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
449 .bind(found.value.id, a.branch)
450 .first();
451 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
452 const graph = await this.projects.graph(found.value.id);
453 const ready: { project: Project; settings: SettingsRow }[] = [];
454 for (const dependent of graph.usedBy) {
455 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
456 if (!project.ok) continue;
457 const settings = await this.settingsRow(project.value.id);
458 if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
459 }
460 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
461 // The builds start after the answer: a person moving on from the page
462 // does not stop them.
463 background(
464 (async () => {
465 for (const { project, settings } of ready) {
466 const actor = await this.workspaceActor(project.workspace);
467 if (!actor) continue;
468 const repo = repoOf(project);
469 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
470 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
471 if (!head) continue;
472 await this.start({
473 project,
474 kind: "preview",
475 branch: a.branch,
476 number: null,
477 commit: head,
478 source: repo.path,
479 reader: actor,
480 createdBy: a.actor.username,
481 settings,
482 // Its own default branch, asked for by a member.
483 trusted: true,
484 });
485 }
486 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
487 );
488 return ok(ready.map(({ project }) => project.name));
489 }
490
Projects: what a workspace builds and runs, first on every page491 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
492 const project = await this.memberProject(a.project, a.actor);
493 if (!project.ok) return project;
494 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page495 return ok(true);
496 }
497
Projects: what a workspace builds and runs, first on every page498 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
499 const workspace = a.workspace.toLowerCase();
500 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
501 const [settings, apps, latest] = await Promise.all([
502 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
503 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
504 this.db
505 .prepare(
506 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
507 )
508 .bind(workspace)
509 .all<DeploymentRow>(),
510 ]);
511 return ok(
512 settings.results.map((row) => {
513 const own = apps.results.filter((app) => app.slug === row.slug);
514 const production = own.find((app) => app.kind === "production");
515 const newest = latest.results.find((d) => d.slug === row.slug);
516 return {
517 slug: row.slug,
518 enabled: !!row.enabled,
519 production: production ? toLive(production) : null,
520 previews: own.filter((app) => app.kind === "preview").length,
521 latest: newest ? toDeployment(newest) : null,
522 };
523 }),
524 );
525 }
526
Deployments: a preview for every pull request, production on g1t.page527 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
528 const slug = a.workspace.toLowerCase();
529 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
530 const [meter, apps] = await Promise.all([
531 this.db
532 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
533 .bind(slug, month())
534 .first<{
535 requests: number;
536 cpu_ms: number;
537 peak_apps: number;
538 build_seconds: number;
539 build_micros: number;
540 counted_at: string | null;
541 }>(),
Projects: what a workspace builds and runs, first on every page542 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.page543 ]);
544 return ok({
545 month: month(),
546 requests: meter?.requests ?? 0,
547 cpuMs: meter?.cpu_ms ?? 0,
548 apps: apps?.n ?? 0,
549 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
550 buildSeconds: meter?.build_seconds ?? 0,
551 buildMicros: meter?.build_micros ?? 0,
552 countedAt: meter?.counted_at ?? null,
553 });
554 }
555
556 // ---- Starting builds -----------------------------------------------
557
558 /**
559 * Opens a deployment and starts its build. Skipped, with the reason
560 * recorded, when the workspace's plan is off.
561 */
562 private async start(input: {
Projects: what a workspace builds and runs, first on every page563 project: Project;
Deployments: a preview for every pull request, production on g1t.page564 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page565 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page566 number: number | null;
567 commit: string;
568 source: RepoPath;
569 reader: User;
570 createdBy: string;
571 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page572 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments573 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page574 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page575 const { project } = input;
576 const repo = repoOf(project);
577 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page578 const id = newId("dpl");
579 const token = randomToken();
Projects: what a workspace builds and runs, first on every page580 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page581 const cloudflare = this.cloudflare;
582 const refused = !plan.ok
583 ? plan.error.message
584 : !cloudflare
585 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
586 : null;
587 await this.db
588 .prepare(
Projects: what a workspace builds and runs, first on every page589 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
590 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
591 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page592 )
593 .bind(
594 id,
Projects: what a workspace builds and runs, first on every page595 project.id,
596 project.workspace,
597 project.slug,
598 repo.id,
599 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page600 input.kind,
Projects: what a workspace builds and runs, first on every page601 input.branch,
Deployments: a preview for every pull request, production on g1t.page602 input.number,
603 input.commit,
604 script,
605 refused ? "skipped" : "queued",
606 refused,
607 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments608 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page609 input.createdBy,
610 now(),
611 refused ? now() : null,
612 )
613 .run();
614 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
615 // Older builds of the same app are replaced by this one.
616 await this.db
617 .prepare(
618 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
619 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
620 )
621 .bind(now(), script, id)
622 .run();
Projects: what a workspace builds and runs, first on every page623 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
624 // What the project's secrets and variables give builds of this kind.
625 const build = await this.resolve(
626 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
627 input.kind,
628 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know629 input.branch,
Projects: what a workspace builds and runs, first on every page630 );
Deployments: a preview for every pull request, production on g1t.page631 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
632 method: "POST",
633 headers: { "content-type": "application/json" },
634 body: JSON.stringify({
635 deployId: id,
636 token,
637 actor: input.reader,
638 source: input.source,
639 commit: input.commit,
Projects: what a workspace builds and runs, first on every page640 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page641 buildCommand: input.settings.build_command,
642 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments643 buildEnv: build.variables,
644 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page645 }),
646 });
647 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
648 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
649 return ok(toDeployment((await this.deploymentRow(id))!));
650 }
651
Projects: what a workspace builds and runs, first on every page652 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
653 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page654 if (!settings?.enabled || !settings.production) return null;
Projects: what a workspace builds and runs, first on every page655 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page656 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page657 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page658 let head = commit;
659 if (!head) {
Projects: what a workspace builds and runs, first on every page660 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
661 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page662 }
663 if (!head) return null;
664 return this.start({
Projects: what a workspace builds and runs, first on every page665 project,
Deployments: a preview for every pull request, production on g1t.page666 kind: "production",
Projects: what a workspace builds and runs, first on every page667 branch: null,
Deployments: a preview for every pull request, production on g1t.page668 number: null,
669 commit: head,
Projects: what a workspace builds and runs, first on every page670 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page671 reader: actor,
672 createdBy,
673 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments674 // The default branch only moves by people and agents with access.
675 trusted: true,
Deployments: a preview for every pull request, production on g1t.page676 });
677 }
678
Projects: what a workspace builds and runs, first on every page679 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
680 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page681 if (!settings?.enabled || !settings.previews) return null;
Projects: what a workspace builds and runs, first on every page682 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page683 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page684 const repo = repoOf(project);
685 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page686 if (!detail.ok) return null;
687 const { pull } = detail.value;
688 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page689 // A pull request from a fork (as g1t's agents work) has no branch here.
690 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page691 if (!force) {
692 // Already built, or being built, at this commit.
693 const same = await this.db
694 .prepare(
Projects: what a workspace builds and runs, first on every page695 `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.page696 AND status IN ('queued', 'building', 'ready')`,
697 )
Projects: what a workspace builds and runs, first on every page698 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page699 .first();
700 if (same) return null;
701 }
702 return this.start({
Projects: what a workspace builds and runs, first on every page703 project,
Deployments: a preview for every pull request, production on g1t.page704 kind: "preview",
Projects: what a workspace builds and runs, first on every page705 branch,
Deployments: a preview for every pull request, production on g1t.page706 number,
707 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page708 source: pull.fork ?? repo.path,
Deployments: a preview for every pull request, production on g1t.page709 // The pull request's fork may be private: read it as its author.
710 reader: pull.author,
711 createdBy,
712 settings,
Projects: what a workspace builds and runs, first on every page713 trusted: await this.insider(repo.path, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page714 });
715 }
716
717 // ---- A build's reports ---------------------------------------------
718
719 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
720 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
721 }
722
723 /** The build, if `token` is its own and it is still under way. */
724 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
725 const row = await this.deploymentRow(id);
726 if (!row?.token_hash || typeof token !== "string") return null;
727 if (row.token_hash !== (await sha256(token))) return null;
728 return row.status === "queued" || row.status === "building" ? row : null;
729 }
730
731 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run732 // Each report, for the logs: a build's own failure says why.
733 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page734 const row = await this.building(id, body.token);
735 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
736 const cloudflare = this.cloudflare;
737 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
738 switch (step) {
739 case "started":
740 await this.db
741 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
742 .bind(now(), id)
743 .run();
744 return Response.json(ok(true));
745 case "session": {
746 const manifest = body.manifest as Manifest | undefined;
747 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
748 const session = await cloudflare.openUpload(row.script, manifest);
749 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
750 }
751 case "finish": {
752 const worker = (body.worker ?? {}) as BuiltWorker;
753 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page754 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page755 try {
Secrets and variables: one list, rows per environment, for workflows and deployments756 // Running apps' secrets and variables are bound here, by g1t:
757 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page758 const runtime = await this.resolve(
759 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
760 row.kind,
761 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know762 row.branch,
Projects: what a workspace builds and runs, first on every page763 );
Deployments: a preview for every pull request, production on g1t.page764 await cloudflare.putScript(
765 row.script,
766 worker,
767 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page768 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments769 runtime,
Deployments: a preview for every pull request, production on g1t.page770 );
771 } catch (error) {
772 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
773 return Response.json(ok(false));
774 }
775 const at = now();
776 await this.db.batch([
777 this.db
778 .prepare(
779 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
780 WHERE id = ?`,
781 )
782 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
783 this.db
784 .prepare(
Projects: what a workspace builds and runs, first on every page785 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
786 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
787 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9`,
Deployments: a preview for every pull request, production on g1t.page788 )
Projects: what a workspace builds and runs, first on every page789 .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.page790 ]);
791 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page792 await this.notePeak(row.workspace);
793 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.page794 return Response.json(ok(true));
795 }
796 case "fail":
797 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
798 return Response.json(ok(true));
799 default:
800 return Response.json(fail("not_found", "No such step."), { status: 404 });
801 }
802 }
803
804 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
805 const row = await this.deploymentRow(id);
806 if (!row || (row.status !== "queued" && row.status !== "building")) return;
807 await this.db
808 .prepare(
809 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
810 WHERE id = ?`,
811 )
812 .bind(message.slice(0, 2000), log, seconds, now(), id)
813 .run();
814 // A failed build still used its sandbox.
815 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page816 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.page817 }
818
819 /** Each build is charged by the second at the container price plus the margin. */
820 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
821 const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond;
822 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page823 const what =
824 row.kind === "preview"
825 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
826 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page827 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page828 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page829 feature: "deployments",
830 costMicros: cost,
831 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page832 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page833 reference: `deploy/${row.id}`,
834 });
835 await this.db
836 .prepare(
837 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
838 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
839 )
Projects: what a workspace builds and runs, first on every page840 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page841 .run();
842 }
843
844 /** Remembers the most apps the workspace had up at once this month. */
Projects: what a workspace builds and runs, first on every page845 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page846 await this.db
847 .prepare(
848 `INSERT INTO meters (namespace, month, peak_apps)
Projects: what a workspace builds and runs, first on every page849 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))
Deployments: a preview for every pull request, production on g1t.page850 ON CONFLICT (namespace, month) DO UPDATE SET
Projects: what a workspace builds and runs, first on every page851 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))`,
Deployments: a preview for every pull request, production on g1t.page852 )
Projects: what a workspace builds and runs, first on every page853 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page854 .run();
855 }
856
Projects: what a workspace builds and runs, first on every page857 /**
858 * The check on the commit: `g1t / deploy`, or, for one of several
859 * projects on a repository, `g1t / deploy (<project>)`.
860 */
861 private async status(
862 repoId: string,
863 sha: string,
864 project: { slug: string; primary: boolean },
865 state: string,
866 description: string,
867 targetUrl: string,
868 ): Promise<void> {
869 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page870 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
871 method: "POST",
872 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page873 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page874 }).catch(() => undefined);
875 }
876
Projects: what a workspace builds and runs, first on every page877 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
878 const projects = await this.projects.byRepo(row.repo_id);
879 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
880 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
881 }
882
Deployments: a preview for every pull request, production on g1t.page883 // ---- Taking apps down ----------------------------------------------
884
885 private async removeApp(script: string): Promise<void> {
886 await this.cloudflare?.deleteScript(script);
887 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
888 }
889
Projects: what a workspace builds and runs, first on every page890 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page891 const apps = await this.db
892 .prepare(
Projects: what a workspace builds and runs, first on every page893 `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.page894 )
Projects: what a workspace builds and runs, first on every page895 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page896 .all<{ script: string }>();
897 for (const app of apps.results) await this.removeApp(app.script);
898 }
899
900 // ---- Events --------------------------------------------------------
901
902 async onEvent(event: G1tEvent): Promise<void> {
903 switch (event.type) {
904 case "pull.opened":
905 case "pull.ready":
Projects: what a workspace builds and runs, first on every page906 case "pull.updated":
907 for (const project of await this.projects.byRepo(event.data.repoId)) {
908 await this.deployPreview(project, event.data.number, "g1t");
909 }
Deployments: a preview for every pull request, production on g1t.page910 break;
911 case "pull.closed":
912 case "pull.merged":
Projects: what a workspace builds and runs, first on every page913 for (const project of await this.projects.byRepo(event.data.repoId)) {
914 const apps = await this.db
915 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
916 .bind(project.id, event.data.number)
917 .all<{ script: string }>();
918 for (const app of apps.results) await this.removeApp(app.script);
919 }
Deployments: a preview for every pull request, production on g1t.page920 break;
Projects: what a workspace builds and runs, first on every page921 case "git.push":
Deployments: a preview for every pull request, production on g1t.page922 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page923 for (const project of await this.projects.byRepo(event.data.repoId)) {
924 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
925 }
Deployments: a preview for every pull request, production on g1t.page926 break;
927 }
928 }
929
930 // ---- The sweep -----------------------------------------------------
931
932 /**
933 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page934 * previews, the apps of workspaces whose plan ended, and scripts no app
935 * holds come down; and a month that is over is charged past its
936 * allowance.
Deployments: a preview for every pull request, production on g1t.page937 */
938 async sweep(): Promise<void> {
939 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
940 const stuck = await this.db
941 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
942 .bind(cutoff)
943 .all<{ id: string }>();
944 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
945
946 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
Projects: what a workspace builds and runs, first on every page947 const workspaces = [...new Set(apps.map((app) => app.workspace))];
Deployments: a preview for every pull request, production on g1t.page948
949 // Apps of workspaces whose plan has ended come down.
950 const billing = billingClient(this.env.BILLING);
951 for (const workspace of workspaces) {
952 const plan = await billing.hasFeature(workspace, "deployments");
953 if (!plan.ok && plan.error.code === "payment_required") {
Projects: what a workspace builds and runs, first on every page954 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.page955 }
956 }
957
Projects: what a workspace builds and runs, first on every page958 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page959 await this.count(apps).catch((error) => console.error("could not count usage", error));
960 await this.takeDownIdle();
961 await this.chargeMonths();
962 }
963
Projects: what a workspace builds and runs, first on every page964 /** Scripts in the namespace that no app holds, such as ones renamed. */
965 private async removeOrphans(apps: AppRow[]): Promise<void> {
966 const cloudflare = this.cloudflare;
967 if (!cloudflare) return;
968 const held = new Set(apps.map((app) => app.script));
969 const building = await this.db
970 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
971 .all<{ script: string }>();
972 for (const row of building.results) held.add(row.script);
973 const cutoff = Date.now() - ORPHAN_AFTER_MS;
974 for (const script of await cloudflare.listScripts()) {
975 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
976 }
977 }
978
Deployments: a preview for every pull request, production on g1t.page979 /** Counts this month's requests and CPU time per workspace, from analytics. */
980 private async count(apps: AppRow[]): Promise<void> {
981 const cloudflare = this.cloudflare;
982 if (!cloudflare || apps.length === 0) return;
983 const start = `${month()}-01T00:00:00Z`;
984 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
985 // Analytics only counts apps that are up; the meter keeps what earlier
986 // apps used by never going down.
987 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
988 for (const app of apps) {
989 const used = totals.get(app.script);
990 if (!used) continue;
Projects: what a workspace builds and runs, first on every page991 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page992 sum.requests += used.requests;
993 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page994 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page995 }
996 const at = now();
Projects: what a workspace builds and runs, first on every page997 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page998 await this.db
999 .prepare(
1000 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1001 ON CONFLICT (namespace, month) DO UPDATE SET
1002 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1003 )
Projects: what a workspace builds and runs, first on every page1004 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1005 .run();
1006 }
1007 // When each preview last answered anyone, for the idle sweep.
1008 const recent = await cloudflare.usage(
1009 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1010 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1011 at,
1012 );
1013 for (const [script, used] of recent) {
1014 if (used.requests > 0) {
1015 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1016 }
1017 }
Projects: what a workspace builds and runs, first on every page1018 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
Deployments: a preview for every pull request, production on g1t.page1019 }
1020
Projects: what a workspace builds and runs, first on every page1021 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page1022 private async takeDownIdle(): Promise<void> {
1023 const idle = await this.db
1024 .prepare(
Projects: what a workspace builds and runs, first on every page1025 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page1026 WHERE apps.kind = 'preview'
1027 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1028 )
1029 .all<{ script: string }>();
1030 for (const { script } of idle.results) await this.removeApp(script);
1031 }
1032
1033 /** Charges each month that is over for what it used past the allowance, once. */
1034 private async chargeMonths(): Promise<void> {
1035 const due = await this.db
1036 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1037 .bind(month())
1038 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
1039 const a = DEPLOYMENTS_ALLOWANCE;
1040 for (const meter of due.results) {
1041 const extraRequests = Math.max(0, meter.requests - a.requests);
1042 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1043 const extraApps = Math.max(0, meter.peak_apps - a.apps);
1044 const cost = Math.ceil(
1045 (extraRequests / 1_000_000) * a.microsPerMillionRequests +
1046 (extraCpu / 1_000_000) * a.microsPerMillionCpuMs +
1047 extraApps * a.microsPerAppMonth,
1048 );
1049 if (cost > 0) {
1050 const parts = [
1051 extraApps && `${extraApps} extra apps`,
1052 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1053 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1054 ].filter(Boolean);
1055 const charged = await billingClient(this.env.BILLING).chargeFeature({
1056 workspace: meter.namespace,
1057 feature: "deployments",
1058 costMicros: cost,
1059 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1060 reference: `deployments/${meter.namespace}/${meter.month}`,
1061 });
1062 if (!charged.ok) continue;
1063 }
1064 await this.db
1065 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1066 .bind(now(), meter.namespace, meter.month)
1067 .run();
1068 }
1069 }
1070}
1071
1072/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know1073async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page1074 switch (method) {
1075 case "settings":
1076 return service.settings(args);
1077 case "update_settings":
1078 return service.updateSettings(args);
1079 case "list":
1080 return service.list(args);
1081 case "get":
1082 return service.get(args);
1083 case "redeploy":
1084 return service.redeploy(args);
1085 case "take_down":
1086 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know1087 case "stack":
1088 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page1089 case "overview":
1090 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page1091 case "usage":
1092 return service.usage(args);
1093 default:
1094 return undefined;
1095 }
1096}
1097
1098export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know1099 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page1100 const { pathname } = new URL(request.url);
1101 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1102 const service = new Deployments(env);
1103 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1104 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1105 if (rpcMatch) {
Project dependencies: addresses, preview stacks, Affects, and agents who know1106 const result = await rpc(service, rpcMatch[1], body, ctx);
Deployments: a preview for every pull request, production on g1t.page1107 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1108 }
1109 // A build's reports, forwarded by the API.
1110 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1111 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1112 return new Response("Not found\n", { status: 404 });
1113 },
1114
1115 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1116 const service = new Deployments(env);
1117 for (const message of batch.messages) {
1118 try {
1119 await service.onEvent(message.body);
1120 message.ack();
1121 } catch (error) {
1122 console.error("deployments could not handle", message.body.type, error);
1123 message.retry();
1124 }
1125 }
1126 },
1127
1128 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1129 await new Deployments(env).sweep();
1130 },
1131} satisfies ExportedHandler<Env, G1tEvent>;