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,975 lines86,533 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look30 ComputeGate,
31 allows,
Deployments: a preview for every pull request, production on g1t.page32 billingClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33 can,
34 needs,
35 permission,
36 sandboxEstimateMicros,
37 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains38 currentWorkspaceSlug,
Deployments: a preview for every pull request, production on g1t.page39 fail,
40 identityClient,
41 newId,
42 ok,
Projects: what a workspace builds and runs, first on every page43 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look44 repoMove,
Deployments: a preview for every pull request, production on g1t.page45 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look46 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains47 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page48 workClient,
49 type DeployKind,
50 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look51 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page52 type DeployStatus,
53 type DeployUsage,
54 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains55 type Domain,
Deployments: a preview for every pull request, production on g1t.page56 type G1tEvent,
57 type LiveApp,
Projects: what a workspace builds and runs, first on every page58 type Project,
59 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look60 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains61 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page62 type ProjectRef,
Deployments: a preview for every pull request, production on g1t.page63 type RepoPath,
64 type Result,
65 type ServiceBinding,
66 type User,
67 type Viewer,
68} from "@g1t/contracts";
69
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look70import { NEEDS, repoRef, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page71import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains72import { CustomHostnames } from "./custom-hostnames";
73import { Domains, NOT_ENABLED_NOTICE, extraDomains, toDomain } from "./domains";
74import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page75
76type Env = {
77 DB: D1Database;
78 REPOS: ServiceBinding;
79 WORK: ServiceBinding;
80 IDENTITY: ServiceBinding;
81 BILLING: ServiceBinding;
82 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page83 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments84 /** Secrets and variables: the actions service holds the one store. */
85 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page86 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
87 CLOUDFLARE_API_TOKEN?: string;
88 CLOUDFLARE_ACCOUNT_ID: string;
89 DISPATCH_NAMESPACE: string;
90 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains91 /** Custom domains: hostname to app, read by the dispatcher. */
92 DOMAINS?: KVNamespace;
93 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
94 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look95 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
96 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page97};
98
99/** A build that has not reported in this long has died. */
100const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page101/** A script in the namespace that no app holds, older than this, is removed. */
102const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page103const LIST_LIMIT = 50;
104const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains105/**
106 * Deliveries of `workspace.renamed` that wait for the projects service to
107 * have seen it too, before going ahead with the new slug regardless.
108 */
109const RENAME_WAITS = 3;
Deployments: a preview for every pull request, production on g1t.page110
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look111/** A build's report of what it found the project to be, if it is one g1t knows. */
112export function detectedKind(value: unknown): DetectedKind | null {
113 return value === "workers" || value === "static" || value === "html" ? value : null;
114}
115
Deployments: a preview for every pull request, production on g1t.page116const now = () => new Date().toISOString();
117const month = (at = new Date()) => at.toISOString().slice(0, 7);
118
119async function sha256(text: string): Promise<string> {
120 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
121 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
122}
123
124function randomToken(): string {
125 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
126}
127
128function isMember(viewer: Viewer, slug: string): boolean {
129 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
130}
131
Projects: what a workspace builds and runs, first on every page132/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look133/** One compute gate per isolate, so entitlements and prices are kept between calls. */
134let computeGate: ComputeGate | null = null;
135function gateFor(billing: ServiceBinding): ComputeGate {
136 computeGate ??= new ComputeGate(billing);
137 return computeGate;
138}
139
140/** The longest a build may run, in minutes: as long as its read token lasts. */
141const BUILD_MINUTES = 30;
142
143/**
144 * A build that was skipped before it started (its plan, its limit, or
145 * billing's refusal) as a failure, so whoever asked for it sees why.
146 */
147function notStarted(result: Result<Deployment>): Result<Deployment> {
148 if (result.ok && result.value.status === "skipped" && result.value.error) {
149 return fail("payment_required", result.value.error);
150 }
151 return result;
152}
153
Projects: what a workspace builds and runs, first on every page154function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
155 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
156 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
157}
158
Deployments: a preview for every pull request, production on g1t.page159type SettingsRow = {
Projects: what a workspace builds and runs, first on every page160 project_id: string;
161 workspace: string;
162 slug: string;
Deployments: a preview for every pull request, production on g1t.page163 repo_id: string;
164 enabled: number;
165 previews: number;
166 production: number;
167 build_command: string | null;
168 output_dir: string | null;
169 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look170 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
171 repo_deleted_at: string | null;
Deployments: a preview for every pull request, production on g1t.page172};
173
174type DeploymentRow = {
175 id: string;
Projects: what a workspace builds and runs, first on every page176 project_id: string;
177 workspace: string;
178 slug: string;
Deployments: a preview for every pull request, production on g1t.page179 repo_id: string;
Projects: what a workspace builds and runs, first on every page180 repo: string;
Deployments: a preview for every pull request, production on g1t.page181 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page182 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page183 number: number | null;
184 commit_sha: string;
185 script: string;
186 status: DeployStatus;
187 error: string | null;
188 warnings: string;
189 log: string | null;
190 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments191 trusted: number;
Deployments: a preview for every pull request, production on g1t.page192 build_seconds: number | null;
193 created_by: string;
194 created_at: string;
195 finished_at: string | null;
196};
197
198type AppRow = {
199 script: string;
Projects: what a workspace builds and runs, first on every page200 project_id: string;
201 workspace: string;
202 slug: string;
Deployments: a preview for every pull request, production on g1t.page203 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page204 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page205 number: number | null;
206 commit_sha: string;
207 deployed_at: string;
208 created_at: string;
209 last_request_at: string | null;
Usage limits: unpaid usage can only go so far210 /** Set while its workspace is over its limit; see `holdToLimits`. */
211 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page212};
213
214function toDeployment(row: DeploymentRow): Deployment {
215 return {
216 id: row.id,
217 kind: row.kind,
Projects: what a workspace builds and runs, first on every page218 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page219 number: row.number,
220 commit: row.commit_sha,
221 status: row.status,
222 url: appUrl(row.script),
223 error: row.error,
224 warnings: JSON.parse(row.warnings || "[]") as string[],
225 buildSeconds: row.build_seconds,
226 createdBy: row.created_by,
227 createdAt: row.created_at,
228 finishedAt: row.finished_at,
229 };
230}
231
Projects: what a workspace builds and runs, first on every page232function toLive(app: AppRow): LiveApp {
233 return {
234 kind: app.kind,
235 branch: app.branch,
236 number: app.number,
237 url: appUrl(app.script),
238 commit: app.commit_sha,
239 deployedAt: app.deployed_at,
240 };
241}
242
Deployments: a preview for every pull request, production on g1t.page243class Deployments {
244 constructor(private readonly env: Env) {}
245
246 private get cloudflare(): Cloudflare | null {
247 const token = this.env.CLOUDFLARE_API_TOKEN;
248 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
249 }
250
251 private get db() {
252 return this.env.DB;
253 }
254
Agents and memory, checks and conflicts, profiles, slug renames, custom domains255 private get domains(): Domains {
256 const token = this.env.CLOUDFLARE_API_TOKEN;
257 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
258 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
259 }
260
Projects: what a workspace builds and runs, first on every page261 private get projects() {
262 return projectsClient(this.env.PROJECTS);
263 }
264
Deployments: a preview for every pull request, production on g1t.page265 /** The workspace itself, as the service acts for it. */
266 private async workspaceActor(slug: string): Promise<User | null> {
267 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
268 if (!workspace) return null;
269 return {
270 id: workspace.id,
271 username: workspace.slug,
272 kind: "workspace",
273 verified: true,
274 workspaces: [{ slug: workspace.slug, role: "member" }],
275 };
276 }
277
Secrets and variables: one list, rows per environment, for workflows and deployments278 /**
Projects: what a workspace builds and runs, first on every page279 * What the project's secrets and variables available to deployments give
280 * production or a preview: its build's environment, and the same again as
281 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments282 */
283 private async resolve(
Projects: what a workspace builds and runs, first on every page284 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments285 environment: DeployKind,
286 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know287 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments288 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know289 const [rows, references] = await Promise.all([
290 this.rows(project, environment, trusted),
291 this.references(project.id, environment === "preview" ? branch : null),
292 ]);
293 // The project's own rows win over a dependency's address of the same name.
294 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
295 }
296
297 /**
298 * Each dependency's address, under the name the dependency gives it:
299 * for a preview, the same branch's preview of it if one is up, else its
300 * production; for production, its production.
301 */
302 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
303 const graph = await this.projects.graph(projectId).catch(() => null);
304 const out: Record<string, string> = {};
305 for (const dependency of graph?.dependsOn ?? []) {
306 if (!dependency.as) continue;
307 const app =
308 (branch
309 ? await this.db
310 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
311 .bind(dependency.id, branch)
312 .first<{ script: string }>()
313 : null) ??
314 (await this.db
315 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
316 .bind(dependency.id)
317 .first<{ script: string }>());
318 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
319 }
320 return out;
321 }
322
323 private async rows(
324 project: { id: string; slug: string; repoId: string; repo: RepoPath },
325 environment: DeployKind,
326 trusted: boolean,
327 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments328 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
329 method: "POST",
330 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page331 body: JSON.stringify({
332 repoId: project.repoId,
333 repo: project.repo,
334 projectId: project.id,
335 projectSlug: project.slug,
336 consumer: "deployments",
337 environment,
338 trusted,
339 }),
Secrets and variables: one list, rows per environment, for workflows and deployments340 });
341 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
342 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
343 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
344 }
345
346 /**
Projects: what a workspace builds and runs, first on every page347 * Whether a pull request's author is trusted with the project's secrets:
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look348 * g1t's agent, or someone who can push to the repository (Write or more,
349 * a member's or a collaborator's). Anyone else gets a preview built
350 * without them, as their workflows run.
Secrets and variables: one list, rows per environment, for workflows and deployments351 */
352 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
353 if (author.kind === "agent" || author.username === "g1t-agent") return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look354 const found = await identityClient(this.env.IDENTITY)
355 .collaboratorPermission(actor, repo.namespace, repo.name, author.username)
356 .catch(() => null);
357 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments358 }
359
Projects: what a workspace builds and runs, first on every page360 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
361 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page362 }
363
Projects: what a workspace builds and runs, first on every page364 /** The name an app gets, unique among apps: production, or a branch's preview. */
365 private async scriptFor(project: Project, branch: string | null): Promise<string> {
366 const base = await label(project.workspace, project.slug, branch);
367 const holder = await this.db
368 .prepare(
369 `SELECT project_id, branch FROM apps WHERE script = ?1
370 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
371 )
372 .bind(base)
373 .first<{ project_id: string; branch: string | null }>();
374 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
375 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
376 }
377
378 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page379 return {
380 enabled: !!row?.enabled,
381 previews: row ? !!row.previews : true,
382 production: row ? !!row.production : true,
383 buildCommand: row?.build_command ?? null,
384 outputDir: row?.output_dir ?? null,
385 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page386 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains387 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look388 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page389 };
390 }
391
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look392 /** What the project's last finished build found it to be; null before one has. */
393 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
394 const row = await this.db
395 .prepare(
396 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
397 )
398 .bind(projectId)
399 .first<{ detected: string }>()
400 .catch(() => null);
401 return detectedKind(row?.detected);
402 }
403
404 /**
405 * The project, if `viewer` may do what `method` needs on its repository
406 * (see `NEEDS`): not found when they cannot read it, refused when they can
407 * but their role is too low.
408 */
409 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
410 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
411 if (!found.ok) return found;
412 const repo = repoRef(found.value);
413 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
414 const capability = NEEDS[method];
415 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
416 return found;
Deployments: a preview for every pull request, production on g1t.page417 }
418
419 // ---- Methods for the site and the API ------------------------------
420
Projects: what a workspace builds and runs, first on every page421 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look422 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page423 if (!project.ok) return project;
424 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page425 }
426
427 async updateSettings(a: {
428 actor: User;
Projects: what a workspace builds and runs, first on every page429 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page430 changes: Partial<DeploySettings>;
431 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look432 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page433 if (!found.ok) return found;
434 const project = found.value;
435 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page436 const next = { ...before, ...a.changes };
437 if (next.enabled && !before.enabled) {
438 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page439 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page440 if (!plan.ok) return plan;
441 }
442 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
443 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
444 await this.db
445 .prepare(
Projects: what a workspace builds and runs, first on every page446 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
447 output_dir, idle_days, updated_by, updated_at)
448 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
449 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
450 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.page451 )
452 .bind(
Projects: what a workspace builds and runs, first on every page453 project.id,
454 project.workspace,
455 project.slug,
456 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page457 next.enabled ? 1 : 0,
458 next.previews ? 1 : 0,
459 next.production ? 1 : 0,
460 clip(next.buildCommand),
461 clip(next.outputDir),
462 idleDays,
463 a.actor.username,
464 now(),
465 )
466 .run();
467 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page468 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page469 else {
Projects: what a workspace builds and runs, first on every page470 if (!next.previews) await this.takeDownWhere(project.id, "preview");
471 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page472 }
473 // Turned on: production goes up from the default branch at once.
474 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page475 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page476 }
Projects: what a workspace builds and runs, first on every page477 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page478 }
479
Projects: what a workspace builds and runs, first on every page480 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look481 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page482 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page483 const [deployments, apps] = await Promise.all([
484 this.db
Projects: what a workspace builds and runs, first on every page485 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
486 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page487 .all<DeploymentRow>(),
488 this.db
Projects: what a workspace builds and runs, first on every page489 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
490 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page491 .all<AppRow>(),
492 ]);
Projects: what a workspace builds and runs, first on every page493 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page494 }
495
Projects: what a workspace builds and runs, first on every page496 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look497 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page498 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page499 const row = await this.db
Projects: what a workspace builds and runs, first on every page500 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
501 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page502 .first<DeploymentRow>();
503 if (!row) return fail("not_found", "No such deployment.");
504 return ok({ ...toDeployment(row), log: row.log });
505 }
506
Projects: what a workspace builds and runs, first on every page507 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look508 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page509 if (!found.ok) return found;
510 const project = found.value;
511 const settings = await this.settingsRow(project.id);
512 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
513 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look514 return notStarted((await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy."));
Projects: what a workspace builds and runs, first on every page515 }
516 // A branch's preview comes from its pull request.
517 const app = await this.db
518 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
519 .bind(project.id, a.branch)
520 .first<{ number: number }>();
521 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look522 return notStarted((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.page523 }
524
Project dependencies: addresses, preview stacks, Affects, and agents who know525 /**
526 * A preview stack: the projects that use this one get previews of their
527 * own default branch, under the same branch name, so each reaches this
528 * branch's preview through its dependency's variable. A change to an API
529 * can then be clicked through in the apps that call it.
530 */
531 async stack(
532 a: { actor: User; project: ProjectRef; branch: string },
533 background: (work: Promise<unknown>) => void,
534 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look535 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know536 if (!found.ok) return found;
537 const upstream = await this.db
538 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
539 .bind(found.value.id, a.branch)
540 .first();
541 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
542 const graph = await this.projects.graph(found.value.id);
543 const ready: { project: Project; settings: SettingsRow }[] = [];
544 for (const dependent of graph.usedBy) {
545 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look546 // Each build spends compute on its own repository: only those the actor can run.
547 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know548 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look549 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know550 }
551 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
552 // The builds start after the answer: a person moving on from the page
553 // does not stop them.
554 background(
555 (async () => {
556 for (const { project, settings } of ready) {
557 const actor = await this.workspaceActor(project.workspace);
558 if (!actor) continue;
559 const repo = repoOf(project);
560 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
561 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
562 if (!head) continue;
563 await this.start({
564 project,
565 kind: "preview",
566 branch: a.branch,
567 number: null,
568 commit: head,
569 source: repo.path,
570 reader: actor,
571 createdBy: a.actor.username,
572 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look573 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know574 trusted: true,
575 });
576 }
577 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
578 );
579 return ok(ready.map(({ project }) => project.name));
580 }
581
Projects: what a workspace builds and runs, first on every page582 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look583 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page584 if (!project.ok) return project;
585 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page586 return ok(true);
587 }
588
Projects: what a workspace builds and runs, first on every page589 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
590 const workspace = a.workspace.toLowerCase();
591 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look592 // Only the projects whose repositories the viewer can read: the
593 // projects service lists no others.
594 const listed = await this.projects.list(workspace, a.viewer);
595 if (!listed.ok) return listed;
596 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page597 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look598 // A deleted repository's projects are hidden until it is restored.
599 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ? AND repo_deleted_at IS NULL").bind(workspace).all<{ slug: string; enabled: number }>(),
Projects: what a workspace builds and runs, first on every page600 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
601 this.db
602 .prepare(
603 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
604 )
605 .bind(workspace)
606 .all<DeploymentRow>(),
607 ]);
608 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look609 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page610 const own = apps.results.filter((app) => app.slug === row.slug);
611 const production = own.find((app) => app.kind === "production");
612 const newest = latest.results.find((d) => d.slug === row.slug);
613 return {
614 slug: row.slug,
615 enabled: !!row.enabled,
616 production: production ? toLive(production) : null,
617 previews: own.filter((app) => app.kind === "preview").length,
618 latest: newest ? toDeployment(newest) : null,
619 };
620 }),
621 );
622 }
623
Deployments: a preview for every pull request, production on g1t.page624 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
625 const slug = a.workspace.toLowerCase();
626 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
627 const [meter, apps] = await Promise.all([
628 this.db
629 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
630 .bind(slug, month())
631 .first<{
632 requests: number;
633 cpu_ms: number;
634 peak_apps: number;
635 build_seconds: number;
636 build_micros: number;
637 counted_at: string | null;
638 }>(),
Projects: what a workspace builds and runs, first on every page639 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.page640 ]);
641 return ok({
642 month: month(),
643 requests: meter?.requests ?? 0,
644 cpuMs: meter?.cpu_ms ?? 0,
645 apps: apps?.n ?? 0,
646 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
647 buildSeconds: meter?.build_seconds ?? 0,
648 buildMicros: meter?.build_micros ?? 0,
649 countedAt: meter?.counted_at ?? null,
650 });
651 }
652
Agents and memory, checks and conflicts, profiles, slug renames, custom domains653 // ---- Custom domains ------------------------------------------------
654
655 async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look656 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains657 if (!project.ok) return project;
658 const domains = this.domains;
659 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
660 const [rows, available, used] = await Promise.all([
661 domains.forProject(project.value.id),
662 domains.available(),
663 domains.countFor(project.value.workspace),
664 ]);
665 return ok({
666 domains: rows.map(toDomain),
667 target: CUSTOM_DOMAIN_TARGET,
668 available,
669 notice: available ? null : NOT_ENABLED_NOTICE,
670 included: DEPLOYMENTS_ALLOWANCE.customDomains,
671 used,
672 });
673 }
674
675 async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look676 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains677 if (!found.ok) return found;
678 const project = found.value;
679 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
680 if (!plan.ok) return plan;
681 const added = await this.domains.add({
682 project: { id: project.id, workspace: project.workspace, slug: project.slug },
683 script: await this.productionScript(project),
684 hostname: String(a.hostname ?? ""),
685 twin: !!a.twin,
686 by: a.actor.username,
687 });
688 if (!added.ok) return fail(added.code, added.message);
689 await this.notePeak(project.workspace);
690 return ok(added.rows.map(toDomain));
691 }
692
693 async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look694 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains695 if (!found.ok) return found;
696 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
697 if (!row) return fail("not_found", "No such domain.");
698 await this.domains.remove(row);
699 return ok(true);
700 }
701
702 async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look703 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains704 if (!found.ok) return found;
705 const domains = this.domains;
706 const row = await domains.byId(found.value.id, String(a.id ?? ""));
707 if (!row) return fail("not_found", "No such domain.");
708 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
709 await this.notePeak(found.value.workspace);
710 return ok(toDomain(after));
711 }
712
713 /** The script production is up under, or the name it will have. */
714 private async productionScript(project: Project): Promise<string> {
715 const app = await this.db
716 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
717 .bind(project.id)
718 .first<{ script: string }>();
719 return app?.script ?? (await this.scriptFor(project, null));
720 }
721
Deployments: a preview for every pull request, production on g1t.page722 // ---- Starting builds -----------------------------------------------
723
724 /**
725 * Opens a deployment and starts its build. Skipped, with the reason
726 * recorded, when the workspace's plan is off.
727 */
728 private async start(input: {
Projects: what a workspace builds and runs, first on every page729 project: Project;
Deployments: a preview for every pull request, production on g1t.page730 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page731 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page732 number: number | null;
733 commit: string;
734 source: RepoPath;
735 reader: User;
736 createdBy: string;
737 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page738 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments739 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page740 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page741 const { project } = input;
742 const repo = repoOf(project);
743 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page744 const id = newId("dpl");
745 const token = randomToken();
Projects: what a workspace builds and runs, first on every page746 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far747 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page748 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look749 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page750 ? plan.error.message
Usage limits: unpaid usage can only go so far751 : limit.ok && limit.value.state === "stopped"
752 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page753 : !cloudflare
754 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
755 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look756 // A build is compute: reserved with billing before it starts, and
757 // settled by its sandbox when it stops. A refusal is the deployment's
758 // status, saying what to do.
759 const compute = gateFor(this.env.BILLING);
760 let reservation: string | null = null;
761 let microsPerSecond = 0;
762 let maxRunMinutes: number | null = null;
763 if (!refused) {
764 const ent = await compute.entitlements(project.workspace);
765 microsPerSecond = await compute.microsPerSecond();
766 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
767 const isPrivate = await reposClient(this.env.REPOS)
768 .get(repo.path, null)
769 .then((found) => !found.ok || found.value.isPrivate)
770 .catch(() => true);
771 const admitted = await compute.admit(
772 {
773 workspace: project.workspace,
774 repo: repo.path,
775 public: !isPrivate,
776 kind: "deploy",
777 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
778 },
779 ent,
780 );
781 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
782 else refused = admitted.message;
783 }
Deployments: a preview for every pull request, production on g1t.page784 await this.db
785 .prepare(
Projects: what a workspace builds and runs, first on every page786 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
787 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
788 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page789 )
790 .bind(
791 id,
Projects: what a workspace builds and runs, first on every page792 project.id,
793 project.workspace,
794 project.slug,
795 repo.id,
796 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page797 input.kind,
Projects: what a workspace builds and runs, first on every page798 input.branch,
Deployments: a preview for every pull request, production on g1t.page799 input.number,
800 input.commit,
801 script,
802 refused ? "skipped" : "queued",
803 refused,
804 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments805 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page806 input.createdBy,
807 now(),
808 refused ? now() : null,
809 )
810 .run();
811 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
812 // Older builds of the same app are replaced by this one.
813 await this.db
814 .prepare(
815 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
816 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
817 )
818 .bind(now(), script, id)
819 .run();
Projects: what a workspace builds and runs, first on every page820 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
821 // What the project's secrets and variables give builds of this kind.
822 const build = await this.resolve(
823 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
824 input.kind,
825 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know826 input.branch,
Projects: what a workspace builds and runs, first on every page827 );
Deployments: a preview for every pull request, production on g1t.page828 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
829 method: "POST",
830 headers: { "content-type": "application/json" },
831 body: JSON.stringify({
832 deployId: id,
833 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look834 workspace: project.workspace,
835 reservation,
836 microsPerSecond,
837 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page838 actor: input.reader,
839 source: input.source,
840 commit: input.commit,
Projects: what a workspace builds and runs, first on every page841 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page842 buildCommand: input.settings.build_command,
843 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments844 buildEnv: build.variables,
845 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page846 }),
847 });
848 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look849 if (!started.ok) {
850 // Never reached a sandbox: what was reserved is given back.
851 if (reservation) await compute.settle(reservation, 0);
852 await this.finishFailed(id, started.error.message, null, null);
853 }
Deployments: a preview for every pull request, production on g1t.page854 return ok(toDeployment((await this.deploymentRow(id))!));
855 }
856
Projects: what a workspace builds and runs, first on every page857 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
858 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look859 if (!settings?.enabled || !settings.production || settings.repo_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page860 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page861 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page862 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page863 let head = commit;
864 if (!head) {
Projects: what a workspace builds and runs, first on every page865 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
866 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page867 }
868 if (!head) return null;
869 return this.start({
Projects: what a workspace builds and runs, first on every page870 project,
Deployments: a preview for every pull request, production on g1t.page871 kind: "production",
Projects: what a workspace builds and runs, first on every page872 branch: null,
Deployments: a preview for every pull request, production on g1t.page873 number: null,
874 commit: head,
Projects: what a workspace builds and runs, first on every page875 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page876 reader: actor,
877 createdBy,
878 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments879 // The default branch only moves by people and agents with access.
880 trusted: true,
Deployments: a preview for every pull request, production on g1t.page881 });
882 }
883
Projects: what a workspace builds and runs, first on every page884 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
885 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look886 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page887 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page888 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page889 const repo = repoOf(project);
890 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page891 if (!detail.ok) return null;
892 const { pull } = detail.value;
893 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page894 // A pull request from a fork (as g1t's agents work) has no branch here.
895 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page896 if (!force) {
897 // Already built, or being built, at this commit.
898 const same = await this.db
899 .prepare(
Projects: what a workspace builds and runs, first on every page900 `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.page901 AND status IN ('queued', 'building', 'ready')`,
902 )
Projects: what a workspace builds and runs, first on every page903 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page904 .first();
905 if (same) return null;
906 }
907 return this.start({
Projects: what a workspace builds and runs, first on every page908 project,
Deployments: a preview for every pull request, production on g1t.page909 kind: "preview",
Projects: what a workspace builds and runs, first on every page910 branch,
Deployments: a preview for every pull request, production on g1t.page911 number,
912 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page913 source: pull.fork ?? repo.path,
Deployments: a preview for every pull request, production on g1t.page914 // The pull request's fork may be private: read it as its author.
915 reader: pull.author,
916 createdBy,
917 settings,
Projects: what a workspace builds and runs, first on every page918 trusted: await this.insider(repo.path, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page919 });
920 }
921
922 // ---- A build's reports ---------------------------------------------
923
924 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
925 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
926 }
927
928 /** The build, if `token` is its own and it is still under way. */
929 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
930 const row = await this.deploymentRow(id);
931 if (!row?.token_hash || typeof token !== "string") return null;
932 if (row.token_hash !== (await sha256(token))) return null;
933 return row.status === "queued" || row.status === "building" ? row : null;
934 }
935
936 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run937 // Each report, for the logs: a build's own failure says why.
938 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page939 const row = await this.building(id, body.token);
940 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
941 const cloudflare = this.cloudflare;
942 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
943 switch (step) {
944 case "started":
945 await this.db
946 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
947 .bind(now(), id)
948 .run();
949 return Response.json(ok(true));
950 case "session": {
951 const manifest = body.manifest as Manifest | undefined;
952 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
953 const session = await cloudflare.openUpload(row.script, manifest);
954 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
955 }
956 case "finish": {
957 const worker = (body.worker ?? {}) as BuiltWorker;
958 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page959 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page960 try {
Secrets and variables: one list, rows per environment, for workflows and deployments961 // Running apps' secrets and variables are bound here, by g1t:
962 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page963 const runtime = await this.resolve(
964 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
965 row.kind,
966 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know967 row.branch,
Projects: what a workspace builds and runs, first on every page968 );
Deployments: a preview for every pull request, production on g1t.page969 await cloudflare.putScript(
970 row.script,
971 worker,
972 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page973 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments974 runtime,
Deployments: a preview for every pull request, production on g1t.page975 );
976 } catch (error) {
977 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
978 return Response.json(ok(false));
979 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains980 // The same app at an older name (its workspace was renamed) now
981 // redirects here; it stays up as it was if the redirect cannot be put.
982 const redirected = await this.supersede(cloudflare, row);
Deployments: a preview for every pull request, production on g1t.page983 const at = now();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains984 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.page985 await this.db.batch([
986 this.db
987 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look988 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page989 WHERE id = ?`,
990 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look991 .bind(
992 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
993 String(body.log ?? ""),
994 seconds,
995 at,
996 detectedKind(body.detected),
997 id,
998 ),
Builds say Replaced or Down once they are no longer live999 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1000 this.db
Builds say Replaced or Down once they are no longer live1001 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1002 .bind(row.script, id),
1003 this.db
Deployments: a preview for every pull request, production on g1t.page1004 .prepare(
Projects: what a workspace builds and runs, first on every page1005 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1006 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1007 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.page1008 )
Projects: what a workspace builds and runs, first on every page1009 .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 domains1010 // A name that redirected elsewhere (a rename undone) is an app again.
1011 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
1012 ...redirected.flatMap((old) => [
1013 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1014 this.db
1015 .prepare(
1016 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1017 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1018 )
1019 .bind(old, appHost(row.script), row.workspace, at, expires),
1020 ]),
Deployments: a preview for every pull request, production on g1t.page1021 ]);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1022 // The project's own domains serve production wherever it is up.
1023 if (row.kind === "production") {
1024 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1025 }
Deployments: a preview for every pull request, production on g1t.page1026 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1027 await this.notePeak(row.workspace);
1028 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1029 // A screenshot of production as it now is, for the project's overview.
1030 if (row.kind === "production") {
1031 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1032 console.error("could not ask for a screenshot", error),
1033 );
1034 }
Deployments: a preview for every pull request, production on g1t.page1035 return Response.json(ok(true));
1036 }
1037 case "fail":
1038 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1039 return Response.json(ok(true));
1040 default:
1041 return Response.json(fail("not_found", "No such step."), { status: 404 });
1042 }
1043 }
1044
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1045 /**
1046 * Older names of the app `row` just put up: one project's production, or
1047 * its preview of one branch, has one name, so any other app row for the
1048 * same is the app under the name it had before its workspace was renamed.
1049 * Each is replaced in the namespace by a redirect to the new name; the
1050 * names that were are returned, for their app rows to go.
1051 */
1052 private async supersede(cloudflare: Cloudflare, row: DeploymentRow): Promise<string[]> {
1053 const older = await this.db
1054 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
1055 .bind(row.project_id, row.kind, row.branch, row.script)
1056 .all<{ script: string }>();
1057 const done: string[] = [];
1058 for (const { script } of older.results) {
1059 try {
1060 await cloudflare.redirectScript(script, appHost(row.script));
1061 done.push(script);
1062 } catch (error) {
1063 // Left as it is, the next deploy of this app tries again.
1064 console.error("could not redirect", script, "to", row.script, error);
1065 }
1066 }
1067 return done;
1068 }
1069
Deployments: a preview for every pull request, production on g1t.page1070 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1071 const row = await this.deploymentRow(id);
1072 if (!row || (row.status !== "queued" && row.status !== "building")) return;
1073 await this.db
1074 .prepare(
1075 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1076 WHERE id = ?`,
1077 )
1078 .bind(message.slice(0, 2000), log, seconds, now(), id)
1079 .run();
1080 // A failed build still used its sandbox.
1081 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1082 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.page1083 }
1084
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1085 /** 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.page1086 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1087 const costs = await this.costs();
1088 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1089 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1090 const what =
1091 row.kind === "preview"
1092 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1093 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1094 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1095 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1096 feature: "deployments",
1097 costMicros: cost,
1098 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1099 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1100 reference: `deploy/${row.id}`,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1101 // The plan's included build minutes pay for what they can; billing
1102 // charges the rest.
1103 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1104 });
1105 await this.db
1106 .prepare(
1107 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1108 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1109 )
Projects: what a workspace builds and runs, first on every page1110 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1111 .run();
1112 }
1113
Prices keep themselves current with what g1t pays1114 /**
1115 * What each unit costs g1t now, from billing's price book, which follows
1116 * what Cloudflare bills. The plan's figures if billing cannot say.
1117 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1118 private async costs(): Promise<{
1119 buildSecond: number;
1120 millionRequests: number;
1121 millionCpuMs: number;
1122 appMonth: number;
1123 domainMonth: number;
1124 }> {
Prices keep themselves current with what g1t pays1125 const a = DEPLOYMENTS_ALLOWANCE;
1126 const book = await billingClient(this.env.BILLING)
1127 .prices()
1128 .catch(() => null);
1129 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1130 return {
1131 buildSecond: cost("build_second", a.microsPerBuildSecond),
1132 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1133 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1134 appMonth: cost("app_month", a.microsPerAppMonth),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1135 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
Prices keep themselves current with what g1t pays1136 };
1137 }
1138
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1139 /** Remembers the most apps, and custom domains, the workspace had at once this month. */
Projects: what a workspace builds and runs, first on every page1140 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1141 await this.db
1142 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1143 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1144 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1145 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1146 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1147 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1148 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.page1149 )
Projects: what a workspace builds and runs, first on every page1150 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1151 .run();
1152 }
1153
Projects: what a workspace builds and runs, first on every page1154 /**
1155 * The check on the commit: `g1t / deploy`, or, for one of several
1156 * projects on a repository, `g1t / deploy (<project>)`.
1157 */
1158 private async status(
1159 repoId: string,
1160 sha: string,
1161 project: { slug: string; primary: boolean },
1162 state: string,
1163 description: string,
1164 targetUrl: string,
1165 ): Promise<void> {
1166 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1167 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1168 method: "POST",
1169 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1170 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1171 }).catch(() => undefined);
1172 }
1173
Projects: what a workspace builds and runs, first on every page1174 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1175 const projects = await this.projects.byRepo(row.repo_id);
1176 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1177 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1178 }
1179
Deployments: a preview for every pull request, production on g1t.page1180 // ---- Taking apps down ----------------------------------------------
1181
1182 private async removeApp(script: string): Promise<void> {
1183 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1184 await this.db.batch([
1185 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1186 // Its build is no longer live anywhere.
1187 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1188 ]);
Deployments: a preview for every pull request, production on g1t.page1189 }
1190
Projects: what a workspace builds and runs, first on every page1191 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1192 const apps = await this.db
1193 .prepare(
Projects: what a workspace builds and runs, first on every page1194 `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.page1195 )
Projects: what a workspace builds and runs, first on every page1196 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1197 .all<{ script: string }>();
1198 for (const app of apps.results) await this.removeApp(app.script);
1199 }
1200
1201 // ---- Events --------------------------------------------------------
1202
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1203 /** `attempts`: which delivery of the event this is, from 1. */
1204 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1205 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1206 case "workspace.renamed":
1207 await this.renamed(event.data, attempts);
1208 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1209 case "repo.transferred":
1210 case "repo.renamed":
1211 await this.moved(repoMove(event)!, attempts);
1212 break;
1213 case "repo.deleted":
1214 await this.repoDeleted(event.data.repoId);
1215 break;
1216 case "repo.restored":
1217 await this.repoRestored(event.data.repoId, attempts);
1218 break;
1219 case "repo.purged":
1220 await this.repoPurged(event.data.repoId);
1221 break;
1222 case "repo.default_branch_changed":
1223 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1224 break;
1225 case "branch.renamed":
1226 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1227 break;
1228
Deployments: a preview for every pull request, production on g1t.page1229 case "pull.opened":
1230 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1231 case "pull.updated":
1232 for (const project of await this.projects.byRepo(event.data.repoId)) {
1233 await this.deployPreview(project, event.data.number, "g1t");
1234 }
Deployments: a preview for every pull request, production on g1t.page1235 break;
1236 case "pull.closed":
1237 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1238 for (const project of await this.projects.byRepo(event.data.repoId)) {
1239 const apps = await this.db
1240 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1241 .bind(project.id, event.data.number)
1242 .all<{ script: string }>();
1243 for (const app of apps.results) await this.removeApp(app.script);
1244 }
Deployments: a preview for every pull request, production on g1t.page1245 break;
Projects: what a workspace builds and runs, first on every page1246 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1247 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1248 for (const project of await this.projects.byRepo(event.data.repoId)) {
1249 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1250 }
Deployments: a preview for every pull request, production on g1t.page1251 break;
1252 }
1253 }
1254
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1255 /**
1256 * A workspace's slug changed, and with it every app's name: production
1257 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1258 *
1259 * Its rows move to the slug it has now (asked of identity, so a delivery
1260 * twice over, or an older rename after a newer one, ends the same), and
1261 * each app that is up is built again from the same commit under its new
1262 * name. The old name keeps serving the app until the new one is live;
1263 * then the build's finish puts a redirect to the new name in its place
1264 * (see `supersede`), held for as long as the workspace holds its old slug.
1265 *
1266 * Paused apps are left: they are rebuilt, under the new name, when the
1267 * workspace is under its limit again, and the old name redirects then.
1268 * Builds under way for the old name are dropped and started again under
1269 * the new one. Only rows still under an old slug are acted on, so a
1270 * second delivery builds nothing.
1271 */
1272 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1273 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1274 const stale = staleSlugs(renamed, current);
1275 if (stale.length === 0) return;
1276 const marks = stale.map(() => "?").join(", ");
1277
1278 // What to build again, read before the rows move.
1279 const [apps, building] = await Promise.all([
1280 this.db
1281 .prepare(`SELECT * FROM apps WHERE workspace IN (${marks}) AND paused_at IS NULL`)
1282 .bind(...stale)
1283 .all<AppRow>(),
1284 this.db
1285 .prepare(`SELECT * FROM deployments WHERE workspace IN (${marks}) AND status IN ('queued', 'building') ORDER BY id`)
1286 .bind(...stale)
1287 .all<DeploymentRow>(),
1288 ]);
1289 type Target = { projectId: string; kind: DeployKind; branch: string | null; number: number | null; commit: string };
1290 const targets = new Map<string, Target>();
1291 const key = (t: { project_id: string; kind: DeployKind; branch: string | null }) => `${t.project_id}/${t.kind}/${t.branch ?? ""}`;
1292 for (const app of apps.results) {
1293 targets.set(key(app), { projectId: app.project_id, kind: app.kind, branch: app.branch, number: app.number, commit: app.commit_sha });
1294 }
1295 // A build under way is newer than what is up.
1296 for (const row of building.results) {
1297 targets.set(key(row), { projectId: row.project_id, kind: row.kind, branch: row.branch, number: row.number, commit: row.commit_sha });
1298 }
1299
1300 // The projects, as the projects service has them now.
1301 const projectIds = [...new Set([...targets.values()].map((t) => t.projectId))];
1302 const projects = new Map<string, Project>();
1303 if (projectIds.length > 0) {
1304 const repoIds = await this.db
1305 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${projectIds.map(() => "?").join(", ")})`)
1306 .bind(...projectIds)
1307 .all<{ repo_id: string }>();
1308 for (const { repo_id } of repoIds.results) {
1309 for (const project of await this.projects.byRepo(repo_id)) {
1310 if (projectIds.includes(project.id)) projects.set(project.id, project);
1311 }
1312 }
1313 // The projects service hears of the rename on its own queue: wait for
1314 // it a few deliveries, so the builds read the repository by its new name.
1315 const behind = [...projects.values()].some((p) => p.workspace !== current);
1316 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
1317 }
1318
1319 // Every row moves at once.
1320 const at = now();
1321 const statements: D1PreparedStatement[] = [];
1322 for (const slug of stale) {
1323 statements.push(
1324 this.db
1325 .prepare(
1326 `UPDATE deployments SET status = 'skipped', error = 'The workspace was renamed: built again under its new name.',
1327 finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
1328 )
1329 .bind(at, slug),
1330 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1331 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1332 this.db
1333 .prepare(
1334 `UPDATE deployments SET workspace = ?1,
1335 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1336 WHERE workspace = ?2`,
1337 )
1338 .bind(current, slug),
1339 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1340 this.domains.rename(slug, current),
1341 // Counters add up; the peak is the higher; a month charged stays charged.
1342 this.db
1343 .prepare(
1344 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1345 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1346 FROM meters WHERE namespace = ?2
1347 ON CONFLICT (namespace, month) DO UPDATE SET
1348 requests = requests + excluded.requests,
1349 cpu_ms = cpu_ms + excluded.cpu_ms,
1350 peak_apps = MAX(peak_apps, excluded.peak_apps),
1351 peak_domains = MAX(peak_domains, excluded.peak_domains),
1352 build_seconds = build_seconds + excluded.build_seconds,
1353 build_micros = build_micros + excluded.build_micros,
1354 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1355 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1356 )
1357 .bind(current, slug),
1358 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1359 );
1360 }
1361 await this.db.batch(statements);
1362
1363 // Each app again, under its new name. Its dependencies' addresses are
1364 // read again too, so apps that call one another follow the rename.
1365 // Failures are logged, not retried: the rows have moved, and the next
1366 // deploy of the app puts it under its new name all the same.
1367 const actor = await this.workspaceActor(current);
1368 for (const target of targets.values()) {
1369 const found = projects.get(target.projectId);
1370 if (!found) continue;
1371 const project = this.underSlug(found, current, stale);
1372 try {
1373 const started =
1374 target.kind === "production"
1375 ? await this.deployProduction(project, target.commit, "g1t")
1376 : target.number != null
1377 ? await this.deployPreview(project, target.number, "g1t", true)
1378 : await this.rebuildStack(project, target.branch, target.commit, actor);
1379 if (started && !started.ok) console.log("could not rebuild", project.slug, target.branch, started.error.message);
1380 } catch (error) {
1381 console.error("could not rebuild after rename", project.slug, target.branch, error);
1382 }
1383 }
1384 }
1385
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1386 /**
1387 * A repository moved: transferred to another workspace, and its projects
1388 * with it, or renamed within its own, when its own project's slug follows
1389 * its name (see the projects service). Each app's name is
1390 * `<project>-<workspace>`, so the apps of every project whose workspace or
1391 * slug changed (production and every preview) are built again, from the
1392 * same commit, under the new name. As after a workspace rename, the old
1393 * name keeps serving until the new one is live, then redirects to it
1394 * (see `supersede`) for `SLUG_HOLD_DAYS`. The project's custom domains
1395 * follow its production. Builds under way are started again under the
1396 * new name. What the apps used this month stays on the old workspace's
1397 * meter; from now on, the new workspace's counts it.
1398 *
1399 * Projects whose name did not change (a rename where the new name was
1400 * taken, or a project of another name) only learn the repository's new
1401 * path. Rows are compared with where the project is now, so a second or
1402 * late delivery builds nothing.
1403 */
1404 private async moved(move: RepoMove, attempts: number): Promise<void> {
1405 const current = await currentMovedPath(this.env.REPOS, move);
1406 if (staleMovedPaths(move, current).length === 0) return;
1407 const [workspace, name] = current.split("/") as [string, string];
1408 const settings = await this.db
1409 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1410 .bind(move.repoId)
1411 .all<{ project_id: string; workspace: string; slug: string }>();
1412 if (settings.results.length === 0) return;
1413
1414 // The projects service hears of the move on its own queue: wait for it
1415 // a few deliveries, so builds read the project where and as it is now.
1416 const projects = new Map<string, Project>();
1417 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1418 const behind = settings.results.some(({ project_id }) => {
1419 const project = projects.get(project_id);
1420 if (!project || project.source.kind !== "hosted") return false;
1421 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1422 });
1423 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1424
1425 // Its history goes with it, as the repository's issues do.
1426 const statements: D1PreparedStatement[] = [
1427 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1428 ];
1429 // The projects whose apps' names change.
1430 const moving = settings.results.filter(
1431 (row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug,
1432 );
1433 const projectIds = moving.map((row) => row.project_id);
1434 if (projectIds.length === 0) {
1435 await this.db.batch(statements);
1436 return;
1437 }
1438 const ids = projectIds.map(() => "?").join(", ");
1439
1440 const [apps, building] = await Promise.all([
1441 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${ids}) AND paused_at IS NULL`).bind(...projectIds).all<AppRow>(),
1442 this.db
1443 .prepare(`SELECT * FROM deployments WHERE project_id IN (${ids}) AND status IN ('queued', 'building') ORDER BY id`)
1444 .bind(...projectIds)
1445 .all<DeploymentRow>(),
1446 ]);
1447 type Target = { projectId: string; kind: DeployKind; branch: string | null; number: number | null; commit: string };
1448 const targets = new Map<string, Target>();
1449 const key = (t: { project_id: string; kind: DeployKind; branch: string | null }) => `${t.project_id}/${t.kind}/${t.branch ?? ""}`;
1450 for (const app of apps.results) {
1451 targets.set(key(app), { projectId: app.project_id, kind: app.kind, branch: app.branch, number: app.number, commit: app.commit_sha });
1452 }
1453 // A build under way is newer than what is up.
1454 for (const row of building.results) {
1455 targets.set(key(row), { projectId: row.project_id, kind: row.kind, branch: row.branch, number: row.number, commit: row.commit_sha });
1456 }
1457
1458 const at = now();
1459 statements.push(
1460 this.db
1461 .prepare(
1462 `UPDATE deployments SET status = 'skipped', error = 'The repository moved: built again under its new name.',
1463 finished_at = ? WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1464 )
1465 .bind(at, ...projectIds),
1466 );
1467 for (const projectId of projectIds) {
1468 const slug = projects.get(projectId)?.slug ?? null;
1469 statements.push(
1470 this.db
1471 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1472 .bind(workspace, slug, projectId),
1473 this.db
1474 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1475 .bind(workspace, slug, projectId),
1476 this.db
1477 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1478 .bind(workspace, slug, projectId),
1479 // Within the workspace its apps are listed under the project's name
1480 // now. One left behind in another workspace stays as it is until the
1481 // new name is live and redirects it.
1482 this.db
1483 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1484 .bind(workspace, slug, projectId),
1485 );
1486 }
1487 await this.db.batch(statements);
1488
1489 // Each app again, under its new name. App rows under the old name stay
1490 // until the new one is live, which redirects them.
1491 const actor = await this.workspaceActor(workspace);
1492 for (const target of targets.values()) {
1493 const found = projects.get(target.projectId);
1494 if (!found) continue;
1495 const project: Project =
1496 found.source.kind === "hosted"
1497 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1498 : { ...found, workspace };
1499 try {
1500 const started =
1501 target.kind === "production"
1502 ? await this.deployProduction(project, target.commit, "g1t")
1503 : target.number != null
1504 ? await this.deployPreview(project, target.number, "g1t", true)
1505 : await this.rebuildStack(project, target.branch, target.commit, actor);
1506 if (started && !started.ok) console.log("could not rebuild", project.slug, target.branch, started.error.message);
1507 } catch (error) {
1508 console.error("could not rebuild after move", project.slug, target.branch, error);
1509 }
1510 }
1511 }
1512
1513 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1514 private async projectIdsFor(repoId: string): Promise<string[]> {
1515 const rows = await this.db
1516 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1517 .bind(repoId)
1518 .all<{ project_id: string }>();
1519 return rows.results.map((row) => row.project_id);
1520 }
1521
1522 /**
1523 * A repository was deleted, restorable for a while: every app of its
1524 * projects (production and previews) comes down, builds under way are
1525 * dropped, and nothing builds for it until it is restored. Its settings
1526 * and custom domains are kept for the restore; until then a domain has
1527 * nothing up to serve, as when production is turned off.
1528 */
1529 private async repoDeleted(repoId: string): Promise<void> {
1530 const projectIds = await this.projectIdsFor(repoId);
1531 if (projectIds.length === 0) return;
1532 const at = now();
1533 await this.db.batch([
1534 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1535 this.db
1536 .prepare(
1537 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1538 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1539 )
1540 .bind(at, repoId),
1541 ]);
1542 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1543 }
1544
1545 /**
1546 * A deleted repository is back: production goes up again from its
1547 * default branch, for each project that has it on. Previews come back
1548 * with the next push to their pull requests.
1549 */
1550 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1551 const deleted = await this.db
1552 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1553 .bind(repoId)
1554 .all<{ project_id: string }>();
1555 const ids = deleted.results.map((row) => row.project_id);
1556 if (ids.length === 0) return;
1557 // The projects service hears of the restore on its own queue, and hides
1558 // the projects until then: wait for it a few deliveries.
1559 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1560 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1561 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1562 for (const project of projects) {
1563 try {
1564 const started = await this.deployProduction(project, null, "g1t");
1565 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1566 } catch (error) {
1567 console.error("could not deploy after restore", project.slug, error);
1568 }
1569 }
1570 }
1571
1572 /**
1573 * A deleted repository is gone for good: its projects' custom domains are
1574 * removed (from the dispatcher and from Cloudflare), any app or redirect
1575 * still up comes down, and every row kept for them goes. What they used
1576 * stays on their workspace's meter.
1577 */
1578 private async repoPurged(repoId: string): Promise<void> {
1579 const projectIds = await this.projectIdsFor(repoId);
1580 const domains = this.domains;
1581 for (const projectId of projectIds) {
1582 await this.takeDownWhere(projectId, null);
1583 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1584 await domains.removeWhere("project_id", projectId);
1585 }
1586 // The redirects left at names its apps had before.
1587 const scripts = await this.db
1588 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1589 .bind(repoId)
1590 .all<{ script: string }>();
1591 const hosts = scripts.results.map(({ script }) => appHost(script));
1592 for (let i = 0; i < hosts.length; i += 50) {
1593 const chunk = hosts.slice(i, i + 50);
1594 const redirects = await this.db
1595 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1596 .bind(...chunk)
1597 .all<{ script: string }>();
1598 for (const { script } of redirects.results) {
1599 await this.cloudflare?.deleteScript(script);
1600 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1601 }
1602 }
1603 await this.db.batch([
1604 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1605 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1606 ]);
1607 }
1608
1609 /**
1610 * The default branch is another one now: production is built from it, as
1611 * from a push to it, unless production already serves (or is building)
1612 * its commit, as when the default branch was only renamed.
1613 */
1614 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1615 for (const found of await this.projects.byRepo(repoId)) {
1616 if (found.source.kind !== "hosted") continue;
1617 // Projects may not have heard yet: the event names the branch.
1618 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1619 const actor = await this.workspaceActor(project.workspace);
1620 if (!actor) continue;
1621 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1622 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1623 if (!head) continue;
1624 const same = await this.db
1625 .prepare(
1626 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1627 AND status IN ('queued', 'building', 'ready')`,
1628 )
1629 .bind(project.id, head)
1630 .first();
1631 if (same) continue;
1632 await this.deployProduction(project, head, createdBy);
1633 }
1634 }
1635
1636 /**
1637 * A branch was renamed: its preview is the same app, so its rows follow.
1638 * The app keeps its name until it is next built; then it goes up under
1639 * the new branch's name, and the old one redirects there (see `supersede`).
1640 */
1641 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1642 const projectIds = await this.projectIdsFor(repoId);
1643 if (projectIds.length === 0) return;
1644 const ids = projectIds.map(() => "?").join(", ");
1645 await this.db.batch([
1646 this.db
1647 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1648 .bind(to, from, ...projectIds),
1649 this.db
1650 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1651 .bind(to, from, ...projectIds),
1652 ]);
1653 }
1654
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1655 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1656 private underSlug(project: Project, current: string, stale: string[]): Project {
1657 const source =
1658 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1659 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1660 : project.source;
1661 return { ...project, workspace: current, source };
1662 }
1663
1664 /** A stack's preview (no pull request of its own) built again at `commit`. */
1665 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1666 if (!actor || branch == null) return null;
1667 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1668 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1669 return this.start({
1670 project,
1671 kind: "preview",
1672 branch,
1673 number: null,
1674 commit,
1675 source: repoOf(project).path,
1676 reader: actor,
1677 createdBy: "g1t",
1678 settings,
1679 // As `stack` built it: the project's own default branch.
1680 trusted: true,
1681 });
1682 }
1683
Deployments: a preview for every pull request, production on g1t.page1684 // ---- The sweep -----------------------------------------------------
1685
1686 /**
1687 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1688 * previews, the apps of workspaces whose plan ended, and scripts no app
1689 * holds come down; and a month that is over is charged past its
1690 * allowance.
Deployments: a preview for every pull request, production on g1t.page1691 */
1692 async sweep(): Promise<void> {
1693 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1694 const stuck = await this.db
1695 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1696 .bind(cutoff)
1697 .all<{ id: string }>();
1698 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1699
1700 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
Projects: what a workspace builds and runs, first on every page1701 const workspaces = [...new Set(apps.map((app) => app.workspace))];
Deployments: a preview for every pull request, production on g1t.page1702
1703 // Apps of workspaces whose plan has ended come down.
1704 const billing = billingClient(this.env.BILLING);
Usage limits: unpaid usage can only go so far1705 await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1706 for (const workspace of workspaces) {
1707 const plan = await billing.hasFeature(workspace, "deployments");
1708 if (!plan.ok && plan.error.code === "payment_required") {
Projects: what a workspace builds and runs, first on every page1709 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 domains1710 // Custom domains cost g1t by the month: they go with the plan.
1711 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.page1712 }
1713 }
1714
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1715 await this.domains
1716 .sweep(async (projectId) => {
1717 const app = await this.db
1718 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1719 .bind(projectId)
1720 .first<{ script: string }>();
1721 return app?.script ?? null;
1722 })
1723 .catch((error) => console.error("could not check domains", error));
1724
Projects: what a workspace builds and runs, first on every page1725 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page1726 await this.count(apps).catch((error) => console.error("could not count usage", error));
1727 await this.takeDownIdle();
1728 await this.chargeMonths();
1729 }
1730
Usage limits: unpaid usage can only go so far1731 /**
1732 * Pauses the apps of workspaces that reached their limit for usage not
1733 * yet paid for, and rebuilds them from the same commit once they are
1734 * under it again. Paused apps answer with a notice and run nothing.
1735 */
1736 private async holdToLimits(apps: AppRow[]): Promise<void> {
1737 const cloudflare = this.cloudflare;
1738 if (!cloudflare) return;
1739 const billing = billingClient(this.env.BILLING);
1740 for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
1741 const limit = await billing.checkLimit(workspace);
1742 if (!limit.ok) continue;
1743 const theirs = apps.filter((app) => app.workspace === workspace);
1744 if (limit.value.state === "stopped") {
1745 for (const app of theirs.filter((a) => !a.paused_at)) {
1746 await cloudflare.pauseScript(app.script);
1747 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1748 }
1749 continue;
1750 }
1751 const paused = theirs.filter((a) => a.paused_at);
1752 if (paused.length === 0) continue;
1753 const actor = await this.workspaceActor(workspace);
1754 if (!actor) continue;
1755 for (const app of paused) {
1756 const project = await this.projects.get(workspace, app.slug, actor);
1757 if (!project.ok) continue;
1758 // A failed or refused rebuild leaves it paused, to try again next time.
1759 const rebuilt =
1760 app.kind === "production"
1761 ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1762 : app.number != null
1763 ? await this.deployPreview(project.value, app.number, "g1t", true)
1764 : null;
1765 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1766 }
1767 }
1768 }
1769
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1770 /**
1771 * Scripts in the namespace that no app holds, such as ones renamed. An
1772 * old address that redirects to its app's new one is held until its
1773 * redirect expires, then removed with the rest.
1774 */
Projects: what a workspace builds and runs, first on every page1775 private async removeOrphans(apps: AppRow[]): Promise<void> {
1776 const cloudflare = this.cloudflare;
1777 if (!cloudflare) return;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1778 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
1779 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
1780 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 page1781 const building = await this.db
1782 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1783 .all<{ script: string }>();
1784 for (const row of building.results) held.add(row.script);
1785 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1786 for (const script of await cloudflare.listScripts()) {
1787 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1788 }
1789 }
1790
Deployments: a preview for every pull request, production on g1t.page1791 /** Counts this month's requests and CPU time per workspace, from analytics. */
1792 private async count(apps: AppRow[]): Promise<void> {
1793 const cloudflare = this.cloudflare;
1794 if (!cloudflare || apps.length === 0) return;
1795 const start = `${month()}-01T00:00:00Z`;
1796 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1797 // Analytics only counts apps that are up; the meter keeps what earlier
1798 // apps used by never going down.
1799 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1800 for (const app of apps) {
1801 const used = totals.get(app.script);
1802 if (!used) continue;
Projects: what a workspace builds and runs, first on every page1803 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page1804 sum.requests += used.requests;
1805 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page1806 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page1807 }
1808 const at = now();
Projects: what a workspace builds and runs, first on every page1809 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page1810 await this.db
1811 .prepare(
1812 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1813 ON CONFLICT (namespace, month) DO UPDATE SET
1814 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1815 )
Projects: what a workspace builds and runs, first on every page1816 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1817 .run();
1818 }
1819 // When each preview last answered anyone, for the idle sweep.
1820 const recent = await cloudflare.usage(
1821 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1822 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1823 at,
1824 );
1825 for (const [script, used] of recent) {
1826 if (used.requests > 0) {
1827 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1828 }
1829 }
Projects: what a workspace builds and runs, first on every page1830 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
Prices keep themselves current with what g1t pays1831 // What this month's traffic past the plan will cost, so the workspace's
1832 // limit counts it now rather than when the month closes.
1833 const costs = await this.costs();
1834 const a = DEPLOYMENTS_ALLOWANCE;
1835 for (const workspace of perWorkspace.keys()) {
1836 const meter = await this.db
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1837 .prepare("SELECT requests, cpu_ms, peak_apps, peak_domains FROM meters WHERE namespace = ? AND month = ?")
Prices keep themselves current with what g1t pays1838 .bind(workspace, month())
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1839 .first<{ requests: number; cpu_ms: number; peak_apps: number; peak_domains: number }>();
Prices keep themselves current with what g1t pays1840 if (!meter) continue;
1841 const cost = Math.ceil(
1842 (Math.max(0, meter.requests - a.requests) / 1_000_000) * costs.millionRequests +
1843 (Math.max(0, meter.cpu_ms - a.cpuMs) / 1_000_000) * costs.millionCpuMs +
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1844 Math.max(0, meter.peak_apps - a.apps) * costs.appMonth +
1845 extraDomains(meter.peak_domains ?? 0) * costs.domainMonth,
Prices keep themselves current with what g1t pays1846 );
1847 await billingClient(this.env.BILLING)
1848 .notePending(workspace, "deployments", cost)
1849 .catch((error) => console.error("could not note pending usage", error));
1850 }
Deployments: a preview for every pull request, production on g1t.page1851 }
1852
Projects: what a workspace builds and runs, first on every page1853 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page1854 private async takeDownIdle(): Promise<void> {
1855 const idle = await this.db
1856 .prepare(
Projects: what a workspace builds and runs, first on every page1857 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page1858 WHERE apps.kind = 'preview'
1859 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1860 )
1861 .all<{ script: string }>();
1862 for (const { script } of idle.results) await this.removeApp(script);
1863 }
1864
1865 /** Charges each month that is over for what it used past the allowance, once. */
1866 private async chargeMonths(): Promise<void> {
1867 const due = await this.db
1868 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1869 .bind(month())
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1870 .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.page1871 const a = DEPLOYMENTS_ALLOWANCE;
Prices keep themselves current with what g1t pays1872 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page1873 for (const meter of due.results) {
1874 const extraRequests = Math.max(0, meter.requests - a.requests);
1875 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1876 const extraApps = Math.max(0, meter.peak_apps - a.apps);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1877 const moreDomains = extraDomains(meter.peak_domains ?? 0);
Deployments: a preview for every pull request, production on g1t.page1878 const cost = Math.ceil(
Prices keep themselves current with what g1t pays1879 (extraRequests / 1_000_000) * costs.millionRequests +
1880 (extraCpu / 1_000_000) * costs.millionCpuMs +
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1881 extraApps * costs.appMonth +
1882 moreDomains * costs.domainMonth,
Deployments: a preview for every pull request, production on g1t.page1883 );
1884 if (cost > 0) {
1885 const parts = [
1886 extraApps && `${extraApps} extra apps`,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1887 moreDomains && `${moreDomains} extra custom domains`,
Deployments: a preview for every pull request, production on g1t.page1888 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1889 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1890 ].filter(Boolean);
1891 const charged = await billingClient(this.env.BILLING).chargeFeature({
1892 workspace: meter.namespace,
1893 feature: "deployments",
1894 costMicros: cost,
1895 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1896 reference: `deployments/${meter.namespace}/${meter.month}`,
1897 });
1898 if (!charged.ok) continue;
1899 }
1900 await this.db
1901 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1902 .bind(now(), meter.namespace, meter.month)
1903 .run();
1904 }
1905 }
1906}
1907
1908/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know1909async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page1910 switch (method) {
1911 case "settings":
1912 return service.settings(args);
1913 case "update_settings":
1914 return service.updateSettings(args);
1915 case "list":
1916 return service.list(args);
1917 case "get":
1918 return service.get(args);
1919 case "redeploy":
1920 return service.redeploy(args);
1921 case "take_down":
1922 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know1923 case "stack":
1924 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page1925 case "overview":
1926 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page1927 case "usage":
1928 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1929 case "domains":
1930 return service.listDomains(args);
1931 case "add_domain":
1932 return service.addDomain(args);
1933 case "remove_domain":
1934 return service.removeDomain(args);
1935 case "refresh_domain":
1936 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page1937 default:
1938 return undefined;
1939 }
1940}
1941
1942export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know1943 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page1944 const { pathname } = new URL(request.url);
1945 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1946 const service = new Deployments(env);
1947 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1948 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1949 if (rpcMatch) {
Project dependencies: addresses, preview stacks, Affects, and agents who know1950 const result = await rpc(service, rpcMatch[1], body, ctx);
Deployments: a preview for every pull request, production on g1t.page1951 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1952 }
1953 // A build's reports, forwarded by the API.
1954 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1955 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1956 return new Response("Not found\n", { status: 404 });
1957 },
1958
1959 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1960 const service = new Deployments(env);
1961 for (const message of batch.messages) {
1962 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1963 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page1964 message.ack();
1965 } catch (error) {
1966 console.error("deployments could not handle", message.body.type, error);
1967 message.retry();
1968 }
1969 }
1970 },
1971
1972 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1973 await new Deployments(env).sweep();
1974 },
1975} satisfies ExportedHandler<Env, G1tEvent>;