g1t/services/deployments/src/index.ts

1,567 lines67,665 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 *
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put15 * Nothing here is free. The plan includes 200 build minutes a month, and
16 * builds past them are charged by the second; requests, CPU
Deployments: a preview for every pull request, production on g1t.page17 * time and apps past the plan's allowance are charged once the month is
18 * over. A Worker runs only while it answers a request, so an app no one
19 * visits costs nothing, and a preview is taken down when its pull request
Projects: what a workspace builds and runs, first on every page20 * closes or after its project's idle days.
Deployments: a preview for every pull request, production on g1t.page21 *
22 * Reached through service bindings (`POST /rpc/<method>`) and, for a
23 * build's reports, through the API (`POST /jobs/<id>/<step>`).
24 */
25
26import {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains27 CUSTOM_DOMAIN_TARGET,
Deployments: a preview for every pull request, production on g1t.page28 DEPLOYMENTS_ALLOWANCE,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains29 SLUG_HOLD_DAYS,
Deployments: a preview for every pull request, production on g1t.page30 billingClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains31 currentWorkspaceSlug,
Deployments: a preview for every pull request, production on g1t.page32 fail,
33 identityClient,
34 newId,
35 ok,
Projects: what a workspace builds and runs, first on every page36 projectsClient,
Deployments: a preview for every pull request, production on g1t.page37 reposClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains38 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page39 workClient,
40 type DeployKind,
41 type DeploySettings,
42 type DeployStatus,
43 type DeployUsage,
44 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains45 type Domain,
Deployments: a preview for every pull request, production on g1t.page46 type G1tEvent,
47 type LiveApp,
Projects: what a workspace builds and runs, first on every page48 type Project,
49 type ProjectDeploys,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains50 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page51 type ProjectRef,
Deployments: a preview for every pull request, production on g1t.page52 type RepoPath,
53 type Result,
54 type ServiceBinding,
55 type User,
56 type Viewer,
57} from "@g1t/contracts";
58
59import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains60import { CustomHostnames } from "./custom-hostnames";
61import { Domains, NOT_ENABLED_NOTICE, extraDomains, toDomain } from "./domains";
62import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page63
64type Env = {
65 DB: D1Database;
66 REPOS: ServiceBinding;
67 WORK: ServiceBinding;
68 IDENTITY: ServiceBinding;
69 BILLING: ServiceBinding;
70 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page71 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments72 /** Secrets and variables: the actions service holds the one store. */
73 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page74 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
75 CLOUDFLARE_API_TOKEN?: string;
76 CLOUDFLARE_ACCOUNT_ID: string;
77 DISPATCH_NAMESPACE: string;
78 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains79 /** Custom domains: hostname to app, read by the dispatcher. */
80 DOMAINS?: KVNamespace;
81 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
82 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Deployments: a preview for every pull request, production on g1t.page83};
84
85/** A build that has not reported in this long has died. */
86const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page87/** A script in the namespace that no app holds, older than this, is removed. */
88const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page89const LIST_LIMIT = 50;
90const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains91/**
92 * Deliveries of `workspace.renamed` that wait for the projects service to
93 * have seen it too, before going ahead with the new slug regardless.
94 */
95const RENAME_WAITS = 3;
Deployments: a preview for every pull request, production on g1t.page96
97const now = () => new Date().toISOString();
98const month = (at = new Date()) => at.toISOString().slice(0, 7);
99
100async function sha256(text: string): Promise<string> {
101 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
102 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
103}
104
105function randomToken(): string {
106 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
107}
108
109function isMember(viewer: Viewer, slug: string): boolean {
110 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
111}
112
Projects: what a workspace builds and runs, first on every page113/** The repository a project builds from. */
114function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
115 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
116 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
117}
118
Deployments: a preview for every pull request, production on g1t.page119type SettingsRow = {
Projects: what a workspace builds and runs, first on every page120 project_id: string;
121 workspace: string;
122 slug: string;
Deployments: a preview for every pull request, production on g1t.page123 repo_id: string;
124 enabled: number;
125 previews: number;
126 production: number;
127 build_command: string | null;
128 output_dir: string | null;
129 idle_days: number;
130};
131
132type DeploymentRow = {
133 id: string;
Projects: what a workspace builds and runs, first on every page134 project_id: string;
135 workspace: string;
136 slug: string;
Deployments: a preview for every pull request, production on g1t.page137 repo_id: string;
Projects: what a workspace builds and runs, first on every page138 repo: string;
Deployments: a preview for every pull request, production on g1t.page139 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page140 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page141 number: number | null;
142 commit_sha: string;
143 script: string;
144 status: DeployStatus;
145 error: string | null;
146 warnings: string;
147 log: string | null;
148 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments149 trusted: number;
Deployments: a preview for every pull request, production on g1t.page150 build_seconds: number | null;
151 created_by: string;
152 created_at: string;
153 finished_at: string | null;
154};
155
156type AppRow = {
157 script: string;
Projects: what a workspace builds and runs, first on every page158 project_id: string;
159 workspace: string;
160 slug: string;
Deployments: a preview for every pull request, production on g1t.page161 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page162 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page163 number: number | null;
164 commit_sha: string;
165 deployed_at: string;
166 created_at: string;
167 last_request_at: string | null;
Usage limits: unpaid usage can only go so far168 /** Set while its workspace is over its limit; see `holdToLimits`. */
169 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page170};
171
172function toDeployment(row: DeploymentRow): Deployment {
173 return {
174 id: row.id,
175 kind: row.kind,
Projects: what a workspace builds and runs, first on every page176 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page177 number: row.number,
178 commit: row.commit_sha,
179 status: row.status,
180 url: appUrl(row.script),
181 error: row.error,
182 warnings: JSON.parse(row.warnings || "[]") as string[],
183 buildSeconds: row.build_seconds,
184 createdBy: row.created_by,
185 createdAt: row.created_at,
186 finishedAt: row.finished_at,
187 };
188}
189
Projects: what a workspace builds and runs, first on every page190function toLive(app: AppRow): LiveApp {
191 return {
192 kind: app.kind,
193 branch: app.branch,
194 number: app.number,
195 url: appUrl(app.script),
196 commit: app.commit_sha,
197 deployedAt: app.deployed_at,
198 };
199}
200
Deployments: a preview for every pull request, production on g1t.page201class Deployments {
202 constructor(private readonly env: Env) {}
203
204 private get cloudflare(): Cloudflare | null {
205 const token = this.env.CLOUDFLARE_API_TOKEN;
206 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
207 }
208
209 private get db() {
210 return this.env.DB;
211 }
212
Agents and memory, checks and conflicts, profiles, slug renames, custom domains213 private get domains(): Domains {
214 const token = this.env.CLOUDFLARE_API_TOKEN;
215 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
216 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
217 }
218
Projects: what a workspace builds and runs, first on every page219 private get projects() {
220 return projectsClient(this.env.PROJECTS);
221 }
222
Deployments: a preview for every pull request, production on g1t.page223 /** The workspace itself, as the service acts for it. */
224 private async workspaceActor(slug: string): Promise<User | null> {
225 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
226 if (!workspace) return null;
227 return {
228 id: workspace.id,
229 username: workspace.slug,
230 kind: "workspace",
231 verified: true,
232 workspaces: [{ slug: workspace.slug, role: "member" }],
233 };
234 }
235
Secrets and variables: one list, rows per environment, for workflows and deployments236 /**
Projects: what a workspace builds and runs, first on every page237 * What the project's secrets and variables available to deployments give
238 * production or a preview: its build's environment, and the same again as
239 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments240 */
241 private async resolve(
Projects: what a workspace builds and runs, first on every page242 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments243 environment: DeployKind,
244 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know245 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments246 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know247 const [rows, references] = await Promise.all([
248 this.rows(project, environment, trusted),
249 this.references(project.id, environment === "preview" ? branch : null),
250 ]);
251 // The project's own rows win over a dependency's address of the same name.
252 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
253 }
254
255 /**
256 * Each dependency's address, under the name the dependency gives it:
257 * for a preview, the same branch's preview of it if one is up, else its
258 * production; for production, its production.
259 */
260 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
261 const graph = await this.projects.graph(projectId).catch(() => null);
262 const out: Record<string, string> = {};
263 for (const dependency of graph?.dependsOn ?? []) {
264 if (!dependency.as) continue;
265 const app =
266 (branch
267 ? await this.db
268 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
269 .bind(dependency.id, branch)
270 .first<{ script: string }>()
271 : null) ??
272 (await this.db
273 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
274 .bind(dependency.id)
275 .first<{ script: string }>());
276 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
277 }
278 return out;
279 }
280
281 private async rows(
282 project: { id: string; slug: string; repoId: string; repo: RepoPath },
283 environment: DeployKind,
284 trusted: boolean,
285 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments286 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
287 method: "POST",
288 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page289 body: JSON.stringify({
290 repoId: project.repoId,
291 repo: project.repo,
292 projectId: project.id,
293 projectSlug: project.slug,
294 consumer: "deployments",
295 environment,
296 trusted,
297 }),
Secrets and variables: one list, rows per environment, for workflows and deployments298 });
299 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
300 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
301 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
302 }
303
304 /**
Projects: what a workspace builds and runs, first on every page305 * Whether a pull request's author is trusted with the project's secrets:
306 * g1t's agent, or a member of the workspace. Someone from outside gets a
307 * preview built without them, as their workflows run.
Secrets and variables: one list, rows per environment, for workflows and deployments308 */
309 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
310 if (author.kind === "agent" || author.username === "g1t-agent") return true;
311 // On a private repository only members can open one at all.
312 const found = await reposClient(this.env.REPOS).get(repo, actor);
313 if (found.ok && found.value.isPrivate) return true;
314 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
315 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
316 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
317 }
318
Projects: what a workspace builds and runs, first on every page319 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
320 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page321 }
322
Projects: what a workspace builds and runs, first on every page323 /** The name an app gets, unique among apps: production, or a branch's preview. */
324 private async scriptFor(project: Project, branch: string | null): Promise<string> {
325 const base = await label(project.workspace, project.slug, branch);
326 const holder = await this.db
327 .prepare(
328 `SELECT project_id, branch FROM apps WHERE script = ?1
329 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
330 )
331 .bind(base)
332 .first<{ project_id: string; branch: string | null }>();
333 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
334 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
335 }
336
337 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page338 return {
339 enabled: !!row?.enabled,
340 previews: row ? !!row.previews : true,
341 production: row ? !!row.production : true,
342 buildCommand: row?.build_command ?? null,
343 outputDir: row?.output_dir ?? null,
344 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page345 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains346 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Deployments: a preview for every pull request, production on g1t.page347 };
348 }
349
Projects: what a workspace builds and runs, first on every page350 /** The project, if `viewer` belongs to its workspace. */
351 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
352 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
353 return this.projects.get(ref.workspace, ref.slug, viewer);
Deployments: a preview for every pull request, production on g1t.page354 }
355
356 // ---- Methods for the site and the API ------------------------------
357
Projects: what a workspace builds and runs, first on every page358 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
359 const project = await this.memberProject(a.project, a.viewer);
360 if (!project.ok) return project;
361 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page362 }
363
364 async updateSettings(a: {
365 actor: User;
Projects: what a workspace builds and runs, first on every page366 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page367 changes: Partial<DeploySettings>;
368 }): Promise<Result<DeploySettings>> {
Projects: what a workspace builds and runs, first on every page369 const found = await this.memberProject(a.project, a.actor);
370 if (!found.ok) return found;
371 const project = found.value;
372 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page373 const next = { ...before, ...a.changes };
374 if (next.enabled && !before.enabled) {
375 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page376 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page377 if (!plan.ok) return plan;
378 }
379 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
380 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
381 await this.db
382 .prepare(
Projects: what a workspace builds and runs, first on every page383 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
384 output_dir, idle_days, updated_by, updated_at)
385 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
386 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
387 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.page388 )
389 .bind(
Projects: what a workspace builds and runs, first on every page390 project.id,
391 project.workspace,
392 project.slug,
393 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page394 next.enabled ? 1 : 0,
395 next.previews ? 1 : 0,
396 next.production ? 1 : 0,
397 clip(next.buildCommand),
398 clip(next.outputDir),
399 idleDays,
400 a.actor.username,
401 now(),
402 )
403 .run();
404 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page405 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page406 else {
Projects: what a workspace builds and runs, first on every page407 if (!next.previews) await this.takeDownWhere(project.id, "preview");
408 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page409 }
410 // Turned on: production goes up from the default branch at once.
411 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page412 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page413 }
Projects: what a workspace builds and runs, first on every page414 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page415 }
416
Projects: what a workspace builds and runs, first on every page417 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
418 const project = await this.memberProject(a.project, a.viewer);
419 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page420 const [deployments, apps] = await Promise.all([
421 this.db
Projects: what a workspace builds and runs, first on every page422 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
423 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page424 .all<DeploymentRow>(),
425 this.db
Projects: what a workspace builds and runs, first on every page426 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
427 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page428 .all<AppRow>(),
429 ]);
Projects: what a workspace builds and runs, first on every page430 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page431 }
432
Projects: what a workspace builds and runs, first on every page433 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
434 const project = await this.memberProject(a.project, a.viewer);
435 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page436 const row = await this.db
Projects: what a workspace builds and runs, first on every page437 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
438 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page439 .first<DeploymentRow>();
440 if (!row) return fail("not_found", "No such deployment.");
441 return ok({ ...toDeployment(row), log: row.log });
442 }
443
Projects: what a workspace builds and runs, first on every page444 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
445 const found = await this.memberProject(a.project, a.actor);
446 if (!found.ok) return found;
447 const project = found.value;
448 const settings = await this.settingsRow(project.id);
449 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
450 if (a.branch == null) {
451 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
452 }
453 // A branch's preview comes from its pull request.
454 const app = await this.db
455 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
456 .bind(project.id, a.branch)
457 .first<{ number: number }>();
458 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
459 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.page460 }
461
Project dependencies: addresses, preview stacks, Affects, and agents who know462 /**
463 * A preview stack: the projects that use this one get previews of their
464 * own default branch, under the same branch name, so each reaches this
465 * branch's preview through its dependency's variable. A change to an API
466 * can then be clicked through in the apps that call it.
467 */
468 async stack(
469 a: { actor: User; project: ProjectRef; branch: string },
470 background: (work: Promise<unknown>) => void,
471 ): Promise<Result<string[]>> {
472 const found = await this.memberProject(a.project, a.actor);
473 if (!found.ok) return found;
474 const upstream = await this.db
475 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
476 .bind(found.value.id, a.branch)
477 .first();
478 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
479 const graph = await this.projects.graph(found.value.id);
480 const ready: { project: Project; settings: SettingsRow }[] = [];
481 for (const dependent of graph.usedBy) {
482 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
483 if (!project.ok) continue;
484 const settings = await this.settingsRow(project.value.id);
485 if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
486 }
487 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
488 // The builds start after the answer: a person moving on from the page
489 // does not stop them.
490 background(
491 (async () => {
492 for (const { project, settings } of ready) {
493 const actor = await this.workspaceActor(project.workspace);
494 if (!actor) continue;
495 const repo = repoOf(project);
496 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
497 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
498 if (!head) continue;
499 await this.start({
500 project,
501 kind: "preview",
502 branch: a.branch,
503 number: null,
504 commit: head,
505 source: repo.path,
506 reader: actor,
507 createdBy: a.actor.username,
508 settings,
509 // Its own default branch, asked for by a member.
510 trusted: true,
511 });
512 }
513 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
514 );
515 return ok(ready.map(({ project }) => project.name));
516 }
517
Projects: what a workspace builds and runs, first on every page518 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
519 const project = await this.memberProject(a.project, a.actor);
520 if (!project.ok) return project;
521 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page522 return ok(true);
523 }
524
Projects: what a workspace builds and runs, first on every page525 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
526 const workspace = a.workspace.toLowerCase();
527 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
528 const [settings, apps, latest] = await Promise.all([
529 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
530 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
531 this.db
532 .prepare(
533 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
534 )
535 .bind(workspace)
536 .all<DeploymentRow>(),
537 ]);
538 return ok(
539 settings.results.map((row) => {
540 const own = apps.results.filter((app) => app.slug === row.slug);
541 const production = own.find((app) => app.kind === "production");
542 const newest = latest.results.find((d) => d.slug === row.slug);
543 return {
544 slug: row.slug,
545 enabled: !!row.enabled,
546 production: production ? toLive(production) : null,
547 previews: own.filter((app) => app.kind === "preview").length,
548 latest: newest ? toDeployment(newest) : null,
549 };
550 }),
551 );
552 }
553
Deployments: a preview for every pull request, production on g1t.page554 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
555 const slug = a.workspace.toLowerCase();
556 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
557 const [meter, apps] = await Promise.all([
558 this.db
559 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
560 .bind(slug, month())
561 .first<{
562 requests: number;
563 cpu_ms: number;
564 peak_apps: number;
565 build_seconds: number;
566 build_micros: number;
567 counted_at: string | null;
568 }>(),
Projects: what a workspace builds and runs, first on every page569 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.page570 ]);
571 return ok({
572 month: month(),
573 requests: meter?.requests ?? 0,
574 cpuMs: meter?.cpu_ms ?? 0,
575 apps: apps?.n ?? 0,
576 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
577 buildSeconds: meter?.build_seconds ?? 0,
578 buildMicros: meter?.build_micros ?? 0,
579 countedAt: meter?.counted_at ?? null,
580 });
581 }
582
Agents and memory, checks and conflicts, profiles, slug renames, custom domains583 // ---- Custom domains ------------------------------------------------
584
585 async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> {
586 const project = await this.memberProject(a.project, a.viewer);
587 if (!project.ok) return project;
588 const domains = this.domains;
589 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
590 const [rows, available, used] = await Promise.all([
591 domains.forProject(project.value.id),
592 domains.available(),
593 domains.countFor(project.value.workspace),
594 ]);
595 return ok({
596 domains: rows.map(toDomain),
597 target: CUSTOM_DOMAIN_TARGET,
598 available,
599 notice: available ? null : NOT_ENABLED_NOTICE,
600 included: DEPLOYMENTS_ALLOWANCE.customDomains,
601 used,
602 });
603 }
604
605 async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> {
606 const found = await this.memberProject(a.project, a.actor);
607 if (!found.ok) return found;
608 const project = found.value;
609 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
610 if (!plan.ok) return plan;
611 const added = await this.domains.add({
612 project: { id: project.id, workspace: project.workspace, slug: project.slug },
613 script: await this.productionScript(project),
614 hostname: String(a.hostname ?? ""),
615 twin: !!a.twin,
616 by: a.actor.username,
617 });
618 if (!added.ok) return fail(added.code, added.message);
619 await this.notePeak(project.workspace);
620 return ok(added.rows.map(toDomain));
621 }
622
623 async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> {
624 const found = await this.memberProject(a.project, a.actor);
625 if (!found.ok) return found;
626 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
627 if (!row) return fail("not_found", "No such domain.");
628 await this.domains.remove(row);
629 return ok(true);
630 }
631
632 async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> {
633 const found = await this.memberProject(a.project, a.actor);
634 if (!found.ok) return found;
635 const domains = this.domains;
636 const row = await domains.byId(found.value.id, String(a.id ?? ""));
637 if (!row) return fail("not_found", "No such domain.");
638 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
639 await this.notePeak(found.value.workspace);
640 return ok(toDomain(after));
641 }
642
643 /** The script production is up under, or the name it will have. */
644 private async productionScript(project: Project): Promise<string> {
645 const app = await this.db
646 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
647 .bind(project.id)
648 .first<{ script: string }>();
649 return app?.script ?? (await this.scriptFor(project, null));
650 }
651
Deployments: a preview for every pull request, production on g1t.page652 // ---- Starting builds -----------------------------------------------
653
654 /**
655 * Opens a deployment and starts its build. Skipped, with the reason
656 * recorded, when the workspace's plan is off.
657 */
658 private async start(input: {
Projects: what a workspace builds and runs, first on every page659 project: Project;
Deployments: a preview for every pull request, production on g1t.page660 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page661 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page662 number: number | null;
663 commit: string;
664 source: RepoPath;
665 reader: User;
666 createdBy: string;
667 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page668 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments669 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page670 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page671 const { project } = input;
672 const repo = repoOf(project);
673 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page674 const id = newId("dpl");
675 const token = randomToken();
Projects: what a workspace builds and runs, first on every page676 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far677 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page678 const cloudflare = this.cloudflare;
679 const refused = !plan.ok
680 ? plan.error.message
Usage limits: unpaid usage can only go so far681 : limit.ok && limit.value.state === "stopped"
682 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page683 : !cloudflare
684 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
685 : null;
686 await this.db
687 .prepare(
Projects: what a workspace builds and runs, first on every page688 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
689 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
690 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page691 )
692 .bind(
693 id,
Projects: what a workspace builds and runs, first on every page694 project.id,
695 project.workspace,
696 project.slug,
697 repo.id,
698 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page699 input.kind,
Projects: what a workspace builds and runs, first on every page700 input.branch,
Deployments: a preview for every pull request, production on g1t.page701 input.number,
702 input.commit,
703 script,
704 refused ? "skipped" : "queued",
705 refused,
706 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments707 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page708 input.createdBy,
709 now(),
710 refused ? now() : null,
711 )
712 .run();
713 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
714 // Older builds of the same app are replaced by this one.
715 await this.db
716 .prepare(
717 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
718 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
719 )
720 .bind(now(), script, id)
721 .run();
Projects: what a workspace builds and runs, first on every page722 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
723 // What the project's secrets and variables give builds of this kind.
724 const build = await this.resolve(
725 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
726 input.kind,
727 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know728 input.branch,
Projects: what a workspace builds and runs, first on every page729 );
Deployments: a preview for every pull request, production on g1t.page730 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
731 method: "POST",
732 headers: { "content-type": "application/json" },
733 body: JSON.stringify({
734 deployId: id,
735 token,
736 actor: input.reader,
737 source: input.source,
738 commit: input.commit,
Projects: what a workspace builds and runs, first on every page739 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page740 buildCommand: input.settings.build_command,
741 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments742 buildEnv: build.variables,
743 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page744 }),
745 });
746 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
747 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
748 return ok(toDeployment((await this.deploymentRow(id))!));
749 }
750
Projects: what a workspace builds and runs, first on every page751 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
752 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page753 if (!settings?.enabled || !settings.production) return null;
Projects: what a workspace builds and runs, first on every page754 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page755 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page756 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page757 let head = commit;
758 if (!head) {
Projects: what a workspace builds and runs, first on every page759 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
760 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page761 }
762 if (!head) return null;
763 return this.start({
Projects: what a workspace builds and runs, first on every page764 project,
Deployments: a preview for every pull request, production on g1t.page765 kind: "production",
Projects: what a workspace builds and runs, first on every page766 branch: null,
Deployments: a preview for every pull request, production on g1t.page767 number: null,
768 commit: head,
Projects: what a workspace builds and runs, first on every page769 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page770 reader: actor,
771 createdBy,
772 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments773 // The default branch only moves by people and agents with access.
774 trusted: true,
Deployments: a preview for every pull request, production on g1t.page775 });
776 }
777
Projects: what a workspace builds and runs, first on every page778 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
779 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page780 if (!settings?.enabled || !settings.previews) return null;
Projects: what a workspace builds and runs, first on every page781 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page782 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page783 const repo = repoOf(project);
784 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page785 if (!detail.ok) return null;
786 const { pull } = detail.value;
787 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page788 // A pull request from a fork (as g1t's agents work) has no branch here.
789 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page790 if (!force) {
791 // Already built, or being built, at this commit.
792 const same = await this.db
793 .prepare(
Projects: what a workspace builds and runs, first on every page794 `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.page795 AND status IN ('queued', 'building', 'ready')`,
796 )
Projects: what a workspace builds and runs, first on every page797 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page798 .first();
799 if (same) return null;
800 }
801 return this.start({
Projects: what a workspace builds and runs, first on every page802 project,
Deployments: a preview for every pull request, production on g1t.page803 kind: "preview",
Projects: what a workspace builds and runs, first on every page804 branch,
Deployments: a preview for every pull request, production on g1t.page805 number,
806 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page807 source: pull.fork ?? repo.path,
Deployments: a preview for every pull request, production on g1t.page808 // The pull request's fork may be private: read it as its author.
809 reader: pull.author,
810 createdBy,
811 settings,
Projects: what a workspace builds and runs, first on every page812 trusted: await this.insider(repo.path, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page813 });
814 }
815
816 // ---- A build's reports ---------------------------------------------
817
818 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
819 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
820 }
821
822 /** The build, if `token` is its own and it is still under way. */
823 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
824 const row = await this.deploymentRow(id);
825 if (!row?.token_hash || typeof token !== "string") return null;
826 if (row.token_hash !== (await sha256(token))) return null;
827 return row.status === "queued" || row.status === "building" ? row : null;
828 }
829
830 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run831 // Each report, for the logs: a build's own failure says why.
832 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page833 const row = await this.building(id, body.token);
834 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
835 const cloudflare = this.cloudflare;
836 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
837 switch (step) {
838 case "started":
839 await this.db
840 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
841 .bind(now(), id)
842 .run();
843 return Response.json(ok(true));
844 case "session": {
845 const manifest = body.manifest as Manifest | undefined;
846 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
847 const session = await cloudflare.openUpload(row.script, manifest);
848 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
849 }
850 case "finish": {
851 const worker = (body.worker ?? {}) as BuiltWorker;
852 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page853 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page854 try {
Secrets and variables: one list, rows per environment, for workflows and deployments855 // Running apps' secrets and variables are bound here, by g1t:
856 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page857 const runtime = await this.resolve(
858 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
859 row.kind,
860 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know861 row.branch,
Projects: what a workspace builds and runs, first on every page862 );
Deployments: a preview for every pull request, production on g1t.page863 await cloudflare.putScript(
864 row.script,
865 worker,
866 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page867 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments868 runtime,
Deployments: a preview for every pull request, production on g1t.page869 );
870 } catch (error) {
871 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
872 return Response.json(ok(false));
873 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains874 // The same app at an older name (its workspace was renamed) now
875 // redirects here; it stays up as it was if the redirect cannot be put.
876 const redirected = await this.supersede(cloudflare, row);
Deployments: a preview for every pull request, production on g1t.page877 const at = now();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains878 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
Deployments: a preview for every pull request, production on g1t.page879 await this.db.batch([
880 this.db
881 .prepare(
882 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
883 WHERE id = ?`,
884 )
885 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
Builds say Replaced or Down once they are no longer live886 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page887 this.db
Builds say Replaced or Down once they are no longer live888 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
889 .bind(row.script, id),
890 this.db
Deployments: a preview for every pull request, production on g1t.page891 .prepare(
Projects: what a workspace builds and runs, first on every page892 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
893 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far894 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.page895 )
Projects: what a workspace builds and runs, first on every page896 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains897 // A name that redirected elsewhere (a rename undone) is an app again.
898 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
899 ...redirected.flatMap((old) => [
900 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
901 this.db
902 .prepare(
903 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
904 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
905 )
906 .bind(old, appHost(row.script), row.workspace, at, expires),
907 ]),
Deployments: a preview for every pull request, production on g1t.page908 ]);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains909 // The project's own domains serve production wherever it is up.
910 if (row.kind === "production") {
911 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
912 }
Deployments: a preview for every pull request, production on g1t.page913 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page914 await this.notePeak(row.workspace);
915 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.page916 return Response.json(ok(true));
917 }
918 case "fail":
919 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
920 return Response.json(ok(true));
921 default:
922 return Response.json(fail("not_found", "No such step."), { status: 404 });
923 }
924 }
925
Agents and memory, checks and conflicts, profiles, slug renames, custom domains926 /**
927 * Older names of the app `row` just put up: one project's production, or
928 * its preview of one branch, has one name, so any other app row for the
929 * same is the app under the name it had before its workspace was renamed.
930 * Each is replaced in the namespace by a redirect to the new name; the
931 * names that were are returned, for their app rows to go.
932 */
933 private async supersede(cloudflare: Cloudflare, row: DeploymentRow): Promise<string[]> {
934 const older = await this.db
935 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
936 .bind(row.project_id, row.kind, row.branch, row.script)
937 .all<{ script: string }>();
938 const done: string[] = [];
939 for (const { script } of older.results) {
940 try {
941 await cloudflare.redirectScript(script, appHost(row.script));
942 done.push(script);
943 } catch (error) {
944 // Left as it is, the next deploy of this app tries again.
945 console.error("could not redirect", script, "to", row.script, error);
946 }
947 }
948 return done;
949 }
950
Deployments: a preview for every pull request, production on g1t.page951 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
952 const row = await this.deploymentRow(id);
953 if (!row || (row.status !== "queued" && row.status !== "building")) return;
954 await this.db
955 .prepare(
956 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
957 WHERE id = ?`,
958 )
959 .bind(message.slice(0, 2000), log, seconds, now(), id)
960 .run();
961 // A failed build still used its sandbox.
962 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page963 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.page964 }
965
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put966 /** Each build is charged by the second at the container price plus the margin, past the plan's included build minutes (billing applies those). */
Deployments: a preview for every pull request, production on g1t.page967 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays968 const costs = await this.costs();
969 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page970 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page971 const what =
972 row.kind === "preview"
973 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
974 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page975 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page976 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page977 feature: "deployments",
978 costMicros: cost,
979 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page980 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page981 reference: `deploy/${row.id}`,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put982 // The plan's included build minutes pay for what they can; billing
983 // charges the rest.
984 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page985 });
986 await this.db
987 .prepare(
988 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
989 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
990 )
Projects: what a workspace builds and runs, first on every page991 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page992 .run();
993 }
994
Prices keep themselves current with what g1t pays995 /**
996 * What each unit costs g1t now, from billing's price book, which follows
997 * what Cloudflare bills. The plan's figures if billing cannot say.
998 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains999 private async costs(): Promise<{
1000 buildSecond: number;
1001 millionRequests: number;
1002 millionCpuMs: number;
1003 appMonth: number;
1004 domainMonth: number;
1005 }> {
Prices keep themselves current with what g1t pays1006 const a = DEPLOYMENTS_ALLOWANCE;
1007 const book = await billingClient(this.env.BILLING)
1008 .prices()
1009 .catch(() => null);
1010 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1011 return {
1012 buildSecond: cost("build_second", a.microsPerBuildSecond),
1013 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1014 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1015 appMonth: cost("app_month", a.microsPerAppMonth),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1016 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
Prices keep themselves current with what g1t pays1017 };
1018 }
1019
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1020 /** Remembers the most apps, and custom domains, the workspace had at once this month. */
Projects: what a workspace builds and runs, first on every page1021 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1022 await this.db
1023 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1024 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1025 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1026 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1027 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1028 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1029 peak_domains = MAX(peak_domains, (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))`,
Deployments: a preview for every pull request, production on g1t.page1030 )
Projects: what a workspace builds and runs, first on every page1031 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1032 .run();
1033 }
1034
Projects: what a workspace builds and runs, first on every page1035 /**
1036 * The check on the commit: `g1t / deploy`, or, for one of several
1037 * projects on a repository, `g1t / deploy (<project>)`.
1038 */
1039 private async status(
1040 repoId: string,
1041 sha: string,
1042 project: { slug: string; primary: boolean },
1043 state: string,
1044 description: string,
1045 targetUrl: string,
1046 ): Promise<void> {
1047 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1048 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1049 method: "POST",
1050 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1051 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1052 }).catch(() => undefined);
1053 }
1054
Projects: what a workspace builds and runs, first on every page1055 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1056 const projects = await this.projects.byRepo(row.repo_id);
1057 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1058 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1059 }
1060
Deployments: a preview for every pull request, production on g1t.page1061 // ---- Taking apps down ----------------------------------------------
1062
1063 private async removeApp(script: string): Promise<void> {
1064 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1065 await this.db.batch([
1066 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1067 // Its build is no longer live anywhere.
1068 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1069 ]);
Deployments: a preview for every pull request, production on g1t.page1070 }
1071
Projects: what a workspace builds and runs, first on every page1072 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1073 const apps = await this.db
1074 .prepare(
Projects: what a workspace builds and runs, first on every page1075 `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.page1076 )
Projects: what a workspace builds and runs, first on every page1077 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1078 .all<{ script: string }>();
1079 for (const app of apps.results) await this.removeApp(app.script);
1080 }
1081
1082 // ---- Events --------------------------------------------------------
1083
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1084 /** `attempts`: which delivery of the event this is, from 1. */
1085 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1086 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1087 case "workspace.renamed":
1088 await this.renamed(event.data, attempts);
1089 break;
Deployments: a preview for every pull request, production on g1t.page1090 case "pull.opened":
1091 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1092 case "pull.updated":
1093 for (const project of await this.projects.byRepo(event.data.repoId)) {
1094 await this.deployPreview(project, event.data.number, "g1t");
1095 }
Deployments: a preview for every pull request, production on g1t.page1096 break;
1097 case "pull.closed":
1098 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1099 for (const project of await this.projects.byRepo(event.data.repoId)) {
1100 const apps = await this.db
1101 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1102 .bind(project.id, event.data.number)
1103 .all<{ script: string }>();
1104 for (const app of apps.results) await this.removeApp(app.script);
1105 }
Deployments: a preview for every pull request, production on g1t.page1106 break;
Projects: what a workspace builds and runs, first on every page1107 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1108 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1109 for (const project of await this.projects.byRepo(event.data.repoId)) {
1110 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1111 }
Deployments: a preview for every pull request, production on g1t.page1112 break;
1113 }
1114 }
1115
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1116 /**
1117 * A workspace's slug changed, and with it every app's name: production
1118 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1119 *
1120 * Its rows move to the slug it has now (asked of identity, so a delivery
1121 * twice over, or an older rename after a newer one, ends the same), and
1122 * each app that is up is built again from the same commit under its new
1123 * name. The old name keeps serving the app until the new one is live;
1124 * then the build's finish puts a redirect to the new name in its place
1125 * (see `supersede`), held for as long as the workspace holds its old slug.
1126 *
1127 * Paused apps are left: they are rebuilt, under the new name, when the
1128 * workspace is under its limit again, and the old name redirects then.
1129 * Builds under way for the old name are dropped and started again under
1130 * the new one. Only rows still under an old slug are acted on, so a
1131 * second delivery builds nothing.
1132 */
1133 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1134 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1135 const stale = staleSlugs(renamed, current);
1136 if (stale.length === 0) return;
1137 const marks = stale.map(() => "?").join(", ");
1138
1139 // What to build again, read before the rows move.
1140 const [apps, building] = await Promise.all([
1141 this.db
1142 .prepare(`SELECT * FROM apps WHERE workspace IN (${marks}) AND paused_at IS NULL`)
1143 .bind(...stale)
1144 .all<AppRow>(),
1145 this.db
1146 .prepare(`SELECT * FROM deployments WHERE workspace IN (${marks}) AND status IN ('queued', 'building') ORDER BY id`)
1147 .bind(...stale)
1148 .all<DeploymentRow>(),
1149 ]);
1150 type Target = { projectId: string; kind: DeployKind; branch: string | null; number: number | null; commit: string };
1151 const targets = new Map<string, Target>();
1152 const key = (t: { project_id: string; kind: DeployKind; branch: string | null }) => `${t.project_id}/${t.kind}/${t.branch ?? ""}`;
1153 for (const app of apps.results) {
1154 targets.set(key(app), { projectId: app.project_id, kind: app.kind, branch: app.branch, number: app.number, commit: app.commit_sha });
1155 }
1156 // A build under way is newer than what is up.
1157 for (const row of building.results) {
1158 targets.set(key(row), { projectId: row.project_id, kind: row.kind, branch: row.branch, number: row.number, commit: row.commit_sha });
1159 }
1160
1161 // The projects, as the projects service has them now.
1162 const projectIds = [...new Set([...targets.values()].map((t) => t.projectId))];
1163 const projects = new Map<string, Project>();
1164 if (projectIds.length > 0) {
1165 const repoIds = await this.db
1166 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${projectIds.map(() => "?").join(", ")})`)
1167 .bind(...projectIds)
1168 .all<{ repo_id: string }>();
1169 for (const { repo_id } of repoIds.results) {
1170 for (const project of await this.projects.byRepo(repo_id)) {
1171 if (projectIds.includes(project.id)) projects.set(project.id, project);
1172 }
1173 }
1174 // The projects service hears of the rename on its own queue: wait for
1175 // it a few deliveries, so the builds read the repository by its new name.
1176 const behind = [...projects.values()].some((p) => p.workspace !== current);
1177 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
1178 }
1179
1180 // Every row moves at once.
1181 const at = now();
1182 const statements: D1PreparedStatement[] = [];
1183 for (const slug of stale) {
1184 statements.push(
1185 this.db
1186 .prepare(
1187 `UPDATE deployments SET status = 'skipped', error = 'The workspace was renamed: built again under its new name.',
1188 finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
1189 )
1190 .bind(at, slug),
1191 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1192 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1193 this.db
1194 .prepare(
1195 `UPDATE deployments SET workspace = ?1,
1196 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1197 WHERE workspace = ?2`,
1198 )
1199 .bind(current, slug),
1200 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1201 this.domains.rename(slug, current),
1202 // Counters add up; the peak is the higher; a month charged stays charged.
1203 this.db
1204 .prepare(
1205 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1206 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1207 FROM meters WHERE namespace = ?2
1208 ON CONFLICT (namespace, month) DO UPDATE SET
1209 requests = requests + excluded.requests,
1210 cpu_ms = cpu_ms + excluded.cpu_ms,
1211 peak_apps = MAX(peak_apps, excluded.peak_apps),
1212 peak_domains = MAX(peak_domains, excluded.peak_domains),
1213 build_seconds = build_seconds + excluded.build_seconds,
1214 build_micros = build_micros + excluded.build_micros,
1215 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1216 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1217 )
1218 .bind(current, slug),
1219 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1220 );
1221 }
1222 await this.db.batch(statements);
1223
1224 // Each app again, under its new name. Its dependencies' addresses are
1225 // read again too, so apps that call one another follow the rename.
1226 // Failures are logged, not retried: the rows have moved, and the next
1227 // deploy of the app puts it under its new name all the same.
1228 const actor = await this.workspaceActor(current);
1229 for (const target of targets.values()) {
1230 const found = projects.get(target.projectId);
1231 if (!found) continue;
1232 const project = this.underSlug(found, current, stale);
1233 try {
1234 const started =
1235 target.kind === "production"
1236 ? await this.deployProduction(project, target.commit, "g1t")
1237 : target.number != null
1238 ? await this.deployPreview(project, target.number, "g1t", true)
1239 : await this.rebuildStack(project, target.branch, target.commit, actor);
1240 if (started && !started.ok) console.log("could not rebuild", project.slug, target.branch, started.error.message);
1241 } catch (error) {
1242 console.error("could not rebuild after rename", project.slug, target.branch, error);
1243 }
1244 }
1245 }
1246
1247 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1248 private underSlug(project: Project, current: string, stale: string[]): Project {
1249 const source =
1250 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1251 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1252 : project.source;
1253 return { ...project, workspace: current, source };
1254 }
1255
1256 /** A stack's preview (no pull request of its own) built again at `commit`. */
1257 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1258 if (!actor || branch == null) return null;
1259 const settings = await this.settingsRow(project.id);
1260 if (!settings?.enabled || !settings.previews) return null;
1261 return this.start({
1262 project,
1263 kind: "preview",
1264 branch,
1265 number: null,
1266 commit,
1267 source: repoOf(project).path,
1268 reader: actor,
1269 createdBy: "g1t",
1270 settings,
1271 // As `stack` built it: the project's own default branch.
1272 trusted: true,
1273 });
1274 }
1275
Deployments: a preview for every pull request, production on g1t.page1276 // ---- The sweep -----------------------------------------------------
1277
1278 /**
1279 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1280 * previews, the apps of workspaces whose plan ended, and scripts no app
1281 * holds come down; and a month that is over is charged past its
1282 * allowance.
Deployments: a preview for every pull request, production on g1t.page1283 */
1284 async sweep(): Promise<void> {
1285 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1286 const stuck = await this.db
1287 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1288 .bind(cutoff)
1289 .all<{ id: string }>();
1290 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1291
1292 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
Projects: what a workspace builds and runs, first on every page1293 const workspaces = [...new Set(apps.map((app) => app.workspace))];
Deployments: a preview for every pull request, production on g1t.page1294
1295 // Apps of workspaces whose plan has ended come down.
1296 const billing = billingClient(this.env.BILLING);
Usage limits: unpaid usage can only go so far1297 await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1298 for (const workspace of workspaces) {
1299 const plan = await billing.hasFeature(workspace, "deployments");
1300 if (!plan.ok && plan.error.code === "payment_required") {
Projects: what a workspace builds and runs, first on every page1301 for (const app of apps.filter((a) => a.workspace === workspace)) await this.removeApp(app.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1302 // Custom domains cost g1t by the month: they go with the plan.
1303 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
Deployments: a preview for every pull request, production on g1t.page1304 }
1305 }
1306
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1307 await this.domains
1308 .sweep(async (projectId) => {
1309 const app = await this.db
1310 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1311 .bind(projectId)
1312 .first<{ script: string }>();
1313 return app?.script ?? null;
1314 })
1315 .catch((error) => console.error("could not check domains", error));
1316
Projects: what a workspace builds and runs, first on every page1317 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page1318 await this.count(apps).catch((error) => console.error("could not count usage", error));
1319 await this.takeDownIdle();
1320 await this.chargeMonths();
1321 }
1322
Usage limits: unpaid usage can only go so far1323 /**
1324 * Pauses the apps of workspaces that reached their limit for usage not
1325 * yet paid for, and rebuilds them from the same commit once they are
1326 * under it again. Paused apps answer with a notice and run nothing.
1327 */
1328 private async holdToLimits(apps: AppRow[]): Promise<void> {
1329 const cloudflare = this.cloudflare;
1330 if (!cloudflare) return;
1331 const billing = billingClient(this.env.BILLING);
1332 for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
1333 const limit = await billing.checkLimit(workspace);
1334 if (!limit.ok) continue;
1335 const theirs = apps.filter((app) => app.workspace === workspace);
1336 if (limit.value.state === "stopped") {
1337 for (const app of theirs.filter((a) => !a.paused_at)) {
1338 await cloudflare.pauseScript(app.script);
1339 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1340 }
1341 continue;
1342 }
1343 const paused = theirs.filter((a) => a.paused_at);
1344 if (paused.length === 0) continue;
1345 const actor = await this.workspaceActor(workspace);
1346 if (!actor) continue;
1347 for (const app of paused) {
1348 const project = await this.projects.get(workspace, app.slug, actor);
1349 if (!project.ok) continue;
1350 // A failed or refused rebuild leaves it paused, to try again next time.
1351 const rebuilt =
1352 app.kind === "production"
1353 ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1354 : app.number != null
1355 ? await this.deployPreview(project.value, app.number, "g1t", true)
1356 : null;
1357 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1358 }
1359 }
1360 }
1361
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1362 /**
1363 * Scripts in the namespace that no app holds, such as ones renamed. An
1364 * old address that redirects to its app's new one is held until its
1365 * redirect expires, then removed with the rest.
1366 */
Projects: what a workspace builds and runs, first on every page1367 private async removeOrphans(apps: AppRow[]): Promise<void> {
1368 const cloudflare = this.cloudflare;
1369 if (!cloudflare) return;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1370 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
1371 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
1372 const held = new Set([...apps.map((app) => app.script), ...redirects.results.map((r) => r.script)]);
Projects: what a workspace builds and runs, first on every page1373 const building = await this.db
1374 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1375 .all<{ script: string }>();
1376 for (const row of building.results) held.add(row.script);
1377 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1378 for (const script of await cloudflare.listScripts()) {
1379 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1380 }
1381 }
1382
Deployments: a preview for every pull request, production on g1t.page1383 /** Counts this month's requests and CPU time per workspace, from analytics. */
1384 private async count(apps: AppRow[]): Promise<void> {
1385 const cloudflare = this.cloudflare;
1386 if (!cloudflare || apps.length === 0) return;
1387 const start = `${month()}-01T00:00:00Z`;
1388 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1389 // Analytics only counts apps that are up; the meter keeps what earlier
1390 // apps used by never going down.
1391 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1392 for (const app of apps) {
1393 const used = totals.get(app.script);
1394 if (!used) continue;
Projects: what a workspace builds and runs, first on every page1395 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page1396 sum.requests += used.requests;
1397 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page1398 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page1399 }
1400 const at = now();
Projects: what a workspace builds and runs, first on every page1401 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page1402 await this.db
1403 .prepare(
1404 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1405 ON CONFLICT (namespace, month) DO UPDATE SET
1406 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1407 )
Projects: what a workspace builds and runs, first on every page1408 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1409 .run();
1410 }
1411 // When each preview last answered anyone, for the idle sweep.
1412 const recent = await cloudflare.usage(
1413 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1414 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1415 at,
1416 );
1417 for (const [script, used] of recent) {
1418 if (used.requests > 0) {
1419 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1420 }
1421 }
Projects: what a workspace builds and runs, first on every page1422 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
Prices keep themselves current with what g1t pays1423 // What this month's traffic past the plan will cost, so the workspace's
1424 // limit counts it now rather than when the month closes.
1425 const costs = await this.costs();
1426 const a = DEPLOYMENTS_ALLOWANCE;
1427 for (const workspace of perWorkspace.keys()) {
1428 const meter = await this.db
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1429 .prepare("SELECT requests, cpu_ms, peak_apps, peak_domains FROM meters WHERE namespace = ? AND month = ?")
Prices keep themselves current with what g1t pays1430 .bind(workspace, month())
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1431 .first<{ requests: number; cpu_ms: number; peak_apps: number; peak_domains: number }>();
Prices keep themselves current with what g1t pays1432 if (!meter) continue;
1433 const cost = Math.ceil(
1434 (Math.max(0, meter.requests - a.requests) / 1_000_000) * costs.millionRequests +
1435 (Math.max(0, meter.cpu_ms - a.cpuMs) / 1_000_000) * costs.millionCpuMs +
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1436 Math.max(0, meter.peak_apps - a.apps) * costs.appMonth +
1437 extraDomains(meter.peak_domains ?? 0) * costs.domainMonth,
Prices keep themselves current with what g1t pays1438 );
1439 await billingClient(this.env.BILLING)
1440 .notePending(workspace, "deployments", cost)
1441 .catch((error) => console.error("could not note pending usage", error));
1442 }
Deployments: a preview for every pull request, production on g1t.page1443 }
1444
Projects: what a workspace builds and runs, first on every page1445 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page1446 private async takeDownIdle(): Promise<void> {
1447 const idle = await this.db
1448 .prepare(
Projects: what a workspace builds and runs, first on every page1449 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page1450 WHERE apps.kind = 'preview'
1451 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1452 )
1453 .all<{ script: string }>();
1454 for (const { script } of idle.results) await this.removeApp(script);
1455 }
1456
1457 /** Charges each month that is over for what it used past the allowance, once. */
1458 private async chargeMonths(): Promise<void> {
1459 const due = await this.db
1460 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1461 .bind(month())
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1462 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number; peak_domains: number }>();
Deployments: a preview for every pull request, production on g1t.page1463 const a = DEPLOYMENTS_ALLOWANCE;
Prices keep themselves current with what g1t pays1464 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page1465 for (const meter of due.results) {
1466 const extraRequests = Math.max(0, meter.requests - a.requests);
1467 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1468 const extraApps = Math.max(0, meter.peak_apps - a.apps);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1469 const moreDomains = extraDomains(meter.peak_domains ?? 0);
Deployments: a preview for every pull request, production on g1t.page1470 const cost = Math.ceil(
Prices keep themselves current with what g1t pays1471 (extraRequests / 1_000_000) * costs.millionRequests +
1472 (extraCpu / 1_000_000) * costs.millionCpuMs +
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1473 extraApps * costs.appMonth +
1474 moreDomains * costs.domainMonth,
Deployments: a preview for every pull request, production on g1t.page1475 );
1476 if (cost > 0) {
1477 const parts = [
1478 extraApps && `${extraApps} extra apps`,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1479 moreDomains && `${moreDomains} extra custom domains`,
Deployments: a preview for every pull request, production on g1t.page1480 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1481 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1482 ].filter(Boolean);
1483 const charged = await billingClient(this.env.BILLING).chargeFeature({
1484 workspace: meter.namespace,
1485 feature: "deployments",
1486 costMicros: cost,
1487 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1488 reference: `deployments/${meter.namespace}/${meter.month}`,
1489 });
1490 if (!charged.ok) continue;
1491 }
1492 await this.db
1493 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1494 .bind(now(), meter.namespace, meter.month)
1495 .run();
1496 }
1497 }
1498}
1499
1500/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know1501async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page1502 switch (method) {
1503 case "settings":
1504 return service.settings(args);
1505 case "update_settings":
1506 return service.updateSettings(args);
1507 case "list":
1508 return service.list(args);
1509 case "get":
1510 return service.get(args);
1511 case "redeploy":
1512 return service.redeploy(args);
1513 case "take_down":
1514 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know1515 case "stack":
1516 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page1517 case "overview":
1518 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page1519 case "usage":
1520 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1521 case "domains":
1522 return service.listDomains(args);
1523 case "add_domain":
1524 return service.addDomain(args);
1525 case "remove_domain":
1526 return service.removeDomain(args);
1527 case "refresh_domain":
1528 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page1529 default:
1530 return undefined;
1531 }
1532}
1533
1534export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know1535 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page1536 const { pathname } = new URL(request.url);
1537 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1538 const service = new Deployments(env);
1539 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1540 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1541 if (rpcMatch) {
Project dependencies: addresses, preview stacks, Affects, and agents who know1542 const result = await rpc(service, rpcMatch[1], body, ctx);
Deployments: a preview for every pull request, production on g1t.page1543 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1544 }
1545 // A build's reports, forwarded by the API.
1546 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1547 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1548 return new Response("Not found\n", { status: 404 });
1549 },
1550
1551 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1552 const service = new Deployments(env);
1553 for (const message of batch.messages) {
1554 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1555 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page1556 message.ack();
1557 } catch (error) {
1558 console.error("deployments could not handle", message.body.type, error);
1559 message.retry();
1560 }
1561 }
1562 },
1563
1564 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1565 await new Deployments(env).sweep();
1566 },
1567} satisfies ExportedHandler<Env, G1tEvent>;