flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/deployments/src/index.ts

1,176 lines48,576 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> {
826 const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond;
827 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page828 const what =
829 row.kind === "preview"
830 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
831 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page832 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page833 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page834 feature: "deployments",
835 costMicros: cost,
836 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page837 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page838 reference: `deploy/${row.id}`,
839 });
840 await this.db
841 .prepare(
842 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
843 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
844 )
Projects: what a workspace builds and runs, first on every page845 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page846 .run();
847 }
848
849 /** Remembers the most apps the workspace had up at once this month. */
Projects: what a workspace builds and runs, first on every page850 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page851 await this.db
852 .prepare(
853 `INSERT INTO meters (namespace, month, peak_apps)
Projects: what a workspace builds and runs, first on every page854 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))
Deployments: a preview for every pull request, production on g1t.page855 ON CONFLICT (namespace, month) DO UPDATE SET
Projects: what a workspace builds and runs, first on every page856 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))`,
Deployments: a preview for every pull request, production on g1t.page857 )
Projects: what a workspace builds and runs, first on every page858 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page859 .run();
860 }
861
Projects: what a workspace builds and runs, first on every page862 /**
863 * The check on the commit: `g1t / deploy`, or, for one of several
864 * projects on a repository, `g1t / deploy (<project>)`.
865 */
866 private async status(
867 repoId: string,
868 sha: string,
869 project: { slug: string; primary: boolean },
870 state: string,
871 description: string,
872 targetUrl: string,
873 ): Promise<void> {
874 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page875 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
876 method: "POST",
877 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page878 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page879 }).catch(() => undefined);
880 }
881
Projects: what a workspace builds and runs, first on every page882 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
883 const projects = await this.projects.byRepo(row.repo_id);
884 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
885 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
886 }
887
Deployments: a preview for every pull request, production on g1t.page888 // ---- Taking apps down ----------------------------------------------
889
890 private async removeApp(script: string): Promise<void> {
891 await this.cloudflare?.deleteScript(script);
892 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
893 }
894
Projects: what a workspace builds and runs, first on every page895 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page896 const apps = await this.db
897 .prepare(
Projects: what a workspace builds and runs, first on every page898 `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.page899 )
Projects: what a workspace builds and runs, first on every page900 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page901 .all<{ script: string }>();
902 for (const app of apps.results) await this.removeApp(app.script);
903 }
904
905 // ---- Events --------------------------------------------------------
906
907 async onEvent(event: G1tEvent): Promise<void> {
908 switch (event.type) {
909 case "pull.opened":
910 case "pull.ready":
Projects: what a workspace builds and runs, first on every page911 case "pull.updated":
912 for (const project of await this.projects.byRepo(event.data.repoId)) {
913 await this.deployPreview(project, event.data.number, "g1t");
914 }
Deployments: a preview for every pull request, production on g1t.page915 break;
916 case "pull.closed":
917 case "pull.merged":
Projects: what a workspace builds and runs, first on every page918 for (const project of await this.projects.byRepo(event.data.repoId)) {
919 const apps = await this.db
920 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
921 .bind(project.id, event.data.number)
922 .all<{ script: string }>();
923 for (const app of apps.results) await this.removeApp(app.script);
924 }
Deployments: a preview for every pull request, production on g1t.page925 break;
Projects: what a workspace builds and runs, first on every page926 case "git.push":
Deployments: a preview for every pull request, production on g1t.page927 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page928 for (const project of await this.projects.byRepo(event.data.repoId)) {
929 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
930 }
Deployments: a preview for every pull request, production on g1t.page931 break;
932 }
933 }
934
935 // ---- The sweep -----------------------------------------------------
936
937 /**
938 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page939 * previews, the apps of workspaces whose plan ended, and scripts no app
940 * holds come down; and a month that is over is charged past its
941 * allowance.
Deployments: a preview for every pull request, production on g1t.page942 */
943 async sweep(): Promise<void> {
944 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
945 const stuck = await this.db
946 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
947 .bind(cutoff)
948 .all<{ id: string }>();
949 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
950
951 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
Projects: what a workspace builds and runs, first on every page952 const workspaces = [...new Set(apps.map((app) => app.workspace))];
Deployments: a preview for every pull request, production on g1t.page953
954 // Apps of workspaces whose plan has ended come down.
955 const billing = billingClient(this.env.BILLING);
Usage limits: unpaid usage can only go so far956 await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page957 for (const workspace of workspaces) {
958 const plan = await billing.hasFeature(workspace, "deployments");
959 if (!plan.ok && plan.error.code === "payment_required") {
Projects: what a workspace builds and runs, first on every page960 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.page961 }
962 }
963
Projects: what a workspace builds and runs, first on every page964 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page965 await this.count(apps).catch((error) => console.error("could not count usage", error));
966 await this.takeDownIdle();
967 await this.chargeMonths();
968 }
969
Usage limits: unpaid usage can only go so far970 /**
971 * Pauses the apps of workspaces that reached their limit for usage not
972 * yet paid for, and rebuilds them from the same commit once they are
973 * under it again. Paused apps answer with a notice and run nothing.
974 */
975 private async holdToLimits(apps: AppRow[]): Promise<void> {
976 const cloudflare = this.cloudflare;
977 if (!cloudflare) return;
978 const billing = billingClient(this.env.BILLING);
979 for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
980 const limit = await billing.checkLimit(workspace);
981 if (!limit.ok) continue;
982 const theirs = apps.filter((app) => app.workspace === workspace);
983 if (limit.value.state === "stopped") {
984 for (const app of theirs.filter((a) => !a.paused_at)) {
985 await cloudflare.pauseScript(app.script);
986 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
987 }
988 continue;
989 }
990 const paused = theirs.filter((a) => a.paused_at);
991 if (paused.length === 0) continue;
992 const actor = await this.workspaceActor(workspace);
993 if (!actor) continue;
994 for (const app of paused) {
995 const project = await this.projects.get(workspace, app.slug, actor);
996 if (!project.ok) continue;
997 // A failed or refused rebuild leaves it paused, to try again next time.
998 const rebuilt =
999 app.kind === "production"
1000 ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1001 : app.number != null
1002 ? await this.deployPreview(project.value, app.number, "g1t", true)
1003 : null;
1004 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1005 }
1006 }
1007 }
1008
Projects: what a workspace builds and runs, first on every page1009 /** Scripts in the namespace that no app holds, such as ones renamed. */
1010 private async removeOrphans(apps: AppRow[]): Promise<void> {
1011 const cloudflare = this.cloudflare;
1012 if (!cloudflare) return;
1013 const held = new Set(apps.map((app) => app.script));
1014 const building = await this.db
1015 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1016 .all<{ script: string }>();
1017 for (const row of building.results) held.add(row.script);
1018 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1019 for (const script of await cloudflare.listScripts()) {
1020 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1021 }
1022 }
1023
Deployments: a preview for every pull request, production on g1t.page1024 /** Counts this month's requests and CPU time per workspace, from analytics. */
1025 private async count(apps: AppRow[]): Promise<void> {
1026 const cloudflare = this.cloudflare;
1027 if (!cloudflare || apps.length === 0) return;
1028 const start = `${month()}-01T00:00:00Z`;
1029 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1030 // Analytics only counts apps that are up; the meter keeps what earlier
1031 // apps used by never going down.
1032 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1033 for (const app of apps) {
1034 const used = totals.get(app.script);
1035 if (!used) continue;
Projects: what a workspace builds and runs, first on every page1036 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page1037 sum.requests += used.requests;
1038 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page1039 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page1040 }
1041 const at = now();
Projects: what a workspace builds and runs, first on every page1042 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page1043 await this.db
1044 .prepare(
1045 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1046 ON CONFLICT (namespace, month) DO UPDATE SET
1047 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1048 )
Projects: what a workspace builds and runs, first on every page1049 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1050 .run();
1051 }
1052 // When each preview last answered anyone, for the idle sweep.
1053 const recent = await cloudflare.usage(
1054 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1055 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1056 at,
1057 );
1058 for (const [script, used] of recent) {
1059 if (used.requests > 0) {
1060 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1061 }
1062 }
Projects: what a workspace builds and runs, first on every page1063 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.page1064 }
1065
Projects: what a workspace builds and runs, first on every page1066 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page1067 private async takeDownIdle(): Promise<void> {
1068 const idle = await this.db
1069 .prepare(
Projects: what a workspace builds and runs, first on every page1070 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page1071 WHERE apps.kind = 'preview'
1072 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1073 )
1074 .all<{ script: string }>();
1075 for (const { script } of idle.results) await this.removeApp(script);
1076 }
1077
1078 /** Charges each month that is over for what it used past the allowance, once. */
1079 private async chargeMonths(): Promise<void> {
1080 const due = await this.db
1081 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1082 .bind(month())
1083 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
1084 const a = DEPLOYMENTS_ALLOWANCE;
1085 for (const meter of due.results) {
1086 const extraRequests = Math.max(0, meter.requests - a.requests);
1087 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1088 const extraApps = Math.max(0, meter.peak_apps - a.apps);
1089 const cost = Math.ceil(
1090 (extraRequests / 1_000_000) * a.microsPerMillionRequests +
1091 (extraCpu / 1_000_000) * a.microsPerMillionCpuMs +
1092 extraApps * a.microsPerAppMonth,
1093 );
1094 if (cost > 0) {
1095 const parts = [
1096 extraApps && `${extraApps} extra apps`,
1097 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1098 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1099 ].filter(Boolean);
1100 const charged = await billingClient(this.env.BILLING).chargeFeature({
1101 workspace: meter.namespace,
1102 feature: "deployments",
1103 costMicros: cost,
1104 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1105 reference: `deployments/${meter.namespace}/${meter.month}`,
1106 });
1107 if (!charged.ok) continue;
1108 }
1109 await this.db
1110 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1111 .bind(now(), meter.namespace, meter.month)
1112 .run();
1113 }
1114 }
1115}
1116
1117/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know1118async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page1119 switch (method) {
1120 case "settings":
1121 return service.settings(args);
1122 case "update_settings":
1123 return service.updateSettings(args);
1124 case "list":
1125 return service.list(args);
1126 case "get":
1127 return service.get(args);
1128 case "redeploy":
1129 return service.redeploy(args);
1130 case "take_down":
1131 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know1132 case "stack":
1133 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page1134 case "overview":
1135 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page1136 case "usage":
1137 return service.usage(args);
1138 default:
1139 return undefined;
1140 }
1141}
1142
1143export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know1144 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page1145 const { pathname } = new URL(request.url);
1146 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1147 const service = new Deployments(env);
1148 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1149 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1150 if (rpcMatch) {
Project dependencies: addresses, preview stacks, Affects, and agents who know1151 const result = await rpc(service, rpcMatch[1], body, ctx);
Deployments: a preview for every pull request, production on g1t.page1152 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1153 }
1154 // A build's reports, forwarded by the API.
1155 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1156 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1157 return new Response("Not found\n", { status: 404 });
1158 },
1159
1160 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1161 const service = new Deployments(env);
1162 for (const message of batch.messages) {
1163 try {
1164 await service.onEvent(message.body);
1165 message.ack();
1166 } catch (error) {
1167 console.error("deployments could not handle", message.body.type, error);
1168 message.retry();
1169 }
1170 }
1171 },
1172
1173 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1174 await new Deployments(env).sweep();
1175 },
1176} satisfies ExportedHandler<Env, G1tEvent>;