flagon-io/g1t

public

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

g1t/services/deployments/src/index.ts

1,563 lines67,398 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 {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains26 CUSTOM_DOMAIN_TARGET,
Deployments: a preview for every pull request, production on g1t.page27 DEPLOYMENTS_ALLOWANCE,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains28 SLUG_HOLD_DAYS,
Deployments: a preview for every pull request, production on g1t.page29 billingClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains30 currentWorkspaceSlug,
Deployments: a preview for every pull request, production on g1t.page31 fail,
32 identityClient,
33 newId,
34 ok,
Projects: what a workspace builds and runs, first on every page35 projectsClient,
Deployments: a preview for every pull request, production on g1t.page36 reposClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains37 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page38 workClient,
39 type DeployKind,
40 type DeploySettings,
41 type DeployStatus,
42 type DeployUsage,
43 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains44 type Domain,
Deployments: a preview for every pull request, production on g1t.page45 type G1tEvent,
46 type LiveApp,
Projects: what a workspace builds and runs, first on every page47 type Project,
48 type ProjectDeploys,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains49 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page50 type ProjectRef,
Deployments: a preview for every pull request, production on g1t.page51 type RepoPath,
52 type Result,
53 type ServiceBinding,
54 type User,
55 type Viewer,
56} from "@g1t/contracts";
57
58import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains59import { CustomHostnames } from "./custom-hostnames";
60import { Domains, NOT_ENABLED_NOTICE, extraDomains, toDomain } from "./domains";
61import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page62
63type Env = {
64 DB: D1Database;
65 REPOS: ServiceBinding;
66 WORK: ServiceBinding;
67 IDENTITY: ServiceBinding;
68 BILLING: ServiceBinding;
69 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page70 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments71 /** Secrets and variables: the actions service holds the one store. */
72 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page73 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
74 CLOUDFLARE_API_TOKEN?: string;
75 CLOUDFLARE_ACCOUNT_ID: string;
76 DISPATCH_NAMESPACE: string;
77 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains78 /** Custom domains: hostname to app, read by the dispatcher. */
79 DOMAINS?: KVNamespace;
80 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
81 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Deployments: a preview for every pull request, production on g1t.page82};
83
84/** A build that has not reported in this long has died. */
85const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page86/** A script in the namespace that no app holds, older than this, is removed. */
87const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page88const LIST_LIMIT = 50;
89const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains90/**
91 * Deliveries of `workspace.renamed` that wait for the projects service to
92 * have seen it too, before going ahead with the new slug regardless.
93 */
94const RENAME_WAITS = 3;
Deployments: a preview for every pull request, production on g1t.page95
96const now = () => new Date().toISOString();
97const month = (at = new Date()) => at.toISOString().slice(0, 7);
98
99async function sha256(text: string): Promise<string> {
100 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
101 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
102}
103
104function randomToken(): string {
105 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
106}
107
108function isMember(viewer: Viewer, slug: string): boolean {
109 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
110}
111
Projects: what a workspace builds and runs, first on every page112/** The repository a project builds from. */
113function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
114 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
115 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
116}
117
Deployments: a preview for every pull request, production on g1t.page118type SettingsRow = {
Projects: what a workspace builds and runs, first on every page119 project_id: string;
120 workspace: string;
121 slug: string;
Deployments: a preview for every pull request, production on g1t.page122 repo_id: string;
123 enabled: number;
124 previews: number;
125 production: number;
126 build_command: string | null;
127 output_dir: string | null;
128 idle_days: number;
129};
130
131type DeploymentRow = {
132 id: string;
Projects: what a workspace builds and runs, first on every page133 project_id: string;
134 workspace: string;
135 slug: string;
Deployments: a preview for every pull request, production on g1t.page136 repo_id: string;
Projects: what a workspace builds and runs, first on every page137 repo: string;
Deployments: a preview for every pull request, production on g1t.page138 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page139 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page140 number: number | null;
141 commit_sha: string;
142 script: string;
143 status: DeployStatus;
144 error: string | null;
145 warnings: string;
146 log: string | null;
147 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments148 trusted: number;
Deployments: a preview for every pull request, production on g1t.page149 build_seconds: number | null;
150 created_by: string;
151 created_at: string;
152 finished_at: string | null;
153};
154
155type AppRow = {
156 script: string;
Projects: what a workspace builds and runs, first on every page157 project_id: string;
158 workspace: string;
159 slug: string;
Deployments: a preview for every pull request, production on g1t.page160 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page161 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page162 number: number | null;
163 commit_sha: string;
164 deployed_at: string;
165 created_at: string;
166 last_request_at: string | null;
Usage limits: unpaid usage can only go so far167 /** Set while its workspace is over its limit; see `holdToLimits`. */
168 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page169};
170
171function toDeployment(row: DeploymentRow): Deployment {
172 return {
173 id: row.id,
174 kind: row.kind,
Projects: what a workspace builds and runs, first on every page175 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page176 number: row.number,
177 commit: row.commit_sha,
178 status: row.status,
179 url: appUrl(row.script),
180 error: row.error,
181 warnings: JSON.parse(row.warnings || "[]") as string[],
182 buildSeconds: row.build_seconds,
183 createdBy: row.created_by,
184 createdAt: row.created_at,
185 finishedAt: row.finished_at,
186 };
187}
188
Projects: what a workspace builds and runs, first on every page189function toLive(app: AppRow): LiveApp {
190 return {
191 kind: app.kind,
192 branch: app.branch,
193 number: app.number,
194 url: appUrl(app.script),
195 commit: app.commit_sha,
196 deployedAt: app.deployed_at,
197 };
198}
199
Deployments: a preview for every pull request, production on g1t.page200class Deployments {
201 constructor(private readonly env: Env) {}
202
203 private get cloudflare(): Cloudflare | null {
204 const token = this.env.CLOUDFLARE_API_TOKEN;
205 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
206 }
207
208 private get db() {
209 return this.env.DB;
210 }
211
Agents and memory, checks and conflicts, profiles, slug renames, custom domains212 private get domains(): Domains {
213 const token = this.env.CLOUDFLARE_API_TOKEN;
214 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
215 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
216 }
217
Projects: what a workspace builds and runs, first on every page218 private get projects() {
219 return projectsClient(this.env.PROJECTS);
220 }
221
Deployments: a preview for every pull request, production on g1t.page222 /** The workspace itself, as the service acts for it. */
223 private async workspaceActor(slug: string): Promise<User | null> {
224 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
225 if (!workspace) return null;
226 return {
227 id: workspace.id,
228 username: workspace.slug,
229 kind: "workspace",
230 verified: true,
231 workspaces: [{ slug: workspace.slug, role: "member" }],
232 };
233 }
234
Secrets and variables: one list, rows per environment, for workflows and deployments235 /**
Projects: what a workspace builds and runs, first on every page236 * What the project's secrets and variables available to deployments give
237 * production or a preview: its build's environment, and the same again as
238 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments239 */
240 private async resolve(
Projects: what a workspace builds and runs, first on every page241 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments242 environment: DeployKind,
243 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know244 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments245 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know246 const [rows, references] = await Promise.all([
247 this.rows(project, environment, trusted),
248 this.references(project.id, environment === "preview" ? branch : null),
249 ]);
250 // The project's own rows win over a dependency's address of the same name.
251 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
252 }
253
254 /**
255 * Each dependency's address, under the name the dependency gives it:
256 * for a preview, the same branch's preview of it if one is up, else its
257 * production; for production, its production.
258 */
259 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
260 const graph = await this.projects.graph(projectId).catch(() => null);
261 const out: Record<string, string> = {};
262 for (const dependency of graph?.dependsOn ?? []) {
263 if (!dependency.as) continue;
264 const app =
265 (branch
266 ? await this.db
267 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
268 .bind(dependency.id, branch)
269 .first<{ script: string }>()
270 : null) ??
271 (await this.db
272 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
273 .bind(dependency.id)
274 .first<{ script: string }>());
275 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
276 }
277 return out;
278 }
279
280 private async rows(
281 project: { id: string; slug: string; repoId: string; repo: RepoPath },
282 environment: DeployKind,
283 trusted: boolean,
284 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments285 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
286 method: "POST",
287 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page288 body: JSON.stringify({
289 repoId: project.repoId,
290 repo: project.repo,
291 projectId: project.id,
292 projectSlug: project.slug,
293 consumer: "deployments",
294 environment,
295 trusted,
296 }),
Secrets and variables: one list, rows per environment, for workflows and deployments297 });
298 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
299 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
300 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
301 }
302
303 /**
Projects: what a workspace builds and runs, first on every page304 * Whether a pull request's author is trusted with the project's secrets:
305 * g1t's agent, or a member of the workspace. Someone from outside gets a
306 * preview built without them, as their workflows run.
Secrets and variables: one list, rows per environment, for workflows and deployments307 */
308 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
309 if (author.kind === "agent" || author.username === "g1t-agent") return true;
310 // On a private repository only members can open one at all.
311 const found = await reposClient(this.env.REPOS).get(repo, actor);
312 if (found.ok && found.value.isPrivate) return true;
313 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
314 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
315 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
316 }
317
Projects: what a workspace builds and runs, first on every page318 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
319 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page320 }
321
Projects: what a workspace builds and runs, first on every page322 /** The name an app gets, unique among apps: production, or a branch's preview. */
323 private async scriptFor(project: Project, branch: string | null): Promise<string> {
324 const base = await label(project.workspace, project.slug, branch);
325 const holder = await this.db
326 .prepare(
327 `SELECT project_id, branch FROM apps WHERE script = ?1
328 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
329 )
330 .bind(base)
331 .first<{ project_id: string; branch: string | null }>();
332 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
333 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
334 }
335
336 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page337 return {
338 enabled: !!row?.enabled,
339 previews: row ? !!row.previews : true,
340 production: row ? !!row.production : true,
341 buildCommand: row?.build_command ?? null,
342 outputDir: row?.output_dir ?? null,
343 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page344 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains345 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Deployments: a preview for every pull request, production on g1t.page346 };
347 }
348
Projects: what a workspace builds and runs, first on every page349 /** The project, if `viewer` belongs to its workspace. */
350 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
351 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
352 return this.projects.get(ref.workspace, ref.slug, viewer);
Deployments: a preview for every pull request, production on g1t.page353 }
354
355 // ---- Methods for the site and the API ------------------------------
356
Projects: what a workspace builds and runs, first on every page357 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
358 const project = await this.memberProject(a.project, a.viewer);
359 if (!project.ok) return project;
360 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page361 }
362
363 async updateSettings(a: {
364 actor: User;
Projects: what a workspace builds and runs, first on every page365 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page366 changes: Partial<DeploySettings>;
367 }): Promise<Result<DeploySettings>> {
Projects: what a workspace builds and runs, first on every page368 const found = await this.memberProject(a.project, a.actor);
369 if (!found.ok) return found;
370 const project = found.value;
371 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page372 const next = { ...before, ...a.changes };
373 if (next.enabled && !before.enabled) {
374 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page375 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page376 if (!plan.ok) return plan;
377 }
378 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
379 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
380 await this.db
381 .prepare(
Projects: what a workspace builds and runs, first on every page382 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
383 output_dir, idle_days, updated_by, updated_at)
384 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
385 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
386 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.page387 )
388 .bind(
Projects: what a workspace builds and runs, first on every page389 project.id,
390 project.workspace,
391 project.slug,
392 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page393 next.enabled ? 1 : 0,
394 next.previews ? 1 : 0,
395 next.production ? 1 : 0,
396 clip(next.buildCommand),
397 clip(next.outputDir),
398 idleDays,
399 a.actor.username,
400 now(),
401 )
402 .run();
403 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page404 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page405 else {
Projects: what a workspace builds and runs, first on every page406 if (!next.previews) await this.takeDownWhere(project.id, "preview");
407 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page408 }
409 // Turned on: production goes up from the default branch at once.
410 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page411 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page412 }
Projects: what a workspace builds and runs, first on every page413 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page414 }
415
Projects: what a workspace builds and runs, first on every page416 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
417 const project = await this.memberProject(a.project, a.viewer);
418 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page419 const [deployments, apps] = await Promise.all([
420 this.db
Projects: what a workspace builds and runs, first on every page421 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
422 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page423 .all<DeploymentRow>(),
424 this.db
Projects: what a workspace builds and runs, first on every page425 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
426 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page427 .all<AppRow>(),
428 ]);
Projects: what a workspace builds and runs, first on every page429 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page430 }
431
Projects: what a workspace builds and runs, first on every page432 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
433 const project = await this.memberProject(a.project, a.viewer);
434 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page435 const row = await this.db
Projects: what a workspace builds and runs, first on every page436 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
437 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page438 .first<DeploymentRow>();
439 if (!row) return fail("not_found", "No such deployment.");
440 return ok({ ...toDeployment(row), log: row.log });
441 }
442
Projects: what a workspace builds and runs, first on every page443 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
444 const found = await this.memberProject(a.project, a.actor);
445 if (!found.ok) return found;
446 const project = found.value;
447 const settings = await this.settingsRow(project.id);
448 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
449 if (a.branch == null) {
450 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
451 }
452 // A branch's preview comes from its pull request.
453 const app = await this.db
454 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
455 .bind(project.id, a.branch)
456 .first<{ number: number }>();
457 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
458 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.page459 }
460
Project dependencies: addresses, preview stacks, Affects, and agents who know461 /**
462 * A preview stack: the projects that use this one get previews of their
463 * own default branch, under the same branch name, so each reaches this
464 * branch's preview through its dependency's variable. A change to an API
465 * can then be clicked through in the apps that call it.
466 */
467 async stack(
468 a: { actor: User; project: ProjectRef; branch: string },
469 background: (work: Promise<unknown>) => void,
470 ): Promise<Result<string[]>> {
471 const found = await this.memberProject(a.project, a.actor);
472 if (!found.ok) return found;
473 const upstream = await this.db
474 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
475 .bind(found.value.id, a.branch)
476 .first();
477 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
478 const graph = await this.projects.graph(found.value.id);
479 const ready: { project: Project; settings: SettingsRow }[] = [];
480 for (const dependent of graph.usedBy) {
481 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
482 if (!project.ok) continue;
483 const settings = await this.settingsRow(project.value.id);
484 if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
485 }
486 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
487 // The builds start after the answer: a person moving on from the page
488 // does not stop them.
489 background(
490 (async () => {
491 for (const { project, settings } of ready) {
492 const actor = await this.workspaceActor(project.workspace);
493 if (!actor) continue;
494 const repo = repoOf(project);
495 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
496 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
497 if (!head) continue;
498 await this.start({
499 project,
500 kind: "preview",
501 branch: a.branch,
502 number: null,
503 commit: head,
504 source: repo.path,
505 reader: actor,
506 createdBy: a.actor.username,
507 settings,
508 // Its own default branch, asked for by a member.
509 trusted: true,
510 });
511 }
512 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
513 );
514 return ok(ready.map(({ project }) => project.name));
515 }
516
Projects: what a workspace builds and runs, first on every page517 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
518 const project = await this.memberProject(a.project, a.actor);
519 if (!project.ok) return project;
520 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page521 return ok(true);
522 }
523
Projects: what a workspace builds and runs, first on every page524 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
525 const workspace = a.workspace.toLowerCase();
526 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
527 const [settings, apps, latest] = await Promise.all([
528 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
529 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
530 this.db
531 .prepare(
532 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
533 )
534 .bind(workspace)
535 .all<DeploymentRow>(),
536 ]);
537 return ok(
538 settings.results.map((row) => {
539 const own = apps.results.filter((app) => app.slug === row.slug);
540 const production = own.find((app) => app.kind === "production");
541 const newest = latest.results.find((d) => d.slug === row.slug);
542 return {
543 slug: row.slug,
544 enabled: !!row.enabled,
545 production: production ? toLive(production) : null,
546 previews: own.filter((app) => app.kind === "preview").length,
547 latest: newest ? toDeployment(newest) : null,
548 };
549 }),
550 );
551 }
552
Deployments: a preview for every pull request, production on g1t.page553 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
554 const slug = a.workspace.toLowerCase();
555 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
556 const [meter, apps] = await Promise.all([
557 this.db
558 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
559 .bind(slug, month())
560 .first<{
561 requests: number;
562 cpu_ms: number;
563 peak_apps: number;
564 build_seconds: number;
565 build_micros: number;
566 counted_at: string | null;
567 }>(),
Projects: what a workspace builds and runs, first on every page568 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.page569 ]);
570 return ok({
571 month: month(),
572 requests: meter?.requests ?? 0,
573 cpuMs: meter?.cpu_ms ?? 0,
574 apps: apps?.n ?? 0,
575 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
576 buildSeconds: meter?.build_seconds ?? 0,
577 buildMicros: meter?.build_micros ?? 0,
578 countedAt: meter?.counted_at ?? null,
579 });
580 }
581
Agents and memory, checks and conflicts, profiles, slug renames, custom domains582 // ---- Custom domains ------------------------------------------------
583
584 async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> {
585 const project = await this.memberProject(a.project, a.viewer);
586 if (!project.ok) return project;
587 const domains = this.domains;
588 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
589 const [rows, available, used] = await Promise.all([
590 domains.forProject(project.value.id),
591 domains.available(),
592 domains.countFor(project.value.workspace),
593 ]);
594 return ok({
595 domains: rows.map(toDomain),
596 target: CUSTOM_DOMAIN_TARGET,
597 available,
598 notice: available ? null : NOT_ENABLED_NOTICE,
599 included: DEPLOYMENTS_ALLOWANCE.customDomains,
600 used,
601 });
602 }
603
604 async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> {
605 const found = await this.memberProject(a.project, a.actor);
606 if (!found.ok) return found;
607 const project = found.value;
608 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
609 if (!plan.ok) return plan;
610 const added = await this.domains.add({
611 project: { id: project.id, workspace: project.workspace, slug: project.slug },
612 script: await this.productionScript(project),
613 hostname: String(a.hostname ?? ""),
614 twin: !!a.twin,
615 by: a.actor.username,
616 });
617 if (!added.ok) return fail(added.code, added.message);
618 await this.notePeak(project.workspace);
619 return ok(added.rows.map(toDomain));
620 }
621
622 async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> {
623 const found = await this.memberProject(a.project, a.actor);
624 if (!found.ok) return found;
625 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
626 if (!row) return fail("not_found", "No such domain.");
627 await this.domains.remove(row);
628 return ok(true);
629 }
630
631 async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> {
632 const found = await this.memberProject(a.project, a.actor);
633 if (!found.ok) return found;
634 const domains = this.domains;
635 const row = await domains.byId(found.value.id, String(a.id ?? ""));
636 if (!row) return fail("not_found", "No such domain.");
637 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
638 await this.notePeak(found.value.workspace);
639 return ok(toDomain(after));
640 }
641
642 /** The script production is up under, or the name it will have. */
643 private async productionScript(project: Project): Promise<string> {
644 const app = await this.db
645 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
646 .bind(project.id)
647 .first<{ script: string }>();
648 return app?.script ?? (await this.scriptFor(project, null));
649 }
650
Deployments: a preview for every pull request, production on g1t.page651 // ---- Starting builds -----------------------------------------------
652
653 /**
654 * Opens a deployment and starts its build. Skipped, with the reason
655 * recorded, when the workspace's plan is off.
656 */
657 private async start(input: {
Projects: what a workspace builds and runs, first on every page658 project: Project;
Deployments: a preview for every pull request, production on g1t.page659 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page660 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page661 number: number | null;
662 commit: string;
663 source: RepoPath;
664 reader: User;
665 createdBy: string;
666 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page667 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments668 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page669 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page670 const { project } = input;
671 const repo = repoOf(project);
672 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page673 const id = newId("dpl");
674 const token = randomToken();
Projects: what a workspace builds and runs, first on every page675 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far676 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page677 const cloudflare = this.cloudflare;
678 const refused = !plan.ok
679 ? plan.error.message
Usage limits: unpaid usage can only go so far680 : limit.ok && limit.value.state === "stopped"
681 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page682 : !cloudflare
683 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
684 : null;
685 await this.db
686 .prepare(
Projects: what a workspace builds and runs, first on every page687 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
688 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
689 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page690 )
691 .bind(
692 id,
Projects: what a workspace builds and runs, first on every page693 project.id,
694 project.workspace,
695 project.slug,
696 repo.id,
697 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page698 input.kind,
Projects: what a workspace builds and runs, first on every page699 input.branch,
Deployments: a preview for every pull request, production on g1t.page700 input.number,
701 input.commit,
702 script,
703 refused ? "skipped" : "queued",
704 refused,
705 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments706 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page707 input.createdBy,
708 now(),
709 refused ? now() : null,
710 )
711 .run();
712 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
713 // Older builds of the same app are replaced by this one.
714 await this.db
715 .prepare(
716 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
717 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
718 )
719 .bind(now(), script, id)
720 .run();
Projects: what a workspace builds and runs, first on every page721 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
722 // What the project's secrets and variables give builds of this kind.
723 const build = await this.resolve(
724 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
725 input.kind,
726 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know727 input.branch,
Projects: what a workspace builds and runs, first on every page728 );
Deployments: a preview for every pull request, production on g1t.page729 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
730 method: "POST",
731 headers: { "content-type": "application/json" },
732 body: JSON.stringify({
733 deployId: id,
734 token,
735 actor: input.reader,
736 source: input.source,
737 commit: input.commit,
Projects: what a workspace builds and runs, first on every page738 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page739 buildCommand: input.settings.build_command,
740 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments741 buildEnv: build.variables,
742 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page743 }),
744 });
745 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
746 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
747 return ok(toDeployment((await this.deploymentRow(id))!));
748 }
749
Projects: what a workspace builds and runs, first on every page750 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
751 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page752 if (!settings?.enabled || !settings.production) return null;
Projects: what a workspace builds and runs, first on every page753 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page754 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page755 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page756 let head = commit;
757 if (!head) {
Projects: what a workspace builds and runs, first on every page758 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
759 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page760 }
761 if (!head) return null;
762 return this.start({
Projects: what a workspace builds and runs, first on every page763 project,
Deployments: a preview for every pull request, production on g1t.page764 kind: "production",
Projects: what a workspace builds and runs, first on every page765 branch: null,
Deployments: a preview for every pull request, production on g1t.page766 number: null,
767 commit: head,
Projects: what a workspace builds and runs, first on every page768 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page769 reader: actor,
770 createdBy,
771 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments772 // The default branch only moves by people and agents with access.
773 trusted: true,
Deployments: a preview for every pull request, production on g1t.page774 });
775 }
776
Projects: what a workspace builds and runs, first on every page777 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
778 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page779 if (!settings?.enabled || !settings.previews) return null;
Projects: what a workspace builds and runs, first on every page780 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page781 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page782 const repo = repoOf(project);
783 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page784 if (!detail.ok) return null;
785 const { pull } = detail.value;
786 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page787 // A pull request from a fork (as g1t's agents work) has no branch here.
788 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page789 if (!force) {
790 // Already built, or being built, at this commit.
791 const same = await this.db
792 .prepare(
Projects: what a workspace builds and runs, first on every page793 `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.page794 AND status IN ('queued', 'building', 'ready')`,
795 )
Projects: what a workspace builds and runs, first on every page796 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page797 .first();
798 if (same) return null;
799 }
800 return this.start({
Projects: what a workspace builds and runs, first on every page801 project,
Deployments: a preview for every pull request, production on g1t.page802 kind: "preview",
Projects: what a workspace builds and runs, first on every page803 branch,
Deployments: a preview for every pull request, production on g1t.page804 number,
805 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page806 source: pull.fork ?? repo.path,
Deployments: a preview for every pull request, production on g1t.page807 // The pull request's fork may be private: read it as its author.
808 reader: pull.author,
809 createdBy,
810 settings,
Projects: what a workspace builds and runs, first on every page811 trusted: await this.insider(repo.path, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page812 });
813 }
814
815 // ---- A build's reports ---------------------------------------------
816
817 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
818 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
819 }
820
821 /** The build, if `token` is its own and it is still under way. */
822 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
823 const row = await this.deploymentRow(id);
824 if (!row?.token_hash || typeof token !== "string") return null;
825 if (row.token_hash !== (await sha256(token))) return null;
826 return row.status === "queued" || row.status === "building" ? row : null;
827 }
828
829 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run830 // Each report, for the logs: a build's own failure says why.
831 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page832 const row = await this.building(id, body.token);
833 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
834 const cloudflare = this.cloudflare;
835 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
836 switch (step) {
837 case "started":
838 await this.db
839 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
840 .bind(now(), id)
841 .run();
842 return Response.json(ok(true));
843 case "session": {
844 const manifest = body.manifest as Manifest | undefined;
845 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
846 const session = await cloudflare.openUpload(row.script, manifest);
847 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
848 }
849 case "finish": {
850 const worker = (body.worker ?? {}) as BuiltWorker;
851 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page852 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page853 try {
Secrets and variables: one list, rows per environment, for workflows and deployments854 // Running apps' secrets and variables are bound here, by g1t:
855 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page856 const runtime = await this.resolve(
857 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
858 row.kind,
859 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know860 row.branch,
Projects: what a workspace builds and runs, first on every page861 );
Deployments: a preview for every pull request, production on g1t.page862 await cloudflare.putScript(
863 row.script,
864 worker,
865 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page866 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments867 runtime,
Deployments: a preview for every pull request, production on g1t.page868 );
869 } catch (error) {
870 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
871 return Response.json(ok(false));
872 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains873 // The same app at an older name (its workspace was renamed) now
874 // redirects here; it stays up as it was if the redirect cannot be put.
875 const redirected = await this.supersede(cloudflare, row);
Deployments: a preview for every pull request, production on g1t.page876 const at = now();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains877 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.page878 await this.db.batch([
879 this.db
880 .prepare(
881 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
882 WHERE id = ?`,
883 )
884 .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 live885 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page886 this.db
Builds say Replaced or Down once they are no longer live887 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
888 .bind(row.script, id),
889 this.db
Deployments: a preview for every pull request, production on g1t.page890 .prepare(
Projects: what a workspace builds and runs, first on every page891 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
892 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far893 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.page894 )
Projects: what a workspace builds and runs, first on every page895 .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 domains896 // A name that redirected elsewhere (a rename undone) is an app again.
897 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
898 ...redirected.flatMap((old) => [
899 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
900 this.db
901 .prepare(
902 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
903 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
904 )
905 .bind(old, appHost(row.script), row.workspace, at, expires),
906 ]),
Deployments: a preview for every pull request, production on g1t.page907 ]);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains908 // The project's own domains serve production wherever it is up.
909 if (row.kind === "production") {
910 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
911 }
Deployments: a preview for every pull request, production on g1t.page912 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page913 await this.notePeak(row.workspace);
914 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.page915 return Response.json(ok(true));
916 }
917 case "fail":
918 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
919 return Response.json(ok(true));
920 default:
921 return Response.json(fail("not_found", "No such step."), { status: 404 });
922 }
923 }
924
Agents and memory, checks and conflicts, profiles, slug renames, custom domains925 /**
926 * Older names of the app `row` just put up: one project's production, or
927 * its preview of one branch, has one name, so any other app row for the
928 * same is the app under the name it had before its workspace was renamed.
929 * Each is replaced in the namespace by a redirect to the new name; the
930 * names that were are returned, for their app rows to go.
931 */
932 private async supersede(cloudflare: Cloudflare, row: DeploymentRow): Promise<string[]> {
933 const older = await this.db
934 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
935 .bind(row.project_id, row.kind, row.branch, row.script)
936 .all<{ script: string }>();
937 const done: string[] = [];
938 for (const { script } of older.results) {
939 try {
940 await cloudflare.redirectScript(script, appHost(row.script));
941 done.push(script);
942 } catch (error) {
943 // Left as it is, the next deploy of this app tries again.
944 console.error("could not redirect", script, "to", row.script, error);
945 }
946 }
947 return done;
948 }
949
Deployments: a preview for every pull request, production on g1t.page950 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
951 const row = await this.deploymentRow(id);
952 if (!row || (row.status !== "queued" && row.status !== "building")) return;
953 await this.db
954 .prepare(
955 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
956 WHERE id = ?`,
957 )
958 .bind(message.slice(0, 2000), log, seconds, now(), id)
959 .run();
960 // A failed build still used its sandbox.
961 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page962 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.page963 }
964
965 /** Each build is charged by the second at the container price plus the margin. */
966 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays967 const costs = await this.costs();
968 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page969 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page970 const what =
971 row.kind === "preview"
972 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
973 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page974 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page975 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page976 feature: "deployments",
977 costMicros: cost,
978 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page979 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page980 reference: `deploy/${row.id}`,
981 });
982 await this.db
983 .prepare(
984 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
985 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
986 )
Projects: what a workspace builds and runs, first on every page987 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page988 .run();
989 }
990
Prices keep themselves current with what g1t pays991 /**
992 * What each unit costs g1t now, from billing's price book, which follows
993 * what Cloudflare bills. The plan's figures if billing cannot say.
994 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains995 private async costs(): Promise<{
996 buildSecond: number;
997 millionRequests: number;
998 millionCpuMs: number;
999 appMonth: number;
1000 domainMonth: number;
1001 }> {
Prices keep themselves current with what g1t pays1002 const a = DEPLOYMENTS_ALLOWANCE;
1003 const book = await billingClient(this.env.BILLING)
1004 .prices()
1005 .catch(() => null);
1006 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1007 return {
1008 buildSecond: cost("build_second", a.microsPerBuildSecond),
1009 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1010 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1011 appMonth: cost("app_month", a.microsPerAppMonth),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1012 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
Prices keep themselves current with what g1t pays1013 };
1014 }
1015
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1016 /** Remembers the most apps, and custom domains, the workspace had at once this month. */
Projects: what a workspace builds and runs, first on every page1017 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1018 await this.db
1019 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1020 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1021 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1022 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1023 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1024 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1025 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.page1026 )
Projects: what a workspace builds and runs, first on every page1027 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1028 .run();
1029 }
1030
Projects: what a workspace builds and runs, first on every page1031 /**
1032 * The check on the commit: `g1t / deploy`, or, for one of several
1033 * projects on a repository, `g1t / deploy (<project>)`.
1034 */
1035 private async status(
1036 repoId: string,
1037 sha: string,
1038 project: { slug: string; primary: boolean },
1039 state: string,
1040 description: string,
1041 targetUrl: string,
1042 ): Promise<void> {
1043 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1044 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1045 method: "POST",
1046 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1047 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1048 }).catch(() => undefined);
1049 }
1050
Projects: what a workspace builds and runs, first on every page1051 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1052 const projects = await this.projects.byRepo(row.repo_id);
1053 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1054 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1055 }
1056
Deployments: a preview for every pull request, production on g1t.page1057 // ---- Taking apps down ----------------------------------------------
1058
1059 private async removeApp(script: string): Promise<void> {
1060 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1061 await this.db.batch([
1062 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1063 // Its build is no longer live anywhere.
1064 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1065 ]);
Deployments: a preview for every pull request, production on g1t.page1066 }
1067
Projects: what a workspace builds and runs, first on every page1068 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1069 const apps = await this.db
1070 .prepare(
Projects: what a workspace builds and runs, first on every page1071 `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.page1072 )
Projects: what a workspace builds and runs, first on every page1073 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1074 .all<{ script: string }>();
1075 for (const app of apps.results) await this.removeApp(app.script);
1076 }
1077
1078 // ---- Events --------------------------------------------------------
1079
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1080 /** `attempts`: which delivery of the event this is, from 1. */
1081 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1082 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1083 case "workspace.renamed":
1084 await this.renamed(event.data, attempts);
1085 break;
Deployments: a preview for every pull request, production on g1t.page1086 case "pull.opened":
1087 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1088 case "pull.updated":
1089 for (const project of await this.projects.byRepo(event.data.repoId)) {
1090 await this.deployPreview(project, event.data.number, "g1t");
1091 }
Deployments: a preview for every pull request, production on g1t.page1092 break;
1093 case "pull.closed":
1094 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1095 for (const project of await this.projects.byRepo(event.data.repoId)) {
1096 const apps = await this.db
1097 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1098 .bind(project.id, event.data.number)
1099 .all<{ script: string }>();
1100 for (const app of apps.results) await this.removeApp(app.script);
1101 }
Deployments: a preview for every pull request, production on g1t.page1102 break;
Projects: what a workspace builds and runs, first on every page1103 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1104 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1105 for (const project of await this.projects.byRepo(event.data.repoId)) {
1106 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1107 }
Deployments: a preview for every pull request, production on g1t.page1108 break;
1109 }
1110 }
1111
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1112 /**
1113 * A workspace's slug changed, and with it every app's name: production
1114 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1115 *
1116 * Its rows move to the slug it has now (asked of identity, so a delivery
1117 * twice over, or an older rename after a newer one, ends the same), and
1118 * each app that is up is built again from the same commit under its new
1119 * name. The old name keeps serving the app until the new one is live;
1120 * then the build's finish puts a redirect to the new name in its place
1121 * (see `supersede`), held for as long as the workspace holds its old slug.
1122 *
1123 * Paused apps are left: they are rebuilt, under the new name, when the
1124 * workspace is under its limit again, and the old name redirects then.
1125 * Builds under way for the old name are dropped and started again under
1126 * the new one. Only rows still under an old slug are acted on, so a
1127 * second delivery builds nothing.
1128 */
1129 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1130 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1131 const stale = staleSlugs(renamed, current);
1132 if (stale.length === 0) return;
1133 const marks = stale.map(() => "?").join(", ");
1134
1135 // What to build again, read before the rows move.
1136 const [apps, building] = await Promise.all([
1137 this.db
1138 .prepare(`SELECT * FROM apps WHERE workspace IN (${marks}) AND paused_at IS NULL`)
1139 .bind(...stale)
1140 .all<AppRow>(),
1141 this.db
1142 .prepare(`SELECT * FROM deployments WHERE workspace IN (${marks}) AND status IN ('queued', 'building') ORDER BY id`)
1143 .bind(...stale)
1144 .all<DeploymentRow>(),
1145 ]);
1146 type Target = { projectId: string; kind: DeployKind; branch: string | null; number: number | null; commit: string };
1147 const targets = new Map<string, Target>();
1148 const key = (t: { project_id: string; kind: DeployKind; branch: string | null }) => `${t.project_id}/${t.kind}/${t.branch ?? ""}`;
1149 for (const app of apps.results) {
1150 targets.set(key(app), { projectId: app.project_id, kind: app.kind, branch: app.branch, number: app.number, commit: app.commit_sha });
1151 }
1152 // A build under way is newer than what is up.
1153 for (const row of building.results) {
1154 targets.set(key(row), { projectId: row.project_id, kind: row.kind, branch: row.branch, number: row.number, commit: row.commit_sha });
1155 }
1156
1157 // The projects, as the projects service has them now.
1158 const projectIds = [...new Set([...targets.values()].map((t) => t.projectId))];
1159 const projects = new Map<string, Project>();
1160 if (projectIds.length > 0) {
1161 const repoIds = await this.db
1162 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${projectIds.map(() => "?").join(", ")})`)
1163 .bind(...projectIds)
1164 .all<{ repo_id: string }>();
1165 for (const { repo_id } of repoIds.results) {
1166 for (const project of await this.projects.byRepo(repo_id)) {
1167 if (projectIds.includes(project.id)) projects.set(project.id, project);
1168 }
1169 }
1170 // The projects service hears of the rename on its own queue: wait for
1171 // it a few deliveries, so the builds read the repository by its new name.
1172 const behind = [...projects.values()].some((p) => p.workspace !== current);
1173 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
1174 }
1175
1176 // Every row moves at once.
1177 const at = now();
1178 const statements: D1PreparedStatement[] = [];
1179 for (const slug of stale) {
1180 statements.push(
1181 this.db
1182 .prepare(
1183 `UPDATE deployments SET status = 'skipped', error = 'The workspace was renamed: built again under its new name.',
1184 finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
1185 )
1186 .bind(at, slug),
1187 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1188 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1189 this.db
1190 .prepare(
1191 `UPDATE deployments SET workspace = ?1,
1192 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1193 WHERE workspace = ?2`,
1194 )
1195 .bind(current, slug),
1196 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1197 this.domains.rename(slug, current),
1198 // Counters add up; the peak is the higher; a month charged stays charged.
1199 this.db
1200 .prepare(
1201 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1202 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1203 FROM meters WHERE namespace = ?2
1204 ON CONFLICT (namespace, month) DO UPDATE SET
1205 requests = requests + excluded.requests,
1206 cpu_ms = cpu_ms + excluded.cpu_ms,
1207 peak_apps = MAX(peak_apps, excluded.peak_apps),
1208 peak_domains = MAX(peak_domains, excluded.peak_domains),
1209 build_seconds = build_seconds + excluded.build_seconds,
1210 build_micros = build_micros + excluded.build_micros,
1211 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1212 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1213 )
1214 .bind(current, slug),
1215 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1216 );
1217 }
1218 await this.db.batch(statements);
1219
1220 // Each app again, under its new name. Its dependencies' addresses are
1221 // read again too, so apps that call one another follow the rename.
1222 // Failures are logged, not retried: the rows have moved, and the next
1223 // deploy of the app puts it under its new name all the same.
1224 const actor = await this.workspaceActor(current);
1225 for (const target of targets.values()) {
1226 const found = projects.get(target.projectId);
1227 if (!found) continue;
1228 const project = this.underSlug(found, current, stale);
1229 try {
1230 const started =
1231 target.kind === "production"
1232 ? await this.deployProduction(project, target.commit, "g1t")
1233 : target.number != null
1234 ? await this.deployPreview(project, target.number, "g1t", true)
1235 : await this.rebuildStack(project, target.branch, target.commit, actor);
1236 if (started && !started.ok) console.log("could not rebuild", project.slug, target.branch, started.error.message);
1237 } catch (error) {
1238 console.error("could not rebuild after rename", project.slug, target.branch, error);
1239 }
1240 }
1241 }
1242
1243 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1244 private underSlug(project: Project, current: string, stale: string[]): Project {
1245 const source =
1246 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1247 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1248 : project.source;
1249 return { ...project, workspace: current, source };
1250 }
1251
1252 /** A stack's preview (no pull request of its own) built again at `commit`. */
1253 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1254 if (!actor || branch == null) return null;
1255 const settings = await this.settingsRow(project.id);
1256 if (!settings?.enabled || !settings.previews) return null;
1257 return this.start({
1258 project,
1259 kind: "preview",
1260 branch,
1261 number: null,
1262 commit,
1263 source: repoOf(project).path,
1264 reader: actor,
1265 createdBy: "g1t",
1266 settings,
1267 // As `stack` built it: the project's own default branch.
1268 trusted: true,
1269 });
1270 }
1271
Deployments: a preview for every pull request, production on g1t.page1272 // ---- The sweep -----------------------------------------------------
1273
1274 /**
1275 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1276 * previews, the apps of workspaces whose plan ended, and scripts no app
1277 * holds come down; and a month that is over is charged past its
1278 * allowance.
Deployments: a preview for every pull request, production on g1t.page1279 */
1280 async sweep(): Promise<void> {
1281 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1282 const stuck = await this.db
1283 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1284 .bind(cutoff)
1285 .all<{ id: string }>();
1286 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1287
1288 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
Projects: what a workspace builds and runs, first on every page1289 const workspaces = [...new Set(apps.map((app) => app.workspace))];
Deployments: a preview for every pull request, production on g1t.page1290
1291 // Apps of workspaces whose plan has ended come down.
1292 const billing = billingClient(this.env.BILLING);
Usage limits: unpaid usage can only go so far1293 await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1294 for (const workspace of workspaces) {
1295 const plan = await billing.hasFeature(workspace, "deployments");
1296 if (!plan.ok && plan.error.code === "payment_required") {
Projects: what a workspace builds and runs, first on every page1297 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 domains1298 // Custom domains cost g1t by the month: they go with the plan.
1299 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.page1300 }
1301 }
1302
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1303 await this.domains
1304 .sweep(async (projectId) => {
1305 const app = await this.db
1306 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1307 .bind(projectId)
1308 .first<{ script: string }>();
1309 return app?.script ?? null;
1310 })
1311 .catch((error) => console.error("could not check domains", error));
1312
Projects: what a workspace builds and runs, first on every page1313 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page1314 await this.count(apps).catch((error) => console.error("could not count usage", error));
1315 await this.takeDownIdle();
1316 await this.chargeMonths();
1317 }
1318
Usage limits: unpaid usage can only go so far1319 /**
1320 * Pauses the apps of workspaces that reached their limit for usage not
1321 * yet paid for, and rebuilds them from the same commit once they are
1322 * under it again. Paused apps answer with a notice and run nothing.
1323 */
1324 private async holdToLimits(apps: AppRow[]): Promise<void> {
1325 const cloudflare = this.cloudflare;
1326 if (!cloudflare) return;
1327 const billing = billingClient(this.env.BILLING);
1328 for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
1329 const limit = await billing.checkLimit(workspace);
1330 if (!limit.ok) continue;
1331 const theirs = apps.filter((app) => app.workspace === workspace);
1332 if (limit.value.state === "stopped") {
1333 for (const app of theirs.filter((a) => !a.paused_at)) {
1334 await cloudflare.pauseScript(app.script);
1335 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1336 }
1337 continue;
1338 }
1339 const paused = theirs.filter((a) => a.paused_at);
1340 if (paused.length === 0) continue;
1341 const actor = await this.workspaceActor(workspace);
1342 if (!actor) continue;
1343 for (const app of paused) {
1344 const project = await this.projects.get(workspace, app.slug, actor);
1345 if (!project.ok) continue;
1346 // A failed or refused rebuild leaves it paused, to try again next time.
1347 const rebuilt =
1348 app.kind === "production"
1349 ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1350 : app.number != null
1351 ? await this.deployPreview(project.value, app.number, "g1t", true)
1352 : null;
1353 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1354 }
1355 }
1356 }
1357
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1358 /**
1359 * Scripts in the namespace that no app holds, such as ones renamed. An
1360 * old address that redirects to its app's new one is held until its
1361 * redirect expires, then removed with the rest.
1362 */
Projects: what a workspace builds and runs, first on every page1363 private async removeOrphans(apps: AppRow[]): Promise<void> {
1364 const cloudflare = this.cloudflare;
1365 if (!cloudflare) return;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1366 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
1367 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
1368 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 page1369 const building = await this.db
1370 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1371 .all<{ script: string }>();
1372 for (const row of building.results) held.add(row.script);
1373 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1374 for (const script of await cloudflare.listScripts()) {
1375 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1376 }
1377 }
1378
Deployments: a preview for every pull request, production on g1t.page1379 /** Counts this month's requests and CPU time per workspace, from analytics. */
1380 private async count(apps: AppRow[]): Promise<void> {
1381 const cloudflare = this.cloudflare;
1382 if (!cloudflare || apps.length === 0) return;
1383 const start = `${month()}-01T00:00:00Z`;
1384 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1385 // Analytics only counts apps that are up; the meter keeps what earlier
1386 // apps used by never going down.
1387 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1388 for (const app of apps) {
1389 const used = totals.get(app.script);
1390 if (!used) continue;
Projects: what a workspace builds and runs, first on every page1391 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page1392 sum.requests += used.requests;
1393 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page1394 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page1395 }
1396 const at = now();
Projects: what a workspace builds and runs, first on every page1397 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page1398 await this.db
1399 .prepare(
1400 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1401 ON CONFLICT (namespace, month) DO UPDATE SET
1402 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1403 )
Projects: what a workspace builds and runs, first on every page1404 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1405 .run();
1406 }
1407 // When each preview last answered anyone, for the idle sweep.
1408 const recent = await cloudflare.usage(
1409 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1410 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1411 at,
1412 );
1413 for (const [script, used] of recent) {
1414 if (used.requests > 0) {
1415 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1416 }
1417 }
Projects: what a workspace builds and runs, first on every page1418 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
Prices keep themselves current with what g1t pays1419 // What this month's traffic past the plan will cost, so the workspace's
1420 // limit counts it now rather than when the month closes.
1421 const costs = await this.costs();
1422 const a = DEPLOYMENTS_ALLOWANCE;
1423 for (const workspace of perWorkspace.keys()) {
1424 const meter = await this.db
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1425 .prepare("SELECT requests, cpu_ms, peak_apps, peak_domains FROM meters WHERE namespace = ? AND month = ?")
Prices keep themselves current with what g1t pays1426 .bind(workspace, month())
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1427 .first<{ requests: number; cpu_ms: number; peak_apps: number; peak_domains: number }>();
Prices keep themselves current with what g1t pays1428 if (!meter) continue;
1429 const cost = Math.ceil(
1430 (Math.max(0, meter.requests - a.requests) / 1_000_000) * costs.millionRequests +
1431 (Math.max(0, meter.cpu_ms - a.cpuMs) / 1_000_000) * costs.millionCpuMs +
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1432 Math.max(0, meter.peak_apps - a.apps) * costs.appMonth +
1433 extraDomains(meter.peak_domains ?? 0) * costs.domainMonth,
Prices keep themselves current with what g1t pays1434 );
1435 await billingClient(this.env.BILLING)
1436 .notePending(workspace, "deployments", cost)
1437 .catch((error) => console.error("could not note pending usage", error));
1438 }
Deployments: a preview for every pull request, production on g1t.page1439 }
1440
Projects: what a workspace builds and runs, first on every page1441 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page1442 private async takeDownIdle(): Promise<void> {
1443 const idle = await this.db
1444 .prepare(
Projects: what a workspace builds and runs, first on every page1445 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page1446 WHERE apps.kind = 'preview'
1447 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1448 )
1449 .all<{ script: string }>();
1450 for (const { script } of idle.results) await this.removeApp(script);
1451 }
1452
1453 /** Charges each month that is over for what it used past the allowance, once. */
1454 private async chargeMonths(): Promise<void> {
1455 const due = await this.db
1456 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1457 .bind(month())
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1458 .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.page1459 const a = DEPLOYMENTS_ALLOWANCE;
Prices keep themselves current with what g1t pays1460 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page1461 for (const meter of due.results) {
1462 const extraRequests = Math.max(0, meter.requests - a.requests);
1463 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1464 const extraApps = Math.max(0, meter.peak_apps - a.apps);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1465 const moreDomains = extraDomains(meter.peak_domains ?? 0);
Deployments: a preview for every pull request, production on g1t.page1466 const cost = Math.ceil(
Prices keep themselves current with what g1t pays1467 (extraRequests / 1_000_000) * costs.millionRequests +
1468 (extraCpu / 1_000_000) * costs.millionCpuMs +
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1469 extraApps * costs.appMonth +
1470 moreDomains * costs.domainMonth,
Deployments: a preview for every pull request, production on g1t.page1471 );
1472 if (cost > 0) {
1473 const parts = [
1474 extraApps && `${extraApps} extra apps`,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1475 moreDomains && `${moreDomains} extra custom domains`,
Deployments: a preview for every pull request, production on g1t.page1476 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1477 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1478 ].filter(Boolean);
1479 const charged = await billingClient(this.env.BILLING).chargeFeature({
1480 workspace: meter.namespace,
1481 feature: "deployments",
1482 costMicros: cost,
1483 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1484 reference: `deployments/${meter.namespace}/${meter.month}`,
1485 });
1486 if (!charged.ok) continue;
1487 }
1488 await this.db
1489 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1490 .bind(now(), meter.namespace, meter.month)
1491 .run();
1492 }
1493 }
1494}
1495
1496/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know1497async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page1498 switch (method) {
1499 case "settings":
1500 return service.settings(args);
1501 case "update_settings":
1502 return service.updateSettings(args);
1503 case "list":
1504 return service.list(args);
1505 case "get":
1506 return service.get(args);
1507 case "redeploy":
1508 return service.redeploy(args);
1509 case "take_down":
1510 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know1511 case "stack":
1512 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page1513 case "overview":
1514 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page1515 case "usage":
1516 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1517 case "domains":
1518 return service.listDomains(args);
1519 case "add_domain":
1520 return service.addDomain(args);
1521 case "remove_domain":
1522 return service.removeDomain(args);
1523 case "refresh_domain":
1524 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page1525 default:
1526 return undefined;
1527 }
1528}
1529
1530export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know1531 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page1532 const { pathname } = new URL(request.url);
1533 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1534 const service = new Deployments(env);
1535 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1536 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1537 if (rpcMatch) {
Project dependencies: addresses, preview stacks, Affects, and agents who know1538 const result = await rpc(service, rpcMatch[1], body, ctx);
Deployments: a preview for every pull request, production on g1t.page1539 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1540 }
1541 // A build's reports, forwarded by the API.
1542 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1543 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1544 return new Response("Not found\n", { status: 404 });
1545 },
1546
1547 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1548 const service = new Deployments(env);
1549 for (const message of batch.messages) {
1550 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1551 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page1552 message.ack();
1553 } catch (error) {
1554 console.error("deployments could not handle", message.body.type, error);
1555 message.retry();
1556 }
1557 }
1558 },
1559
1560 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1561 await new Deployments(env).sweep();
1562 },
1563} satisfies ExportedHandler<Env, G1tEvent>;