g1t/services/deployments/src/index.ts

2,117 lines94,133 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 *
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas15 * Nothing here is free, and nothing is capped. On the plan, every build
16 * second is charged as it happens; requests, CPU time and custom domains
17 * are charged from the first once the month is over, all at cost plus the
18 * margin and from the plan's included usage first. No count of projects,
19 * previews or apps ever stops or pauses a workspace: apps are not metered
20 * at all. Only the workspace's spend limit pauses its apps
21 * (`holdToLimits`), and only the plan ending takes them down. A Worker
22 * runs only while it answers a request, so an app no one visits costs
23 * nothing, and a preview is taken down when its pull request closes or
24 * after its project's idle days.
Deployments: a preview for every pull request, production on g1t.page25 *
26 * Reached through service bindings (`POST /rpc/<method>`) and, for a
27 * build's reports, through the API (`POST /jobs/<id>/<step>`).
28 */
29
30import {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains31 CUSTOM_DOMAIN_TARGET,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas32 DEPLOYMENT_COSTS,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains33 SLUG_HOLD_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look34 ComputeGate,
35 allows,
Deployments: a preview for every pull request, production on g1t.page36 billingClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 can,
38 needs,
39 permission,
40 sandboxEstimateMicros,
41 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains42 currentWorkspaceSlug,
Deployments: a preview for every pull request, production on g1t.page43 fail,
44 identityClient,
45 newId,
46 ok,
Projects: what a workspace builds and runs, first on every page47 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look48 repoMove,
Deployments: a preview for every pull request, production on g1t.page49 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains51 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page52 workClient,
53 type DeployKind,
54 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look55 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page56 type DeployStatus,
57 type DeployUsage,
58 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains59 type Domain,
Deployments: a preview for every pull request, production on g1t.page60 type G1tEvent,
61 type LiveApp,
Projects: what a workspace builds and runs, first on every page62 type Project,
63 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look64 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains65 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page66 type ProjectRef,
Deployments: a preview for every pull request, production on g1t.page67 type RepoPath,
68 type Result,
69 type ServiceBinding,
70 type User,
71 type Viewer,
72} from "@g1t/contracts";
73
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74import { NEEDS, repoRef, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page75import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains76import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas77import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
78import { monthCost } from "./metering";
79import { moveTargets, ownerOf, rebuildOutcome, retryDue, type DroppedBuild, type MoveTarget } from "./moves";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains80import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page81
82type Env = {
83 DB: D1Database;
84 REPOS: ServiceBinding;
85 WORK: ServiceBinding;
86 IDENTITY: ServiceBinding;
87 BILLING: ServiceBinding;
88 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page89 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments90 /** Secrets and variables: the actions service holds the one store. */
91 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page92 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
93 CLOUDFLARE_API_TOKEN?: string;
94 CLOUDFLARE_ACCOUNT_ID: string;
95 DISPATCH_NAMESPACE: string;
96 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains97 /** Custom domains: hostname to app, read by the dispatcher. */
98 DOMAINS?: KVNamespace;
99 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
100 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look101 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
102 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page103};
104
105/** A build that has not reported in this long has died. */
106const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page107/** A script in the namespace that no app holds, older than this, is removed. */
108const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page109const LIST_LIMIT = 50;
110const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains111/**
112 * Deliveries of `workspace.renamed` that wait for the projects service to
113 * have seen it too, before going ahead with the new slug regardless.
114 */
115const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas116/** Why a build under way was dropped by a move; the move builds it again under the new name. */
117const MOVED_ERROR = "The repository moved: built again under its new name.";
118const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
119/** A move's rebuild that could not start is tried again by the sweep after this long. */
120const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page121
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look122/** A build's report of what it found the project to be, if it is one g1t knows. */
123export function detectedKind(value: unknown): DetectedKind | null {
124 return value === "workers" || value === "static" || value === "html" ? value : null;
125}
126
Deployments: a preview for every pull request, production on g1t.page127const now = () => new Date().toISOString();
128const month = (at = new Date()) => at.toISOString().slice(0, 7);
129
130async function sha256(text: string): Promise<string> {
131 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
132 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
133}
134
135function randomToken(): string {
136 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
137}
138
139function isMember(viewer: Viewer, slug: string): boolean {
140 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
141}
142
Projects: what a workspace builds and runs, first on every page143/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look144/** One compute gate per isolate, so entitlements and prices are kept between calls. */
145let computeGate: ComputeGate | null = null;
146function gateFor(billing: ServiceBinding): ComputeGate {
147 computeGate ??= new ComputeGate(billing);
148 return computeGate;
149}
150
151/** The longest a build may run, in minutes: as long as its read token lasts. */
152const BUILD_MINUTES = 30;
153
154/**
155 * A build that was skipped before it started (its plan, its limit, or
156 * billing's refusal) as a failure, so whoever asked for it sees why.
157 */
158function notStarted(result: Result<Deployment>): Result<Deployment> {
159 if (result.ok && result.value.status === "skipped" && result.value.error) {
160 return fail("payment_required", result.value.error);
161 }
162 return result;
163}
164
Projects: what a workspace builds and runs, first on every page165function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
166 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
167 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
168}
169
Deployments: a preview for every pull request, production on g1t.page170type SettingsRow = {
Projects: what a workspace builds and runs, first on every page171 project_id: string;
172 workspace: string;
173 slug: string;
Deployments: a preview for every pull request, production on g1t.page174 repo_id: string;
175 enabled: number;
176 previews: number;
177 production: number;
178 build_command: string | null;
179 output_dir: string | null;
180 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look181 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
182 repo_deleted_at: string | null;
Deployments: a preview for every pull request, production on g1t.page183};
184
185type DeploymentRow = {
186 id: string;
Projects: what a workspace builds and runs, first on every page187 project_id: string;
188 workspace: string;
189 slug: string;
Deployments: a preview for every pull request, production on g1t.page190 repo_id: string;
Projects: what a workspace builds and runs, first on every page191 repo: string;
Deployments: a preview for every pull request, production on g1t.page192 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page193 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page194 number: number | null;
195 commit_sha: string;
196 script: string;
197 status: DeployStatus;
198 error: string | null;
199 warnings: string;
200 log: string | null;
201 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments202 trusted: number;
Deployments: a preview for every pull request, production on g1t.page203 build_seconds: number | null;
204 created_by: string;
205 created_at: string;
206 finished_at: string | null;
207};
208
209type AppRow = {
210 script: string;
Projects: what a workspace builds and runs, first on every page211 project_id: string;
212 workspace: string;
213 slug: string;
Deployments: a preview for every pull request, production on g1t.page214 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page215 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page216 number: number | null;
217 commit_sha: string;
218 deployed_at: string;
219 created_at: string;
220 last_request_at: string | null;
Usage limits: unpaid usage can only go so far221 /** Set while its workspace is over its limit; see `holdToLimits`. */
222 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page223};
224
225function toDeployment(row: DeploymentRow): Deployment {
226 return {
227 id: row.id,
228 kind: row.kind,
Projects: what a workspace builds and runs, first on every page229 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page230 number: row.number,
231 commit: row.commit_sha,
232 status: row.status,
233 url: appUrl(row.script),
234 error: row.error,
235 warnings: JSON.parse(row.warnings || "[]") as string[],
236 buildSeconds: row.build_seconds,
237 createdBy: row.created_by,
238 createdAt: row.created_at,
239 finishedAt: row.finished_at,
240 };
241}
242
Projects: what a workspace builds and runs, first on every page243function toLive(app: AppRow): LiveApp {
244 return {
245 kind: app.kind,
246 branch: app.branch,
247 number: app.number,
248 url: appUrl(app.script),
249 commit: app.commit_sha,
250 deployedAt: app.deployed_at,
251 };
252}
253
Deployments: a preview for every pull request, production on g1t.page254class Deployments {
255 constructor(private readonly env: Env) {}
256
257 private get cloudflare(): Cloudflare | null {
258 const token = this.env.CLOUDFLARE_API_TOKEN;
259 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
260 }
261
262 private get db() {
263 return this.env.DB;
264 }
265
Agents and memory, checks and conflicts, profiles, slug renames, custom domains266 private get domains(): Domains {
267 const token = this.env.CLOUDFLARE_API_TOKEN;
268 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
269 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
270 }
271
Projects: what a workspace builds and runs, first on every page272 private get projects() {
273 return projectsClient(this.env.PROJECTS);
274 }
275
Deployments: a preview for every pull request, production on g1t.page276 /** The workspace itself, as the service acts for it. */
277 private async workspaceActor(slug: string): Promise<User | null> {
278 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
279 if (!workspace) return null;
280 return {
281 id: workspace.id,
282 username: workspace.slug,
283 kind: "workspace",
284 verified: true,
285 workspaces: [{ slug: workspace.slug, role: "member" }],
286 };
287 }
288
Secrets and variables: one list, rows per environment, for workflows and deployments289 /**
Projects: what a workspace builds and runs, first on every page290 * What the project's secrets and variables available to deployments give
291 * production or a preview: its build's environment, and the same again as
292 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments293 */
294 private async resolve(
Projects: what a workspace builds and runs, first on every page295 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments296 environment: DeployKind,
297 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know298 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments299 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know300 const [rows, references] = await Promise.all([
301 this.rows(project, environment, trusted),
302 this.references(project.id, environment === "preview" ? branch : null),
303 ]);
304 // The project's own rows win over a dependency's address of the same name.
305 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
306 }
307
308 /**
309 * Each dependency's address, under the name the dependency gives it:
310 * for a preview, the same branch's preview of it if one is up, else its
311 * production; for production, its production.
312 */
313 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
314 const graph = await this.projects.graph(projectId).catch(() => null);
315 const out: Record<string, string> = {};
316 for (const dependency of graph?.dependsOn ?? []) {
317 if (!dependency.as) continue;
318 const app =
319 (branch
320 ? await this.db
321 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
322 .bind(dependency.id, branch)
323 .first<{ script: string }>()
324 : null) ??
325 (await this.db
326 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
327 .bind(dependency.id)
328 .first<{ script: string }>());
329 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
330 }
331 return out;
332 }
333
334 private async rows(
335 project: { id: string; slug: string; repoId: string; repo: RepoPath },
336 environment: DeployKind,
337 trusted: boolean,
338 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments339 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
340 method: "POST",
341 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page342 body: JSON.stringify({
343 repoId: project.repoId,
344 repo: project.repo,
345 projectId: project.id,
346 projectSlug: project.slug,
347 consumer: "deployments",
348 environment,
349 trusted,
350 }),
Secrets and variables: one list, rows per environment, for workflows and deployments351 });
352 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
353 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
354 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
355 }
356
357 /**
Projects: what a workspace builds and runs, first on every page358 * 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 look359 * g1t's agent, or someone who can push to the repository (Write or more,
360 * a member's or a collaborator's). Anyone else gets a preview built
361 * without them, as their workflows run.
Secrets and variables: one list, rows per environment, for workflows and deployments362 */
363 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
364 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 look365 const found = await identityClient(this.env.IDENTITY)
366 .collaboratorPermission(actor, repo.namespace, repo.name, author.username)
367 .catch(() => null);
368 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments369 }
370
Projects: what a workspace builds and runs, first on every page371 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
372 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page373 }
374
Projects: what a workspace builds and runs, first on every page375 /** The name an app gets, unique among apps: production, or a branch's preview. */
376 private async scriptFor(project: Project, branch: string | null): Promise<string> {
377 const base = await label(project.workspace, project.slug, branch);
378 const holder = await this.db
379 .prepare(
380 `SELECT project_id, branch FROM apps WHERE script = ?1
381 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
382 )
383 .bind(base)
384 .first<{ project_id: string; branch: string | null }>();
385 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
386 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
387 }
388
389 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page390 return {
391 enabled: !!row?.enabled,
392 previews: row ? !!row.previews : true,
393 production: row ? !!row.production : true,
394 buildCommand: row?.build_command ?? null,
395 outputDir: row?.output_dir ?? null,
396 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page397 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains398 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look399 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page400 };
401 }
402
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look403 /** What the project's last finished build found it to be; null before one has. */
404 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
405 const row = await this.db
406 .prepare(
407 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
408 )
409 .bind(projectId)
410 .first<{ detected: string }>()
411 .catch(() => null);
412 return detectedKind(row?.detected);
413 }
414
415 /**
416 * The project, if `viewer` may do what `method` needs on its repository
417 * (see `NEEDS`): not found when they cannot read it, refused when they can
418 * but their role is too low.
419 */
420 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
421 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
422 if (!found.ok) return found;
423 const repo = repoRef(found.value);
424 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
425 const capability = NEEDS[method];
426 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
427 return found;
Deployments: a preview for every pull request, production on g1t.page428 }
429
430 // ---- Methods for the site and the API ------------------------------
431
Projects: what a workspace builds and runs, first on every page432 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 look433 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page434 if (!project.ok) return project;
435 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page436 }
437
438 async updateSettings(a: {
439 actor: User;
Projects: what a workspace builds and runs, first on every page440 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page441 changes: Partial<DeploySettings>;
442 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look443 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page444 if (!found.ok) return found;
445 const project = found.value;
446 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page447 const next = { ...before, ...a.changes };
448 if (next.enabled && !before.enabled) {
449 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page450 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page451 if (!plan.ok) return plan;
452 }
453 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
454 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
455 await this.db
456 .prepare(
Projects: what a workspace builds and runs, first on every page457 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
458 output_dir, idle_days, updated_by, updated_at)
459 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
460 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
461 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.page462 )
463 .bind(
Projects: what a workspace builds and runs, first on every page464 project.id,
465 project.workspace,
466 project.slug,
467 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page468 next.enabled ? 1 : 0,
469 next.previews ? 1 : 0,
470 next.production ? 1 : 0,
471 clip(next.buildCommand),
472 clip(next.outputDir),
473 idleDays,
474 a.actor.username,
475 now(),
476 )
477 .run();
478 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page479 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page480 else {
Projects: what a workspace builds and runs, first on every page481 if (!next.previews) await this.takeDownWhere(project.id, "preview");
482 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page483 }
484 // Turned on: production goes up from the default branch at once.
485 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page486 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page487 }
Projects: what a workspace builds and runs, first on every page488 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page489 }
490
Projects: what a workspace builds and runs, first on every page491 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 look492 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page493 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page494 const [deployments, apps] = await Promise.all([
495 this.db
Projects: what a workspace builds and runs, first on every page496 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
497 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page498 .all<DeploymentRow>(),
499 this.db
Projects: what a workspace builds and runs, first on every page500 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
501 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page502 .all<AppRow>(),
503 ]);
Projects: what a workspace builds and runs, first on every page504 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page505 }
506
Projects: what a workspace builds and runs, first on every page507 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 look508 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page509 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page510 const row = await this.db
Projects: what a workspace builds and runs, first on every page511 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
512 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page513 .first<DeploymentRow>();
514 if (!row) return fail("not_found", "No such deployment.");
515 return ok({ ...toDeployment(row), log: row.log });
516 }
517
Projects: what a workspace builds and runs, first on every page518 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 look519 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page520 if (!found.ok) return found;
521 const project = found.value;
522 const settings = await this.settingsRow(project.id);
523 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
524 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look525 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 page526 }
527 // A branch's preview comes from its pull request.
528 const app = await this.db
529 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
530 .bind(project.id, a.branch)
531 .first<{ number: number }>();
532 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 look533 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.page534 }
535
Project dependencies: addresses, preview stacks, Affects, and agents who know536 /**
537 * A preview stack: the projects that use this one get previews of their
538 * own default branch, under the same branch name, so each reaches this
539 * branch's preview through its dependency's variable. A change to an API
540 * can then be clicked through in the apps that call it.
541 */
542 async stack(
543 a: { actor: User; project: ProjectRef; branch: string },
544 background: (work: Promise<unknown>) => void,
545 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look546 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know547 if (!found.ok) return found;
548 const upstream = await this.db
549 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
550 .bind(found.value.id, a.branch)
551 .first();
552 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
553 const graph = await this.projects.graph(found.value.id);
554 const ready: { project: Project; settings: SettingsRow }[] = [];
555 for (const dependent of graph.usedBy) {
556 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 look557 // Each build spends compute on its own repository: only those the actor can run.
558 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know559 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look560 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know561 }
562 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
563 // The builds start after the answer: a person moving on from the page
564 // does not stop them.
565 background(
566 (async () => {
567 for (const { project, settings } of ready) {
568 const actor = await this.workspaceActor(project.workspace);
569 if (!actor) continue;
570 const repo = repoOf(project);
571 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
572 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
573 if (!head) continue;
574 await this.start({
575 project,
576 kind: "preview",
577 branch: a.branch,
578 number: null,
579 commit: head,
580 source: repo.path,
581 reader: actor,
582 createdBy: a.actor.username,
583 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look584 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know585 trusted: true,
586 });
587 }
588 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
589 );
590 return ok(ready.map(({ project }) => project.name));
591 }
592
Projects: what a workspace builds and runs, first on every page593 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 look594 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page595 if (!project.ok) return project;
596 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page597 return ok(true);
598 }
599
Projects: what a workspace builds and runs, first on every page600 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
601 const workspace = a.workspace.toLowerCase();
602 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 look603 // Only the projects whose repositories the viewer can read: the
604 // projects service lists no others.
605 const listed = await this.projects.list(workspace, a.viewer);
606 if (!listed.ok) return listed;
607 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page608 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look609 // A deleted repository's projects are hidden until it is restored.
610 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 page611 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
612 this.db
613 .prepare(
614 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
615 )
616 .bind(workspace)
617 .all<DeploymentRow>(),
618 ]);
619 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look620 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page621 const own = apps.results.filter((app) => app.slug === row.slug);
622 const production = own.find((app) => app.kind === "production");
623 const newest = latest.results.find((d) => d.slug === row.slug);
624 return {
625 slug: row.slug,
626 enabled: !!row.enabled,
627 production: production ? toLive(production) : null,
628 previews: own.filter((app) => app.kind === "preview").length,
629 latest: newest ? toDeployment(newest) : null,
630 };
631 }),
632 );
633 }
634
Deployments: a preview for every pull request, production on g1t.page635 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
636 const slug = a.workspace.toLowerCase();
637 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
638 const [meter, apps] = await Promise.all([
639 this.db
640 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
641 .bind(slug, month())
642 .first<{
643 requests: number;
644 cpu_ms: number;
645 peak_apps: number;
646 build_seconds: number;
647 build_micros: number;
648 counted_at: string | null;
649 }>(),
Projects: what a workspace builds and runs, first on every page650 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.page651 ]);
652 return ok({
653 month: month(),
654 requests: meter?.requests ?? 0,
655 cpuMs: meter?.cpu_ms ?? 0,
656 apps: apps?.n ?? 0,
657 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
658 buildSeconds: meter?.build_seconds ?? 0,
659 buildMicros: meter?.build_micros ?? 0,
660 countedAt: meter?.counted_at ?? null,
661 });
662 }
663
Agents and memory, checks and conflicts, profiles, slug renames, custom domains664 // ---- Custom domains ------------------------------------------------
665
666 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 look667 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains668 if (!project.ok) return project;
669 const domains = this.domains;
670 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas671 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains672 domains.forProject(project.value.id),
673 domains.available(),
674 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas675 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains676 ]);
677 return ok({
678 domains: rows.map(toDomain),
679 target: CUSTOM_DOMAIN_TARGET,
680 available,
681 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas682 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains683 used,
684 });
685 }
686
687 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 look688 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains689 if (!found.ok) return found;
690 const project = found.value;
691 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
692 if (!plan.ok) return plan;
693 const added = await this.domains.add({
694 project: { id: project.id, workspace: project.workspace, slug: project.slug },
695 script: await this.productionScript(project),
696 hostname: String(a.hostname ?? ""),
697 twin: !!a.twin,
698 by: a.actor.username,
699 });
700 if (!added.ok) return fail(added.code, added.message);
701 await this.notePeak(project.workspace);
702 return ok(added.rows.map(toDomain));
703 }
704
705 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 look706 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains707 if (!found.ok) return found;
708 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
709 if (!row) return fail("not_found", "No such domain.");
710 await this.domains.remove(row);
711 return ok(true);
712 }
713
714 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 look715 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains716 if (!found.ok) return found;
717 const domains = this.domains;
718 const row = await domains.byId(found.value.id, String(a.id ?? ""));
719 if (!row) return fail("not_found", "No such domain.");
720 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
721 await this.notePeak(found.value.workspace);
722 return ok(toDomain(after));
723 }
724
725 /** The script production is up under, or the name it will have. */
726 private async productionScript(project: Project): Promise<string> {
727 const app = await this.db
728 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
729 .bind(project.id)
730 .first<{ script: string }>();
731 return app?.script ?? (await this.scriptFor(project, null));
732 }
733
Deployments: a preview for every pull request, production on g1t.page734 // ---- Starting builds -----------------------------------------------
735
736 /**
737 * Opens a deployment and starts its build. Skipped, with the reason
738 * recorded, when the workspace's plan is off.
739 */
740 private async start(input: {
Projects: what a workspace builds and runs, first on every page741 project: Project;
Deployments: a preview for every pull request, production on g1t.page742 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page743 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page744 number: number | null;
745 commit: string;
746 source: RepoPath;
747 reader: User;
748 createdBy: string;
749 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page750 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments751 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page752 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page753 const { project } = input;
754 const repo = repoOf(project);
755 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page756 const id = newId("dpl");
757 const token = randomToken();
Projects: what a workspace builds and runs, first on every page758 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far759 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page760 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look761 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page762 ? plan.error.message
Usage limits: unpaid usage can only go so far763 : limit.ok && limit.value.state === "stopped"
764 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page765 : !cloudflare
766 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
767 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look768 // A build is compute: reserved with billing before it starts, and
769 // settled by its sandbox when it stops. A refusal is the deployment's
770 // status, saying what to do.
771 const compute = gateFor(this.env.BILLING);
772 let reservation: string | null = null;
773 let microsPerSecond = 0;
774 let maxRunMinutes: number | null = null;
775 if (!refused) {
776 const ent = await compute.entitlements(project.workspace);
777 microsPerSecond = await compute.microsPerSecond();
778 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
779 const isPrivate = await reposClient(this.env.REPOS)
780 .get(repo.path, null)
781 .then((found) => !found.ok || found.value.isPrivate)
782 .catch(() => true);
783 const admitted = await compute.admit(
784 {
785 workspace: project.workspace,
786 repo: repo.path,
787 public: !isPrivate,
788 kind: "deploy",
789 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
790 },
791 ent,
792 );
793 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
794 else refused = admitted.message;
795 }
Deployments: a preview for every pull request, production on g1t.page796 await this.db
797 .prepare(
Projects: what a workspace builds and runs, first on every page798 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
799 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
800 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page801 )
802 .bind(
803 id,
Projects: what a workspace builds and runs, first on every page804 project.id,
805 project.workspace,
806 project.slug,
807 repo.id,
808 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page809 input.kind,
Projects: what a workspace builds and runs, first on every page810 input.branch,
Deployments: a preview for every pull request, production on g1t.page811 input.number,
812 input.commit,
813 script,
814 refused ? "skipped" : "queued",
815 refused,
816 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments817 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page818 input.createdBy,
819 now(),
820 refused ? now() : null,
821 )
822 .run();
823 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
824 // Older builds of the same app are replaced by this one.
825 await this.db
826 .prepare(
827 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
828 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
829 )
830 .bind(now(), script, id)
831 .run();
Projects: what a workspace builds and runs, first on every page832 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
833 // What the project's secrets and variables give builds of this kind.
834 const build = await this.resolve(
835 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
836 input.kind,
837 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know838 input.branch,
Projects: what a workspace builds and runs, first on every page839 );
Deployments: a preview for every pull request, production on g1t.page840 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
841 method: "POST",
842 headers: { "content-type": "application/json" },
843 body: JSON.stringify({
844 deployId: id,
845 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look846 workspace: project.workspace,
847 reservation,
848 microsPerSecond,
849 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page850 actor: input.reader,
851 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas852 // The project, whose guardrails the build runs under: a preview's
853 // source is its pull request's working copy, not the project.
854 repo: repo.path,
855 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page856 commit: input.commit,
Projects: what a workspace builds and runs, first on every page857 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page858 buildCommand: input.settings.build_command,
859 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments860 buildEnv: build.variables,
861 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page862 }),
863 });
864 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 look865 if (!started.ok) {
866 // Never reached a sandbox: what was reserved is given back.
867 if (reservation) await compute.settle(reservation, 0);
868 await this.finishFailed(id, started.error.message, null, null);
869 }
Deployments: a preview for every pull request, production on g1t.page870 return ok(toDeployment((await this.deploymentRow(id))!));
871 }
872
Projects: what a workspace builds and runs, first on every page873 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
874 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look875 if (!settings?.enabled || !settings.production || settings.repo_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page876 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page877 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page878 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page879 let head = commit;
880 if (!head) {
Projects: what a workspace builds and runs, first on every page881 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
882 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page883 }
884 if (!head) return null;
885 return this.start({
Projects: what a workspace builds and runs, first on every page886 project,
Deployments: a preview for every pull request, production on g1t.page887 kind: "production",
Projects: what a workspace builds and runs, first on every page888 branch: null,
Deployments: a preview for every pull request, production on g1t.page889 number: null,
890 commit: head,
Projects: what a workspace builds and runs, first on every page891 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page892 reader: actor,
893 createdBy,
894 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments895 // The default branch only moves by people and agents with access.
896 trusted: true,
Deployments: a preview for every pull request, production on g1t.page897 });
898 }
899
Projects: what a workspace builds and runs, first on every page900 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
901 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look902 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page903 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page904 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page905 const repo = repoOf(project);
906 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page907 if (!detail.ok) return null;
908 const { pull } = detail.value;
909 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page910 // A pull request from a fork (as g1t's agents work) has no branch here.
911 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page912 if (!force) {
913 // Already built, or being built, at this commit.
914 const same = await this.db
915 .prepare(
Projects: what a workspace builds and runs, first on every page916 `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.page917 AND status IN ('queued', 'building', 'ready')`,
918 )
Projects: what a workspace builds and runs, first on every page919 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page920 .first();
921 if (same) return null;
922 }
923 return this.start({
Projects: what a workspace builds and runs, first on every page924 project,
Deployments: a preview for every pull request, production on g1t.page925 kind: "preview",
Projects: what a workspace builds and runs, first on every page926 branch,
Deployments: a preview for every pull request, production on g1t.page927 number,
928 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page929 source: pull.fork ?? repo.path,
Deployments: a preview for every pull request, production on g1t.page930 // The pull request's fork may be private: read it as its author.
931 reader: pull.author,
932 createdBy,
933 settings,
Projects: what a workspace builds and runs, first on every page934 trusted: await this.insider(repo.path, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page935 });
936 }
937
938 // ---- A build's reports ---------------------------------------------
939
940 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
941 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
942 }
943
944 /** The build, if `token` is its own and it is still under way. */
945 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
946 const row = await this.deploymentRow(id);
947 if (!row?.token_hash || typeof token !== "string") return null;
948 if (row.token_hash !== (await sha256(token))) return null;
949 return row.status === "queued" || row.status === "building" ? row : null;
950 }
951
952 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run953 // Each report, for the logs: a build's own failure says why.
954 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page955 const row = await this.building(id, body.token);
956 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
957 const cloudflare = this.cloudflare;
958 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
959 switch (step) {
960 case "started":
961 await this.db
962 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
963 .bind(now(), id)
964 .run();
965 return Response.json(ok(true));
966 case "session": {
967 const manifest = body.manifest as Manifest | undefined;
968 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
969 const session = await cloudflare.openUpload(row.script, manifest);
970 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
971 }
972 case "finish": {
973 const worker = (body.worker ?? {}) as BuiltWorker;
974 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page975 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page976 try {
Secrets and variables: one list, rows per environment, for workflows and deployments977 // Running apps' secrets and variables are bound here, by g1t:
978 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page979 const runtime = await this.resolve(
980 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
981 row.kind,
982 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know983 row.branch,
Projects: what a workspace builds and runs, first on every page984 );
Deployments: a preview for every pull request, production on g1t.page985 await cloudflare.putScript(
986 row.script,
987 worker,
988 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page989 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments990 runtime,
Deployments: a preview for every pull request, production on g1t.page991 );
992 } catch (error) {
993 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
994 return Response.json(ok(false));
995 }
996 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas997 const wasRedirect = await this.db.prepare("SELECT 1 FROM redirects WHERE script = ?").bind(row.script).first();
Deployments: a preview for every pull request, production on g1t.page998 await this.db.batch([
999 this.db
1000 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1001 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1002 WHERE id = ?`,
1003 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1004 .bind(
1005 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1006 String(body.log ?? ""),
1007 seconds,
1008 at,
1009 detectedKind(body.detected),
1010 id,
1011 ),
Builds say Replaced or Down once they are no longer live1012 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1013 this.db
Builds say Replaced or Down once they are no longer live1014 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1015 .bind(row.script, id),
1016 this.db
Deployments: a preview for every pull request, production on g1t.page1017 .prepare(
Projects: what a workspace builds and runs, first on every page1018 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1019 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1020 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.page1021 )
Projects: what a workspace builds and runs, first on every page1022 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1023 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1024 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1025 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1026 if (wasRedirect) {
1027 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1028 }
1029 // The same app at an older name (its project moved) now redirects
1030 // here; it stays up as it was if the redirect cannot be put.
1031 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1032 // The project's own domains serve production wherever it is up.
1033 if (row.kind === "production") {
1034 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1035 }
Deployments: a preview for every pull request, production on g1t.page1036 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1037 await this.notePeak(row.workspace);
1038 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 look1039 // A screenshot of production as it now is, for the project's overview.
1040 if (row.kind === "production") {
1041 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1042 console.error("could not ask for a screenshot", error),
1043 );
1044 }
Deployments: a preview for every pull request, production on g1t.page1045 return Response.json(ok(true));
1046 }
1047 case "fail":
1048 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1049 return Response.json(ok(true));
1050 default:
1051 return Response.json(fail("not_found", "No such step."), { status: 404 });
1052 }
1053 }
1054
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1055 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1056 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1057 * its preview of one branch, has one name, so any other app row for the
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1058 * same is the app under a name it had before its project moved (its
1059 * workspace renamed, its repository renamed or transferred). Each is
1060 * replaced in the namespace by a redirect to the new name, its app row
1061 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1062 * the sweep to hold the old script) and in `DOMAINS` under the old
1063 * hostname, which the dispatcher follows before running anything, so the
1064 * old address redirects even if the old script is paused. A name that
1065 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1066 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1067 private async supersede(
1068 cloudflare: Cloudflare,
1069 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1070 script: string,
1071 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1072 const older = await this.db
1073 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1074 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1075 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1076 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1077 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1078 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1079 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1080 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1081 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1082 console.error("could not redirect", old, "to", script, error);
1083 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1084 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1085 const at = now();
1086 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1087 await this.db.batch([
1088 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1089 this.db
1090 .prepare(
1091 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1092 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1093 )
1094 .bind(old, target, app.workspace, at, expires),
1095 // Its builds are no longer live under the old name.
1096 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1097 ]);
1098 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1099 expiration: Math.floor(Date.parse(expires) / 1000),
1100 }).catch((error) => console.error("could not record redirect", old, error));
1101 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1102 }
1103 return done;
1104 }
1105
Deployments: a preview for every pull request, production on g1t.page1106 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1107 const row = await this.deploymentRow(id);
1108 if (!row || (row.status !== "queued" && row.status !== "building")) return;
1109 await this.db
1110 .prepare(
1111 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1112 WHERE id = ?`,
1113 )
1114 .bind(message.slice(0, 2000), log, seconds, now(), id)
1115 .run();
1116 // A failed build still used its sandbox.
1117 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1118 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.page1119 }
1120
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1121 /** Each build is charged by the second, from the first, at the container price plus the margin. */
Deployments: a preview for every pull request, production on g1t.page1122 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1123 const costs = await this.costs();
1124 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1125 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1126 const what =
1127 row.kind === "preview"
1128 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1129 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1130 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1131 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1132 feature: "deployments",
1133 costMicros: cost,
1134 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1135 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1136 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1137 // Every second is metered; billing prices it from its price book and
1138 // tallies the month's build time.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1139 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1140 });
1141 await this.db
1142 .prepare(
1143 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1144 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1145 )
Projects: what a workspace builds and runs, first on every page1146 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1147 .run();
1148 }
1149
Prices keep themselves current with what g1t pays1150 /**
1151 * What each unit costs g1t now, from billing's price book, which follows
1152 * what Cloudflare bills. The plan's figures if billing cannot say.
1153 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1154 private async costs(): Promise<{
1155 buildSecond: number;
1156 millionRequests: number;
1157 millionCpuMs: number;
1158 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1159 /** What one custom domain is charged a month: the cost plus the margin. */
1160 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1161 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1162 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1163 const book = await billingClient(this.env.BILLING)
1164 .prices()
1165 .catch(() => null);
1166 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1167 return {
1168 buildSecond: cost("build_second", a.microsPerBuildSecond),
1169 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1170 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1171 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1172 domainMonthPrice:
1173 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1174 };
1175 }
1176
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1177 /** Remembers the most apps (for information; never charged) and custom domains the workspace had at once this month. */
Projects: what a workspace builds and runs, first on every page1178 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1179 await this.db
1180 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1181 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1182 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1183 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1184 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1185 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1186 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.page1187 )
Projects: what a workspace builds and runs, first on every page1188 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1189 .run();
1190 }
1191
Projects: what a workspace builds and runs, first on every page1192 /**
1193 * The check on the commit: `g1t / deploy`, or, for one of several
1194 * projects on a repository, `g1t / deploy (<project>)`.
1195 */
1196 private async status(
1197 repoId: string,
1198 sha: string,
1199 project: { slug: string; primary: boolean },
1200 state: string,
1201 description: string,
1202 targetUrl: string,
1203 ): Promise<void> {
1204 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1205 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1206 method: "POST",
1207 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1208 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1209 }).catch(() => undefined);
1210 }
1211
Projects: what a workspace builds and runs, first on every page1212 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1213 const projects = await this.projects.byRepo(row.repo_id);
1214 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1215 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1216 }
1217
Deployments: a preview for every pull request, production on g1t.page1218 // ---- Taking apps down ----------------------------------------------
1219
1220 private async removeApp(script: string): Promise<void> {
1221 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1222 await this.db.batch([
1223 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1224 // Its build is no longer live anywhere.
1225 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1226 ]);
Deployments: a preview for every pull request, production on g1t.page1227 }
1228
Projects: what a workspace builds and runs, first on every page1229 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1230 const apps = await this.db
1231 .prepare(
Projects: what a workspace builds and runs, first on every page1232 `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.page1233 )
Projects: what a workspace builds and runs, first on every page1234 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1235 .all<{ script: string }>();
1236 for (const app of apps.results) await this.removeApp(app.script);
1237 }
1238
1239 // ---- Events --------------------------------------------------------
1240
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1241 /** `attempts`: which delivery of the event this is, from 1. */
1242 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1243 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1244 case "workspace.renamed":
1245 await this.renamed(event.data, attempts);
1246 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1247 case "repo.transferred":
1248 case "repo.renamed":
1249 await this.moved(repoMove(event)!, attempts);
1250 break;
1251 case "repo.deleted":
1252 await this.repoDeleted(event.data.repoId);
1253 break;
1254 case "repo.restored":
1255 await this.repoRestored(event.data.repoId, attempts);
1256 break;
1257 case "repo.purged":
1258 await this.repoPurged(event.data.repoId);
1259 break;
1260 case "repo.default_branch_changed":
1261 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1262 break;
1263 case "branch.renamed":
1264 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1265 break;
1266
Deployments: a preview for every pull request, production on g1t.page1267 case "pull.opened":
1268 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1269 case "pull.updated":
1270 for (const project of await this.projects.byRepo(event.data.repoId)) {
1271 await this.deployPreview(project, event.data.number, "g1t");
1272 }
Deployments: a preview for every pull request, production on g1t.page1273 break;
1274 case "pull.closed":
1275 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1276 for (const project of await this.projects.byRepo(event.data.repoId)) {
1277 const apps = await this.db
1278 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1279 .bind(project.id, event.data.number)
1280 .all<{ script: string }>();
1281 for (const app of apps.results) await this.removeApp(app.script);
1282 }
Deployments: a preview for every pull request, production on g1t.page1283 break;
Projects: what a workspace builds and runs, first on every page1284 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1285 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1286 for (const project of await this.projects.byRepo(event.data.repoId)) {
1287 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1288 }
Deployments: a preview for every pull request, production on g1t.page1289 break;
1290 }
1291 }
1292
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1293 /**
1294 * A workspace's slug changed, and with it every app's name: production
1295 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1296 *
1297 * Its rows move to the slug it has now (asked of identity, so a delivery
1298 * twice over, or an older rename after a newer one, ends the same), and
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1299 * each app (paused or not) is built again from the same commit under its
1300 * new name; see `followMoves`. The old name keeps serving the app until
1301 * the new one is live; then it redirects to the new name (see
1302 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1303 * Builds under way for the old name are dropped and started again under
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1304 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1305 */
1306 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1307 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1308 const stale = staleSlugs(renamed, current);
1309 if (stale.length === 0) return;
1310 const marks = stale.map(() => "?").join(", ");
1311
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1312 // The workspace's projects, under any of its names: a second delivery
1313 // finds them under the new one, with whatever is left to do.
1314 const rows = await this.db
1315 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1316 .bind(...stale, current)
1317 .all<{ project_id: string }>();
1318 const projectIds = rows.results.map((row) => row.project_id);
1319 const projects = await this.projectsById(projectIds);
1320 // The projects service hears of the rename on its own queue: wait for
1321 // it a few deliveries, so the builds read the repository by its new name.
1322 const behind = [...projects.values()].some((p) => p.workspace !== current);
1323 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1324
1325 // Every row moves at once.
1326 const at = now();
1327 const statements: D1PreparedStatement[] = [];
1328 for (const slug of stale) {
1329 statements.push(
1330 this.db
1331 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1332 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1333 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1334 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1335 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1336 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1337 this.db
1338 .prepare(
1339 `UPDATE deployments SET workspace = ?1,
1340 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1341 WHERE workspace = ?2`,
1342 )
1343 .bind(current, slug),
1344 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1345 this.domains.rename(slug, current),
1346 // Counters add up; the peak is the higher; a month charged stays charged.
1347 this.db
1348 .prepare(
1349 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1350 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1351 FROM meters WHERE namespace = ?2
1352 ON CONFLICT (namespace, month) DO UPDATE SET
1353 requests = requests + excluded.requests,
1354 cpu_ms = cpu_ms + excluded.cpu_ms,
1355 peak_apps = MAX(peak_apps, excluded.peak_apps),
1356 peak_domains = MAX(peak_domains, excluded.peak_domains),
1357 build_seconds = build_seconds + excluded.build_seconds,
1358 build_micros = build_micros + excluded.build_micros,
1359 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1360 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1361 )
1362 .bind(current, slug),
1363 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1364 );
1365 }
1366 await this.db.batch(statements);
1367
1368 // Each app again, under its new name. Its dependencies' addresses are
1369 // read again too, so apps that call one another follow the rename.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1370 const followed = await this.followMoves(projectIds, {
1371 projects,
1372 adjust: (project) => this.underSlug(project, current, stale),
1373 });
1374 if (followed.failed.length > 0) {
1375 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1376 }
1377 }
1378
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1379 /**
1380 * A repository moved: transferred to another workspace, and its projects
1381 * with it, or renamed within its own, when its own project's slug follows
1382 * its name (see the projects service). Each app's name is
1383 * `<project>-<workspace>`, so the apps of every project whose workspace or
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1384 * slug changed (production and every preview, paused or not) are built
1385 * again, from the same commit, under the new name; see `followMoves`. As
1386 * after a workspace rename, the old name keeps serving until the new one
1387 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1388 * The project's custom domains follow its production. Builds under way
1389 * are started again under the new name. What the apps used this month
1390 * stays on the old workspace's meter; from now on, the new workspace's
1391 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1392 *
1393 * Projects whose name did not change (a rename where the new name was
1394 * taken, or a project of another name) only learn the repository's new
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1395 * path. What is left to rebuild is read from the rows each time, so a
1396 * second or late delivery builds only what the first could not, and one
1397 * whose rebuild could not be queued is delivered again (and, past the
1398 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1399 */
1400 private async moved(move: RepoMove, attempts: number): Promise<void> {
1401 const current = await currentMovedPath(this.env.REPOS, move);
1402 if (staleMovedPaths(move, current).length === 0) return;
1403 const [workspace, name] = current.split("/") as [string, string];
1404 const settings = await this.db
1405 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1406 .bind(move.repoId)
1407 .all<{ project_id: string; workspace: string; slug: string }>();
1408 if (settings.results.length === 0) return;
1409
1410 // The projects service hears of the move on its own queue: wait for it
1411 // a few deliveries, so builds read the project where and as it is now.
1412 const projects = new Map<string, Project>();
1413 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1414 const behind = settings.results.some(({ project_id }) => {
1415 const project = projects.get(project_id);
1416 if (!project || project.source.kind !== "hosted") return false;
1417 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1418 });
1419 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1420
1421 // Its history goes with it, as the repository's issues do.
1422 const statements: D1PreparedStatement[] = [
1423 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1424 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1425 // The projects whose apps' names change, and have not been moved yet.
1426 const moving = settings.results
1427 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1428 .map((row) => row.project_id);
1429 if (moving.length > 0) {
1430 const ids = moving.map(() => "?").join(", ");
1431 statements.push(
1432 this.db
1433 .prepare(
1434 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1435 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1436 )
1437 .bind(MOVED_ERROR, now(), ...moving),
1438 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1439 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1440 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1441 const slug = projects.get(projectId)?.slug ?? null;
1442 statements.push(
1443 this.db
1444 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1445 .bind(workspace, slug, projectId),
1446 this.db
1447 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1448 .bind(workspace, slug, projectId),
1449 this.db
1450 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1451 .bind(workspace, slug, projectId),
1452 // Within the workspace its apps are listed under the project's name
1453 // now. One left behind in another workspace stays as it is until the
1454 // new name is live and redirects it.
1455 this.db
1456 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1457 .bind(workspace, slug, projectId),
1458 );
1459 }
1460 await this.db.batch(statements);
1461
1462 // Each app again, under its new name. App rows under the old name stay
1463 // until the new one is live, which redirects them.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1464 const followed = await this.followMoves(
1465 settings.results.map((row) => row.project_id),
1466 {
1467 projects,
1468 adjust: (found) =>
1469 found.source.kind === "hosted"
1470 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1471 : { ...found, workspace },
1472 },
1473 );
1474 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1475 }
1476
1477 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1478 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1479 const projects = new Map<string, Project>();
1480 if (projectIds.length === 0) return projects;
1481 const repoIds = new Set<string>();
1482 for (let i = 0; i < projectIds.length; i += 50) {
1483 const chunk = projectIds.slice(i, i + 50);
1484 const rows = await this.db
1485 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1486 .bind(...chunk)
1487 .all<{ repo_id: string }>();
1488 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1489 }
1490 const wanted = new Set(projectIds);
1491 for (const repoId of repoIds) {
1492 for (const project of await this.projects.byRepo(repoId)) {
1493 if (wanted.has(project.id)) projects.set(project.id, project);
1494 }
1495 }
1496 return projects;
1497 }
1498
1499 /** Whether `script` is the name an app of the project has where the project is now. */
1500 private async namedNow(
1501 script: string,
1502 settings: { project_id: string; workspace: string; slug: string },
1503 branch: string | null,
1504 ): Promise<boolean> {
1505 const base = await label(settings.workspace, settings.slug, branch);
1506 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1507 }
1508
1509 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1510 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1511 const stale: AppRow[] = [];
1512 for (const app of apps) {
1513 const row = settings.get(app.project_id);
1514 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1515 }
1516 return stale;
1517 }
1518
1519 /**
1520 * Brings the apps of the projects `projectIds` (every project when null)
1521 * under the names they have where the projects are now: each app still
1522 * under an older name, paused or not, and each build a move dropped, is
1523 * built again under its new name from the same commit, and once that is
1524 * live the old name redirects to it (`supersede`).
1525 *
1526 * Idempotent, and safe to run again at any time: an app already up under
1527 * its new name only has its old names redirected; one whose rebuild is
1528 * queued or under way is left to it; one whose workspace has no
1529 * Deployments or is over its limit waits, without a refused deployment
1530 * each time; with `backoff` (the sweep), one whose rebuild was tried
1531 * within `MOVE_RETRY_MS` waits. Returns what could not be queued, for an
1532 * event's delivery to be retried.
1533 */
1534 private async followMoves(
1535 projectIds: string[] | null,
1536 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1537 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1538 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1539 if (projectIds && projectIds.length === 0) return result;
1540 const cloudflare = this.cloudflare;
1541 if (!cloudflare) return result;
1542
1543 const settingsRows =
1544 projectIds == null
1545 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1546 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1547 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1548 if (settings.size === 0) return result;
1549 const ids = [...settings.keys()];
1550
1551 const apps: AppRow[] = [];
1552 const dropped: DroppedBuild[] = [];
1553 for (let i = 0; i < ids.length; i += 50) {
1554 const chunk = ids.slice(i, i + 50);
1555 const marks = chunk.map(() => "?").join(", ");
1556 const [appRows, droppedRows] = await Promise.all([
1557 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1558 // Builds a move dropped, that nothing has been built in place of since.
1559 this.db
1560 .prepare(
1561 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1562 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1563 AND NOT EXISTS (
1564 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1565 AND n.created_at > d.created_at AND n.status != 'skipped'
1566 )`,
1567 )
1568 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1569 .all<DeploymentRow>(),
1570 ]);
1571 apps.push(...appRows.results);
1572 dropped.push(...droppedRows.results);
1573 }
1574 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1575 if (targets.length === 0) return result;
1576
1577 const projects = new Map(options.projects ?? []);
1578 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1579 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1580 const billing = billingClient(this.env.BILLING);
1581 const open = new Map<string, boolean>();
1582 const actors = new Map<string, User | null>();
1583
1584 for (const target of targets) {
1585 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1586 const found = projects.get(target.projectId);
1587 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1588 const project = options.adjust ? options.adjust(found) : found;
1589 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1590 // Built only where deployments has the project now; the projects
1591 // service catches up on its own queue, and a later run builds then.
1592 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1593 result.waiting++;
1594 continue;
1595 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1596 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1597 const script = await this.scriptFor(project, target.branch);
1598 // Already up under its new name: only its old names are left to redirect.
1599 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1600 if (up) {
1601 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1602 continue;
1603 }
1604 const last = await this.db
1605 .prepare("SELECT status, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT 1")
1606 .bind(script)
1607 .first<{ status: string; created_at: string }>();
1608 if (last && (last.status === "queued" || last.status === "building")) {
1609 result.queued++;
1610 continue;
1611 }
1612 if (options.backoff && !retryDue(last?.created_at ?? null, Date.now(), MOVE_RETRY_MS)) {
1613 result.waiting++;
1614 continue;
1615 }
1616 // A workspace without Deployments, or over its limit, waits for it
1617 // rather than gathering refused deployments.
1618 if (!open.has(project.workspace)) {
1619 const [plan, limit] = await Promise.all([
1620 billing.hasFeature(project.workspace, "deployments"),
1621 billing.checkLimit(project.workspace),
1622 ]);
1623 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1624 }
1625 if (!open.get(project.workspace)) {
1626 result.waiting++;
1627 continue;
1628 }
1629 if (!actors.has(project.workspace)) actors.set(project.workspace, await this.workspaceActor(project.workspace));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1630 const started =
1631 target.kind === "production"
1632 ? await this.deployProduction(project, target.commit, "g1t")
1633 : target.number != null
1634 ? await this.deployPreview(project, target.number, "g1t", true)
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1635 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1636 const outcome = rebuildOutcome(started);
1637 if (outcome === "queued") result.queued++;
1638 else if (outcome === "failed") {
1639 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1640 result.failed.push(`${named}: ${why}`);
1641 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1642 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1643 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1644 }
1645 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1646 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1647 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1648 }
1649
1650 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1651 private async projectIdsFor(repoId: string): Promise<string[]> {
1652 const rows = await this.db
1653 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1654 .bind(repoId)
1655 .all<{ project_id: string }>();
1656 return rows.results.map((row) => row.project_id);
1657 }
1658
1659 /**
1660 * A repository was deleted, restorable for a while: every app of its
1661 * projects (production and previews) comes down, builds under way are
1662 * dropped, and nothing builds for it until it is restored. Its settings
1663 * and custom domains are kept for the restore; until then a domain has
1664 * nothing up to serve, as when production is turned off.
1665 */
1666 private async repoDeleted(repoId: string): Promise<void> {
1667 const projectIds = await this.projectIdsFor(repoId);
1668 if (projectIds.length === 0) return;
1669 const at = now();
1670 await this.db.batch([
1671 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1672 this.db
1673 .prepare(
1674 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1675 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1676 )
1677 .bind(at, repoId),
1678 ]);
1679 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1680 }
1681
1682 /**
1683 * A deleted repository is back: production goes up again from its
1684 * default branch, for each project that has it on. Previews come back
1685 * with the next push to their pull requests.
1686 */
1687 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1688 const deleted = await this.db
1689 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1690 .bind(repoId)
1691 .all<{ project_id: string }>();
1692 const ids = deleted.results.map((row) => row.project_id);
1693 if (ids.length === 0) return;
1694 // The projects service hears of the restore on its own queue, and hides
1695 // the projects until then: wait for it a few deliveries.
1696 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1697 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1698 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1699 for (const project of projects) {
1700 try {
1701 const started = await this.deployProduction(project, null, "g1t");
1702 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1703 } catch (error) {
1704 console.error("could not deploy after restore", project.slug, error);
1705 }
1706 }
1707 }
1708
1709 /**
1710 * A deleted repository is gone for good: its projects' custom domains are
1711 * removed (from the dispatcher and from Cloudflare), any app or redirect
1712 * still up comes down, and every row kept for them goes. What they used
1713 * stays on their workspace's meter.
1714 */
1715 private async repoPurged(repoId: string): Promise<void> {
1716 const projectIds = await this.projectIdsFor(repoId);
1717 const domains = this.domains;
1718 for (const projectId of projectIds) {
1719 await this.takeDownWhere(projectId, null);
1720 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1721 await domains.removeWhere("project_id", projectId);
1722 }
1723 // The redirects left at names its apps had before.
1724 const scripts = await this.db
1725 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1726 .bind(repoId)
1727 .all<{ script: string }>();
1728 const hosts = scripts.results.map(({ script }) => appHost(script));
1729 for (let i = 0; i < hosts.length; i += 50) {
1730 const chunk = hosts.slice(i, i + 50);
1731 const redirects = await this.db
1732 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1733 .bind(...chunk)
1734 .all<{ script: string }>();
1735 for (const { script } of redirects.results) {
1736 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1737 await this.env.DOMAINS?.delete(appHost(script)).catch((error) => console.error("could not drop redirect", script, error));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1738 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1739 }
1740 }
1741 await this.db.batch([
1742 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1743 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1744 ]);
1745 }
1746
1747 /**
1748 * The default branch is another one now: production is built from it, as
1749 * from a push to it, unless production already serves (or is building)
1750 * its commit, as when the default branch was only renamed.
1751 */
1752 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1753 for (const found of await this.projects.byRepo(repoId)) {
1754 if (found.source.kind !== "hosted") continue;
1755 // Projects may not have heard yet: the event names the branch.
1756 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1757 const actor = await this.workspaceActor(project.workspace);
1758 if (!actor) continue;
1759 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1760 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1761 if (!head) continue;
1762 const same = await this.db
1763 .prepare(
1764 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1765 AND status IN ('queued', 'building', 'ready')`,
1766 )
1767 .bind(project.id, head)
1768 .first();
1769 if (same) continue;
1770 await this.deployProduction(project, head, createdBy);
1771 }
1772 }
1773
1774 /**
1775 * A branch was renamed: its preview is the same app, so its rows follow.
1776 * The app keeps its name until it is next built; then it goes up under
1777 * the new branch's name, and the old one redirects there (see `supersede`).
1778 */
1779 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1780 const projectIds = await this.projectIdsFor(repoId);
1781 if (projectIds.length === 0) return;
1782 const ids = projectIds.map(() => "?").join(", ");
1783 await this.db.batch([
1784 this.db
1785 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1786 .bind(to, from, ...projectIds),
1787 this.db
1788 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1789 .bind(to, from, ...projectIds),
1790 ]);
1791 }
1792
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1793 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1794 private underSlug(project: Project, current: string, stale: string[]): Project {
1795 const source =
1796 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1797 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1798 : project.source;
1799 return { ...project, workspace: current, source };
1800 }
1801
1802 /** A stack's preview (no pull request of its own) built again at `commit`. */
1803 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1804 if (!actor || branch == null) return null;
1805 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1806 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1807 return this.start({
1808 project,
1809 kind: "preview",
1810 branch,
1811 number: null,
1812 commit,
1813 source: repoOf(project).path,
1814 reader: actor,
1815 createdBy: "g1t",
1816 settings,
1817 // As `stack` built it: the project's own default branch.
1818 trusted: true,
1819 });
1820 }
1821
Deployments: a preview for every pull request, production on g1t.page1822 // ---- The sweep -----------------------------------------------------
1823
1824 /**
1825 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1826 * previews, the apps of workspaces whose plan ended, and scripts no app
1827 * holds come down; and a month that is over is charged past its
1828 * allowance.
Deployments: a preview for every pull request, production on g1t.page1829 */
1830 async sweep(): Promise<void> {
1831 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1832 const stuck = await this.db
1833 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1834 .bind(cutoff)
1835 .all<{ id: string }>();
1836 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1837
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1838 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1839 // Each app is its project's workspace's, as deployments has it now: an
1840 // app still under the name it had before its project moved is the new
1841 // workspace's, and plans and limits are checked there.
1842 const settings = new Map(
1843 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
1844 );
1845 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
1846 const workspaces = [...new Set(apps.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page1847
1848 // Apps of workspaces whose plan has ended come down.
1849 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1850 await this.holdToLimits(apps, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1851 for (const workspace of workspaces) {
1852 const plan = await billing.hasFeature(workspace, "deployments");
1853 if (!plan.ok && plan.error.code === "payment_required") {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1854 for (const app of apps.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1855 // Custom domains cost g1t by the month: they go with the plan.
1856 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.page1857 }
1858 }
1859
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1860 // Apps whose project moved and are not up under the new name yet: a
1861 // move's rebuild that could not start is tried again here.
1862 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
1863 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1864
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1865 await this.domains
1866 .sweep(async (projectId) => {
1867 const app = await this.db
1868 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1869 .bind(projectId)
1870 .first<{ script: string }>();
1871 return app?.script ?? null;
1872 })
1873 .catch((error) => console.error("could not check domains", error));
1874
Projects: what a workspace builds and runs, first on every page1875 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page1876 await this.count(apps).catch((error) => console.error("could not count usage", error));
1877 await this.takeDownIdle();
1878 await this.chargeMonths();
1879 }
1880
Usage limits: unpaid usage can only go so far1881 /**
1882 * Pauses the apps of workspaces that reached their limit for usage not
1883 * yet paid for, and rebuilds them from the same commit once they are
1884 * under it again. Paused apps answer with a notice and run nothing.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1885 *
1886 * The workspace is the project's now (see `ownerOf`), never the one an
1887 * app's row was written under, so an app left under its old name after
1888 * a transfer is paused only if its new workspace is over its limit. Such
1889 * an app is resumed by being built under its new name (`followMoves`),
1890 * not here.
Usage limits: unpaid usage can only go so far1891 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1892 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far1893 const cloudflare = this.cloudflare;
1894 if (!cloudflare) return;
1895 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1896 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
1897 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far1898 const limit = await billing.checkLimit(workspace);
1899 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1900 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far1901 if (limit.value.state === "stopped") {
1902 for (const app of theirs.filter((a) => !a.paused_at)) {
1903 await cloudflare.pauseScript(app.script);
1904 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1905 }
1906 continue;
1907 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1908 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
1909 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far1910 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1911 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far1912 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1913 const project = projects.get(app.project_id);
1914 if (!project) continue;
Usage limits: unpaid usage can only go so far1915 // A failed or refused rebuild leaves it paused, to try again next time.
1916 const rebuilt =
1917 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1918 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far1919 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1920 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far1921 : null;
1922 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1923 }
1924 }
1925 }
1926
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1927 /**
1928 * Scripts in the namespace that no app holds, such as ones renamed. An
1929 * old address that redirects to its app's new one is held until its
1930 * redirect expires, then removed with the rest.
1931 */
Projects: what a workspace builds and runs, first on every page1932 private async removeOrphans(apps: AppRow[]): Promise<void> {
1933 const cloudflare = this.cloudflare;
1934 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1935 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1936 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
1937 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
1938 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 page1939 const building = await this.db
1940 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1941 .all<{ script: string }>();
1942 for (const row of building.results) held.add(row.script);
1943 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1944 for (const script of await cloudflare.listScripts()) {
1945 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1946 }
1947 }
1948
Deployments: a preview for every pull request, production on g1t.page1949 /** Counts this month's requests and CPU time per workspace, from analytics. */
1950 private async count(apps: AppRow[]): Promise<void> {
1951 const cloudflare = this.cloudflare;
1952 if (!cloudflare || apps.length === 0) return;
1953 const start = `${month()}-01T00:00:00Z`;
1954 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1955 // Analytics only counts apps that are up; the meter keeps what earlier
1956 // apps used by never going down.
1957 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1958 for (const app of apps) {
1959 const used = totals.get(app.script);
1960 if (!used) continue;
Projects: what a workspace builds and runs, first on every page1961 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page1962 sum.requests += used.requests;
1963 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page1964 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page1965 }
1966 const at = now();
Projects: what a workspace builds and runs, first on every page1967 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page1968 await this.db
1969 .prepare(
1970 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1971 ON CONFLICT (namespace, month) DO UPDATE SET
1972 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1973 )
Projects: what a workspace builds and runs, first on every page1974 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1975 .run();
1976 }
1977 // When each preview last answered anyone, for the idle sweep.
1978 const recent = await cloudflare.usage(
1979 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1980 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1981 at,
1982 );
1983 for (const [script, used] of recent) {
1984 if (used.requests > 0) {
1985 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1986 }
1987 }
Projects: what a workspace builds and runs, first on every page1988 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1989 // What this month's traffic and custom domains will cost, from the
1990 // first request and the first domain, so the workspace's limit counts
1991 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays1992 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1993 const billing = billingClient(this.env.BILLING);
1994 const meters = await this.db
1995 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
1996 .bind(month())
1997 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
1998 for (const meter of meters.results) {
1999 const cost = monthCost(meter, costs);
2000 await billing
2001 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2002 .catch((error) => console.error("could not note pending usage", error));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2003 if ((meter.peak_domains ?? 0) > 0) {
2004 await billing
2005 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2006 .catch((error) => console.error("could not note pending usage", error));
2007 }
Prices keep themselves current with what g1t pays2008 }
Deployments: a preview for every pull request, production on g1t.page2009 }
2010
Projects: what a workspace builds and runs, first on every page2011 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2012 private async takeDownIdle(): Promise<void> {
2013 const idle = await this.db
2014 .prepare(
Projects: what a workspace builds and runs, first on every page2015 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page2016 WHERE apps.kind = 'preview'
2017 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2018 )
2019 .all<{ script: string }>();
2020 for (const { script } of idle.results) await this.removeApp(script);
2021 }
2022
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2023 /** Charges each month that is over for its requests, CPU time and custom domains, from the first, once. */
Deployments: a preview for every pull request, production on g1t.page2024 private async chargeMonths(): Promise<void> {
2025 const due = await this.db
2026 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2027 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2028 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2029 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2030 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2031 const cost = monthCost(meter, costs);
2032 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2033 const charged = await billingClient(this.env.BILLING).chargeFeature({
2034 workspace: meter.namespace,
2035 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2036 costMicros: cost.micros,
2037 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2038 reference: `deployments/${meter.namespace}/${meter.month}`,
2039 });
2040 if (!charged.ok) continue;
2041 }
2042 await this.db
2043 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2044 .bind(now(), meter.namespace, meter.month)
2045 .run();
2046 }
2047 }
2048}
2049
2050/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2051async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2052 switch (method) {
2053 case "settings":
2054 return service.settings(args);
2055 case "update_settings":
2056 return service.updateSettings(args);
2057 case "list":
2058 return service.list(args);
2059 case "get":
2060 return service.get(args);
2061 case "redeploy":
2062 return service.redeploy(args);
2063 case "take_down":
2064 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2065 case "stack":
2066 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2067 case "overview":
2068 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2069 case "usage":
2070 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2071 case "domains":
2072 return service.listDomains(args);
2073 case "add_domain":
2074 return service.addDomain(args);
2075 case "remove_domain":
2076 return service.removeDomain(args);
2077 case "refresh_domain":
2078 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page2079 default:
2080 return undefined;
2081 }
2082}
2083
2084export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2085 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2086 const { pathname } = new URL(request.url);
2087 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2088 const service = new Deployments(env);
2089 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2090 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2091 if (rpcMatch) {
Project dependencies: addresses, preview stacks, Affects, and agents who know2092 const result = await rpc(service, rpcMatch[1], body, ctx);
Deployments: a preview for every pull request, production on g1t.page2093 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
2094 }
2095 // A build's reports, forwarded by the API.
2096 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2097 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2098 return new Response("Not found\n", { status: 404 });
2099 },
2100
2101 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2102 const service = new Deployments(env);
2103 for (const message of batch.messages) {
2104 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2105 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2106 message.ack();
2107 } catch (error) {
2108 console.error("deployments could not handle", message.body.type, error);
2109 message.retry();
2110 }
2111 }
2112 },
2113
2114 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2115 await new Deployments(env).sweep();
2116 },
2117} satisfies ExportedHandler<Env, G1tEvent>;