g1t/services/deployments/src/index.ts

2,126 lines94,661 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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents47 openD1,
Projects: what a workspace builds and runs, first on every page48 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look49 repoMove,
Deployments: a preview for every pull request, production on g1t.page50 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look51 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains52 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page53 workClient,
54 type DeployKind,
55 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look56 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page57 type DeployStatus,
58 type DeployUsage,
59 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains60 type Domain,
Deployments: a preview for every pull request, production on g1t.page61 type G1tEvent,
62 type LiveApp,
Projects: what a workspace builds and runs, first on every page63 type Project,
64 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains66 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page67 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights68 workOwner,
Deployments: a preview for every pull request, production on g1t.page69 type RepoPath,
70 type Result,
71 type ServiceBinding,
72 type User,
73 type Viewer,
74} from "@g1t/contracts";
75
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights76import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page77import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains78import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas79import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
80import { monthCost } from "./metering";
81import { moveTargets, ownerOf, rebuildOutcome, retryDue, type DroppedBuild, type MoveTarget } from "./moves";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains82import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page83
84type Env = {
85 DB: D1Database;
86 REPOS: ServiceBinding;
87 WORK: ServiceBinding;
88 IDENTITY: ServiceBinding;
89 BILLING: ServiceBinding;
90 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page91 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments92 /** Secrets and variables: the actions service holds the one store. */
93 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page94 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
95 CLOUDFLARE_API_TOKEN?: string;
96 CLOUDFLARE_ACCOUNT_ID: string;
97 DISPATCH_NAMESPACE: string;
98 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains99 /** Custom domains: hostname to app, read by the dispatcher. */
100 DOMAINS?: KVNamespace;
101 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
102 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look103 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
104 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page105};
106
107/** A build that has not reported in this long has died. */
108const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page109/** A script in the namespace that no app holds, older than this, is removed. */
110const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page111const LIST_LIMIT = 50;
112const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains113/**
114 * Deliveries of `workspace.renamed` that wait for the projects service to
115 * have seen it too, before going ahead with the new slug regardless.
116 */
117const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas118/** Why a build under way was dropped by a move; the move builds it again under the new name. */
119const MOVED_ERROR = "The repository moved: built again under its new name.";
120const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
121/** A move's rebuild that could not start is tried again by the sweep after this long. */
122const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page123
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look124/** A build's report of what it found the project to be, if it is one g1t knows. */
125export function detectedKind(value: unknown): DetectedKind | null {
126 return value === "workers" || value === "static" || value === "html" ? value : null;
127}
128
Deployments: a preview for every pull request, production on g1t.page129const now = () => new Date().toISOString();
130const month = (at = new Date()) => at.toISOString().slice(0, 7);
131
132async function sha256(text: string): Promise<string> {
133 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
134 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
135}
136
137function randomToken(): string {
138 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
139}
140
141function isMember(viewer: Viewer, slug: string): boolean {
142 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
143}
144
Projects: what a workspace builds and runs, first on every page145/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look146/** One compute gate per isolate, so entitlements and prices are kept between calls. */
147let computeGate: ComputeGate | null = null;
148function gateFor(billing: ServiceBinding): ComputeGate {
149 computeGate ??= new ComputeGate(billing);
150 return computeGate;
151}
152
153/** The longest a build may run, in minutes: as long as its read token lasts. */
154const BUILD_MINUTES = 30;
155
156/**
157 * A build that was skipped before it started (its plan, its limit, or
158 * billing's refusal) as a failure, so whoever asked for it sees why.
159 */
160function notStarted(result: Result<Deployment>): Result<Deployment> {
161 if (result.ok && result.value.status === "skipped" && result.value.error) {
162 return fail("payment_required", result.value.error);
163 }
164 return result;
165}
166
Projects: what a workspace builds and runs, first on every page167function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
168 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
169 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
170}
171
Deployments: a preview for every pull request, production on g1t.page172type SettingsRow = {
Projects: what a workspace builds and runs, first on every page173 project_id: string;
174 workspace: string;
175 slug: string;
Deployments: a preview for every pull request, production on g1t.page176 repo_id: string;
177 enabled: number;
178 previews: number;
179 production: number;
180 build_command: string | null;
181 output_dir: string | null;
182 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look183 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
184 repo_deleted_at: string | null;
Deployments: a preview for every pull request, production on g1t.page185};
186
187type DeploymentRow = {
188 id: string;
Projects: what a workspace builds and runs, first on every page189 project_id: string;
190 workspace: string;
191 slug: string;
Deployments: a preview for every pull request, production on g1t.page192 repo_id: string;
Projects: what a workspace builds and runs, first on every page193 repo: string;
Deployments: a preview for every pull request, production on g1t.page194 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page195 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page196 number: number | null;
197 commit_sha: string;
198 script: string;
199 status: DeployStatus;
200 error: string | null;
201 warnings: string;
202 log: string | null;
203 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments204 trusted: number;
Deployments: a preview for every pull request, production on g1t.page205 build_seconds: number | null;
206 created_by: string;
207 created_at: string;
208 finished_at: string | null;
209};
210
211type AppRow = {
212 script: string;
Projects: what a workspace builds and runs, first on every page213 project_id: string;
214 workspace: string;
215 slug: string;
Deployments: a preview for every pull request, production on g1t.page216 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page217 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page218 number: number | null;
219 commit_sha: string;
220 deployed_at: string;
221 created_at: string;
222 last_request_at: string | null;
Usage limits: unpaid usage can only go so far223 /** Set while its workspace is over its limit; see `holdToLimits`. */
224 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page225};
226
227function toDeployment(row: DeploymentRow): Deployment {
228 return {
229 id: row.id,
230 kind: row.kind,
Projects: what a workspace builds and runs, first on every page231 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page232 number: row.number,
233 commit: row.commit_sha,
234 status: row.status,
235 url: appUrl(row.script),
236 error: row.error,
237 warnings: JSON.parse(row.warnings || "[]") as string[],
238 buildSeconds: row.build_seconds,
239 createdBy: row.created_by,
240 createdAt: row.created_at,
241 finishedAt: row.finished_at,
242 };
243}
244
Projects: what a workspace builds and runs, first on every page245function toLive(app: AppRow): LiveApp {
246 return {
247 kind: app.kind,
248 branch: app.branch,
249 number: app.number,
250 url: appUrl(app.script),
251 commit: app.commit_sha,
252 deployedAt: app.deployed_at,
253 };
254}
255
Deployments: a preview for every pull request, production on g1t.page256class Deployments {
257 constructor(private readonly env: Env) {}
258
259 private get cloudflare(): Cloudflare | null {
260 const token = this.env.CLOUDFLARE_API_TOKEN;
261 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
262 }
263
264 private get db() {
265 return this.env.DB;
266 }
267
Agents and memory, checks and conflicts, profiles, slug renames, custom domains268 private get domains(): Domains {
269 const token = this.env.CLOUDFLARE_API_TOKEN;
270 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
271 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
272 }
273
Projects: what a workspace builds and runs, first on every page274 private get projects() {
275 return projectsClient(this.env.PROJECTS);
276 }
277
Deployments: a preview for every pull request, production on g1t.page278 /** The workspace itself, as the service acts for it. */
279 private async workspaceActor(slug: string): Promise<User | null> {
280 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
281 if (!workspace) return null;
282 return {
283 id: workspace.id,
284 username: workspace.slug,
285 kind: "workspace",
286 verified: true,
287 workspaces: [{ slug: workspace.slug, role: "member" }],
288 };
289 }
290
Secrets and variables: one list, rows per environment, for workflows and deployments291 /**
Projects: what a workspace builds and runs, first on every page292 * What the project's secrets and variables available to deployments give
293 * production or a preview: its build's environment, and the same again as
294 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments295 */
296 private async resolve(
Projects: what a workspace builds and runs, first on every page297 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments298 environment: DeployKind,
299 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know300 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments301 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know302 const [rows, references] = await Promise.all([
303 this.rows(project, environment, trusted),
304 this.references(project.id, environment === "preview" ? branch : null),
305 ]);
306 // The project's own rows win over a dependency's address of the same name.
307 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
308 }
309
310 /**
311 * Each dependency's address, under the name the dependency gives it:
312 * for a preview, the same branch's preview of it if one is up, else its
313 * production; for production, its production.
314 */
315 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
316 const graph = await this.projects.graph(projectId).catch(() => null);
317 const out: Record<string, string> = {};
318 for (const dependency of graph?.dependsOn ?? []) {
319 if (!dependency.as) continue;
320 const app =
321 (branch
322 ? await this.db
323 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
324 .bind(dependency.id, branch)
325 .first<{ script: string }>()
326 : null) ??
327 (await this.db
328 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
329 .bind(dependency.id)
330 .first<{ script: string }>());
331 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
332 }
333 return out;
334 }
335
336 private async rows(
337 project: { id: string; slug: string; repoId: string; repo: RepoPath },
338 environment: DeployKind,
339 trusted: boolean,
340 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments341 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
342 method: "POST",
343 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page344 body: JSON.stringify({
345 repoId: project.repoId,
346 repo: project.repo,
347 projectId: project.id,
348 projectSlug: project.slug,
349 consumer: "deployments",
350 environment,
351 trusted,
352 }),
Secrets and variables: one list, rows per environment, for workflows and deployments353 });
354 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
355 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
356 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
357 }
358
359 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights360 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
361 * it, or its author) is trusted with the project's secrets: g1t itself,
362 * or someone who can push to the repository (Write or more, a member's or
363 * a collaborator's). Anyone else gets a preview built without them, as
364 * their workflows run. A change g1t made for someone is trusted as they
365 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments366 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights367 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
368 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look369 const found = await identityClient(this.env.IDENTITY)
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights370 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look371 .catch(() => null);
372 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments373 }
374
Projects: what a workspace builds and runs, first on every page375 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
376 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page377 }
378
Projects: what a workspace builds and runs, first on every page379 /** The name an app gets, unique among apps: production, or a branch's preview. */
380 private async scriptFor(project: Project, branch: string | null): Promise<string> {
381 const base = await label(project.workspace, project.slug, branch);
382 const holder = await this.db
383 .prepare(
384 `SELECT project_id, branch FROM apps WHERE script = ?1
385 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
386 )
387 .bind(base)
388 .first<{ project_id: string; branch: string | null }>();
389 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
390 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
391 }
392
393 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page394 return {
395 enabled: !!row?.enabled,
396 previews: row ? !!row.previews : true,
397 production: row ? !!row.production : true,
398 buildCommand: row?.build_command ?? null,
399 outputDir: row?.output_dir ?? null,
400 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page401 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains402 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look403 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page404 };
405 }
406
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look407 /** What the project's last finished build found it to be; null before one has. */
408 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
409 const row = await this.db
410 .prepare(
411 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
412 )
413 .bind(projectId)
414 .first<{ detected: string }>()
415 .catch(() => null);
416 return detectedKind(row?.detected);
417 }
418
419 /**
420 * The project, if `viewer` may do what `method` needs on its repository
421 * (see `NEEDS`): not found when they cannot read it, refused when they can
422 * but their role is too low.
423 */
424 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
425 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
426 if (!found.ok) return found;
427 const repo = repoRef(found.value);
428 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
429 const capability = NEEDS[method];
430 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
431 return found;
Deployments: a preview for every pull request, production on g1t.page432 }
433
434 // ---- Methods for the site and the API ------------------------------
435
Projects: what a workspace builds and runs, first on every page436 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 look437 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page438 if (!project.ok) return project;
439 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page440 }
441
442 async updateSettings(a: {
443 actor: User;
Projects: what a workspace builds and runs, first on every page444 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page445 changes: Partial<DeploySettings>;
446 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look447 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page448 if (!found.ok) return found;
449 const project = found.value;
450 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page451 const next = { ...before, ...a.changes };
452 if (next.enabled && !before.enabled) {
453 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page454 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page455 if (!plan.ok) return plan;
456 }
457 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
458 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
459 await this.db
460 .prepare(
Projects: what a workspace builds and runs, first on every page461 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
462 output_dir, idle_days, updated_by, updated_at)
463 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
464 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
465 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.page466 )
467 .bind(
Projects: what a workspace builds and runs, first on every page468 project.id,
469 project.workspace,
470 project.slug,
471 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page472 next.enabled ? 1 : 0,
473 next.previews ? 1 : 0,
474 next.production ? 1 : 0,
475 clip(next.buildCommand),
476 clip(next.outputDir),
477 idleDays,
478 a.actor.username,
479 now(),
480 )
481 .run();
482 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page483 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page484 else {
Projects: what a workspace builds and runs, first on every page485 if (!next.previews) await this.takeDownWhere(project.id, "preview");
486 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page487 }
488 // Turned on: production goes up from the default branch at once.
489 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page490 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page491 }
Projects: what a workspace builds and runs, first on every page492 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page493 }
494
Projects: what a workspace builds and runs, first on every page495 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 look496 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page497 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page498 const [deployments, apps] = await Promise.all([
499 this.db
Projects: what a workspace builds and runs, first on every page500 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
501 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page502 .all<DeploymentRow>(),
503 this.db
Projects: what a workspace builds and runs, first on every page504 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
505 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page506 .all<AppRow>(),
507 ]);
Projects: what a workspace builds and runs, first on every page508 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page509 }
510
Projects: what a workspace builds and runs, first on every page511 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 look512 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page513 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page514 const row = await this.db
Projects: what a workspace builds and runs, first on every page515 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
516 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page517 .first<DeploymentRow>();
518 if (!row) return fail("not_found", "No such deployment.");
519 return ok({ ...toDeployment(row), log: row.log });
520 }
521
Projects: what a workspace builds and runs, first on every page522 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 look523 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page524 if (!found.ok) return found;
525 const project = found.value;
526 const settings = await this.settingsRow(project.id);
527 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
528 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look529 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 page530 }
531 // A branch's preview comes from its pull request.
532 const app = await this.db
533 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
534 .bind(project.id, a.branch)
535 .first<{ number: number }>();
536 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 look537 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.page538 }
539
Project dependencies: addresses, preview stacks, Affects, and agents who know540 /**
541 * A preview stack: the projects that use this one get previews of their
542 * own default branch, under the same branch name, so each reaches this
543 * branch's preview through its dependency's variable. A change to an API
544 * can then be clicked through in the apps that call it.
545 */
546 async stack(
547 a: { actor: User; project: ProjectRef; branch: string },
548 background: (work: Promise<unknown>) => void,
549 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look550 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know551 if (!found.ok) return found;
552 const upstream = await this.db
553 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
554 .bind(found.value.id, a.branch)
555 .first();
556 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
557 const graph = await this.projects.graph(found.value.id);
558 const ready: { project: Project; settings: SettingsRow }[] = [];
559 for (const dependent of graph.usedBy) {
560 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 look561 // Each build spends compute on its own repository: only those the actor can run.
562 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know563 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look564 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know565 }
566 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
567 // The builds start after the answer: a person moving on from the page
568 // does not stop them.
569 background(
570 (async () => {
571 for (const { project, settings } of ready) {
572 const actor = await this.workspaceActor(project.workspace);
573 if (!actor) continue;
574 const repo = repoOf(project);
575 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
576 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
577 if (!head) continue;
578 await this.start({
579 project,
580 kind: "preview",
581 branch: a.branch,
582 number: null,
583 commit: head,
584 source: repo.path,
585 reader: actor,
586 createdBy: a.actor.username,
587 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look588 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know589 trusted: true,
590 });
591 }
592 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
593 );
594 return ok(ready.map(({ project }) => project.name));
595 }
596
Projects: what a workspace builds and runs, first on every page597 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 look598 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page599 if (!project.ok) return project;
600 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page601 return ok(true);
602 }
603
Projects: what a workspace builds and runs, first on every page604 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
605 const workspace = a.workspace.toLowerCase();
606 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 look607 // Only the projects whose repositories the viewer can read: the
608 // projects service lists no others.
609 const listed = await this.projects.list(workspace, a.viewer);
610 if (!listed.ok) return listed;
611 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page612 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look613 // A deleted repository's projects are hidden until it is restored.
614 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 page615 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
616 this.db
617 .prepare(
618 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
619 )
620 .bind(workspace)
621 .all<DeploymentRow>(),
622 ]);
623 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look624 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page625 const own = apps.results.filter((app) => app.slug === row.slug);
626 const production = own.find((app) => app.kind === "production");
627 const newest = latest.results.find((d) => d.slug === row.slug);
628 return {
629 slug: row.slug,
630 enabled: !!row.enabled,
631 production: production ? toLive(production) : null,
632 previews: own.filter((app) => app.kind === "preview").length,
633 latest: newest ? toDeployment(newest) : null,
634 };
635 }),
636 );
637 }
638
Deployments: a preview for every pull request, production on g1t.page639 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
640 const slug = a.workspace.toLowerCase();
641 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
642 const [meter, apps] = await Promise.all([
643 this.db
644 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
645 .bind(slug, month())
646 .first<{
647 requests: number;
648 cpu_ms: number;
649 peak_apps: number;
650 build_seconds: number;
651 build_micros: number;
652 counted_at: string | null;
653 }>(),
Projects: what a workspace builds and runs, first on every page654 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.page655 ]);
656 return ok({
657 month: month(),
658 requests: meter?.requests ?? 0,
659 cpuMs: meter?.cpu_ms ?? 0,
660 apps: apps?.n ?? 0,
661 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
662 buildSeconds: meter?.build_seconds ?? 0,
663 buildMicros: meter?.build_micros ?? 0,
664 countedAt: meter?.counted_at ?? null,
665 });
666 }
667
Agents and memory, checks and conflicts, profiles, slug renames, custom domains668 // ---- Custom domains ------------------------------------------------
669
670 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 look671 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains672 if (!project.ok) return project;
673 const domains = this.domains;
674 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 quotas675 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains676 domains.forProject(project.value.id),
677 domains.available(),
678 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas679 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains680 ]);
681 return ok({
682 domains: rows.map(toDomain),
683 target: CUSTOM_DOMAIN_TARGET,
684 available,
685 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas686 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains687 used,
688 });
689 }
690
691 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 look692 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains693 if (!found.ok) return found;
694 const project = found.value;
695 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
696 if (!plan.ok) return plan;
697 const added = await this.domains.add({
698 project: { id: project.id, workspace: project.workspace, slug: project.slug },
699 script: await this.productionScript(project),
700 hostname: String(a.hostname ?? ""),
701 twin: !!a.twin,
702 by: a.actor.username,
703 });
704 if (!added.ok) return fail(added.code, added.message);
705 await this.notePeak(project.workspace);
706 return ok(added.rows.map(toDomain));
707 }
708
709 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 look710 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains711 if (!found.ok) return found;
712 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
713 if (!row) return fail("not_found", "No such domain.");
714 await this.domains.remove(row);
715 return ok(true);
716 }
717
718 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 look719 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains720 if (!found.ok) return found;
721 const domains = this.domains;
722 const row = await domains.byId(found.value.id, String(a.id ?? ""));
723 if (!row) return fail("not_found", "No such domain.");
724 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
725 await this.notePeak(found.value.workspace);
726 return ok(toDomain(after));
727 }
728
729 /** The script production is up under, or the name it will have. */
730 private async productionScript(project: Project): Promise<string> {
731 const app = await this.db
732 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
733 .bind(project.id)
734 .first<{ script: string }>();
735 return app?.script ?? (await this.scriptFor(project, null));
736 }
737
Deployments: a preview for every pull request, production on g1t.page738 // ---- Starting builds -----------------------------------------------
739
740 /**
741 * Opens a deployment and starts its build. Skipped, with the reason
742 * recorded, when the workspace's plan is off.
743 */
744 private async start(input: {
Projects: what a workspace builds and runs, first on every page745 project: Project;
Deployments: a preview for every pull request, production on g1t.page746 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page747 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page748 number: number | null;
749 commit: string;
750 source: RepoPath;
751 reader: User;
752 createdBy: string;
753 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page754 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments755 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page756 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page757 const { project } = input;
758 const repo = repoOf(project);
759 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page760 const id = newId("dpl");
761 const token = randomToken();
Projects: what a workspace builds and runs, first on every page762 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far763 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page764 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look765 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page766 ? plan.error.message
Usage limits: unpaid usage can only go so far767 : limit.ok && limit.value.state === "stopped"
768 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page769 : !cloudflare
770 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
771 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look772 // A build is compute: reserved with billing before it starts, and
773 // settled by its sandbox when it stops. A refusal is the deployment's
774 // status, saying what to do.
775 const compute = gateFor(this.env.BILLING);
776 let reservation: string | null = null;
777 let microsPerSecond = 0;
778 let maxRunMinutes: number | null = null;
779 if (!refused) {
780 const ent = await compute.entitlements(project.workspace);
781 microsPerSecond = await compute.microsPerSecond();
782 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
783 const isPrivate = await reposClient(this.env.REPOS)
784 .get(repo.path, null)
785 .then((found) => !found.ok || found.value.isPrivate)
786 .catch(() => true);
787 const admitted = await compute.admit(
788 {
789 workspace: project.workspace,
790 repo: repo.path,
791 public: !isPrivate,
792 kind: "deploy",
793 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
794 },
795 ent,
796 );
797 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
798 else refused = admitted.message;
799 }
Deployments: a preview for every pull request, production on g1t.page800 await this.db
801 .prepare(
Projects: what a workspace builds and runs, first on every page802 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
803 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
804 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page805 )
806 .bind(
807 id,
Projects: what a workspace builds and runs, first on every page808 project.id,
809 project.workspace,
810 project.slug,
811 repo.id,
812 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page813 input.kind,
Projects: what a workspace builds and runs, first on every page814 input.branch,
Deployments: a preview for every pull request, production on g1t.page815 input.number,
816 input.commit,
817 script,
818 refused ? "skipped" : "queued",
819 refused,
820 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments821 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page822 input.createdBy,
823 now(),
824 refused ? now() : null,
825 )
826 .run();
827 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
828 // Older builds of the same app are replaced by this one.
829 await this.db
830 .prepare(
831 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
832 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
833 )
834 .bind(now(), script, id)
835 .run();
Projects: what a workspace builds and runs, first on every page836 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
837 // What the project's secrets and variables give builds of this kind.
838 const build = await this.resolve(
839 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
840 input.kind,
841 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know842 input.branch,
Projects: what a workspace builds and runs, first on every page843 );
Deployments: a preview for every pull request, production on g1t.page844 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
845 method: "POST",
846 headers: { "content-type": "application/json" },
847 body: JSON.stringify({
848 deployId: id,
849 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look850 workspace: project.workspace,
851 reservation,
852 microsPerSecond,
853 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page854 actor: input.reader,
855 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas856 // The project, whose guardrails the build runs under: a preview's
857 // source is its pull request's working copy, not the project.
858 repo: repo.path,
859 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page860 commit: input.commit,
Projects: what a workspace builds and runs, first on every page861 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page862 buildCommand: input.settings.build_command,
863 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments864 buildEnv: build.variables,
865 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page866 }),
867 });
868 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 look869 if (!started.ok) {
870 // Never reached a sandbox: what was reserved is given back.
871 if (reservation) await compute.settle(reservation, 0);
872 await this.finishFailed(id, started.error.message, null, null);
873 }
Deployments: a preview for every pull request, production on g1t.page874 return ok(toDeployment((await this.deploymentRow(id))!));
875 }
876
Projects: what a workspace builds and runs, first on every page877 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
878 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look879 if (!settings?.enabled || !settings.production || settings.repo_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page880 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page881 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page882 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page883 let head = commit;
884 if (!head) {
Projects: what a workspace builds and runs, first on every page885 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
886 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page887 }
888 if (!head) return null;
889 return this.start({
Projects: what a workspace builds and runs, first on every page890 project,
Deployments: a preview for every pull request, production on g1t.page891 kind: "production",
Projects: what a workspace builds and runs, first on every page892 branch: null,
Deployments: a preview for every pull request, production on g1t.page893 number: null,
894 commit: head,
Projects: what a workspace builds and runs, first on every page895 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page896 reader: actor,
897 createdBy,
898 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments899 // The default branch only moves by people and agents with access.
900 trusted: true,
Deployments: a preview for every pull request, production on g1t.page901 });
902 }
903
Projects: what a workspace builds and runs, first on every page904 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
905 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look906 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page907 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page908 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page909 const repo = repoOf(project);
910 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page911 if (!detail.ok) return null;
912 const { pull } = detail.value;
913 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page914 // A pull request from a fork (as g1t's agents work) has no branch here.
915 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page916 if (!force) {
917 // Already built, or being built, at this commit.
918 const same = await this.db
919 .prepare(
Projects: what a workspace builds and runs, first on every page920 `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.page921 AND status IN ('queued', 'building', 'ready')`,
922 )
Projects: what a workspace builds and runs, first on every page923 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page924 .first();
925 if (same) return null;
926 }
927 return this.start({
Projects: what a workspace builds and runs, first on every page928 project,
Deployments: a preview for every pull request, production on g1t.page929 kind: "preview",
Projects: what a workspace builds and runs, first on every page930 branch,
Deployments: a preview for every pull request, production on g1t.page931 number,
932 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page933 source: pull.fork ?? repo.path,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights934 // The pull request's fork may be private: read it as whoever it is
935 // for (whoever asked g1t for it, or its author), who is also who is
936 // trusted or not with the project's secrets.
937 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page938 createdBy,
939 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights940 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page941 });
942 }
943
944 // ---- A build's reports ---------------------------------------------
945
946 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
947 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
948 }
949
950 /** The build, if `token` is its own and it is still under way. */
951 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
952 const row = await this.deploymentRow(id);
953 if (!row?.token_hash || typeof token !== "string") return null;
954 if (row.token_hash !== (await sha256(token))) return null;
955 return row.status === "queued" || row.status === "building" ? row : null;
956 }
957
958 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run959 // Each report, for the logs: a build's own failure says why.
960 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page961 const row = await this.building(id, body.token);
962 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
963 const cloudflare = this.cloudflare;
964 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
965 switch (step) {
966 case "started":
967 await this.db
968 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
969 .bind(now(), id)
970 .run();
971 return Response.json(ok(true));
972 case "session": {
973 const manifest = body.manifest as Manifest | undefined;
974 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
975 const session = await cloudflare.openUpload(row.script, manifest);
976 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
977 }
978 case "finish": {
979 const worker = (body.worker ?? {}) as BuiltWorker;
980 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page981 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page982 try {
Secrets and variables: one list, rows per environment, for workflows and deployments983 // Running apps' secrets and variables are bound here, by g1t:
984 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page985 const runtime = await this.resolve(
986 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
987 row.kind,
988 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know989 row.branch,
Projects: what a workspace builds and runs, first on every page990 );
Deployments: a preview for every pull request, production on g1t.page991 await cloudflare.putScript(
992 row.script,
993 worker,
994 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page995 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments996 runtime,
Deployments: a preview for every pull request, production on g1t.page997 );
998 } catch (error) {
999 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1000 return Response.json(ok(false));
1001 }
1002 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1003 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.page1004 await this.db.batch([
1005 this.db
1006 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1007 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1008 WHERE id = ?`,
1009 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1010 .bind(
1011 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1012 String(body.log ?? ""),
1013 seconds,
1014 at,
1015 detectedKind(body.detected),
1016 id,
1017 ),
Builds say Replaced or Down once they are no longer live1018 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1019 this.db
Builds say Replaced or Down once they are no longer live1020 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1021 .bind(row.script, id),
1022 this.db
Deployments: a preview for every pull request, production on g1t.page1023 .prepare(
Projects: what a workspace builds and runs, first on every page1024 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1025 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1026 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.page1027 )
Projects: what a workspace builds and runs, first on every page1028 .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 quotas1029 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1030 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1031 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1032 if (wasRedirect) {
1033 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1034 }
1035 // The same app at an older name (its project moved) now redirects
1036 // here; it stays up as it was if the redirect cannot be put.
1037 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1038 // The project's own domains serve production wherever it is up.
1039 if (row.kind === "production") {
1040 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1041 }
Deployments: a preview for every pull request, production on g1t.page1042 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1043 await this.notePeak(row.workspace);
1044 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 look1045 // A screenshot of production as it now is, for the project's overview.
1046 if (row.kind === "production") {
1047 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1048 console.error("could not ask for a screenshot", error),
1049 );
1050 }
Deployments: a preview for every pull request, production on g1t.page1051 return Response.json(ok(true));
1052 }
1053 case "fail":
1054 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1055 return Response.json(ok(true));
1056 default:
1057 return Response.json(fail("not_found", "No such step."), { status: 404 });
1058 }
1059 }
1060
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1061 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1062 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1063 * 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 quotas1064 * same is the app under a name it had before its project moved (its
1065 * workspace renamed, its repository renamed or transferred). Each is
1066 * replaced in the namespace by a redirect to the new name, its app row
1067 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1068 * the sweep to hold the old script) and in `DOMAINS` under the old
1069 * hostname, which the dispatcher follows before running anything, so the
1070 * old address redirects even if the old script is paused. A name that
1071 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1072 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1073 private async supersede(
1074 cloudflare: Cloudflare,
1075 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1076 script: string,
1077 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1078 const older = await this.db
1079 .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 quotas1080 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1081 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1082 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1083 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1084 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1085 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1086 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1087 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1088 console.error("could not redirect", old, "to", script, error);
1089 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1090 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1091 const at = now();
1092 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1093 await this.db.batch([
1094 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1095 this.db
1096 .prepare(
1097 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1098 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1099 )
1100 .bind(old, target, app.workspace, at, expires),
1101 // Its builds are no longer live under the old name.
1102 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1103 ]);
1104 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1105 expiration: Math.floor(Date.parse(expires) / 1000),
1106 }).catch((error) => console.error("could not record redirect", old, error));
1107 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1108 }
1109 return done;
1110 }
1111
Deployments: a preview for every pull request, production on g1t.page1112 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1113 const row = await this.deploymentRow(id);
1114 if (!row || (row.status !== "queued" && row.status !== "building")) return;
1115 await this.db
1116 .prepare(
1117 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1118 WHERE id = ?`,
1119 )
1120 .bind(message.slice(0, 2000), log, seconds, now(), id)
1121 .run();
1122 // A failed build still used its sandbox.
1123 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1124 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.page1125 }
1126
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1127 /** 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.page1128 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1129 const costs = await this.costs();
1130 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1131 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1132 const what =
1133 row.kind === "preview"
1134 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1135 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1136 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1137 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1138 feature: "deployments",
1139 costMicros: cost,
1140 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1141 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1142 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1143 // Every second is metered; billing prices it from its price book and
1144 // 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 put1145 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1146 });
1147 await this.db
1148 .prepare(
1149 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1150 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1151 )
Projects: what a workspace builds and runs, first on every page1152 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1153 .run();
1154 }
1155
Prices keep themselves current with what g1t pays1156 /**
1157 * What each unit costs g1t now, from billing's price book, which follows
1158 * what Cloudflare bills. The plan's figures if billing cannot say.
1159 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1160 private async costs(): Promise<{
1161 buildSecond: number;
1162 millionRequests: number;
1163 millionCpuMs: number;
1164 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1165 /** What one custom domain is charged a month: the cost plus the margin. */
1166 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1167 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1168 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1169 const book = await billingClient(this.env.BILLING)
1170 .prices()
1171 .catch(() => null);
1172 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1173 return {
1174 buildSecond: cost("build_second", a.microsPerBuildSecond),
1175 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1176 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1177 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 quotas1178 domainMonthPrice:
1179 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1180 };
1181 }
1182
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1183 /** 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 page1184 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1185 await this.db
1186 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1187 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1188 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1189 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1190 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1191 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1192 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.page1193 )
Projects: what a workspace builds and runs, first on every page1194 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1195 .run();
1196 }
1197
Projects: what a workspace builds and runs, first on every page1198 /**
1199 * The check on the commit: `g1t / deploy`, or, for one of several
1200 * projects on a repository, `g1t / deploy (<project>)`.
1201 */
1202 private async status(
1203 repoId: string,
1204 sha: string,
1205 project: { slug: string; primary: boolean },
1206 state: string,
1207 description: string,
1208 targetUrl: string,
1209 ): Promise<void> {
1210 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1211 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1212 method: "POST",
1213 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1214 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1215 }).catch(() => undefined);
1216 }
1217
Projects: what a workspace builds and runs, first on every page1218 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1219 const projects = await this.projects.byRepo(row.repo_id);
1220 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1221 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1222 }
1223
Deployments: a preview for every pull request, production on g1t.page1224 // ---- Taking apps down ----------------------------------------------
1225
1226 private async removeApp(script: string): Promise<void> {
1227 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1228 await this.db.batch([
1229 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1230 // Its build is no longer live anywhere.
1231 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1232 ]);
Deployments: a preview for every pull request, production on g1t.page1233 }
1234
Projects: what a workspace builds and runs, first on every page1235 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1236 const apps = await this.db
1237 .prepare(
Projects: what a workspace builds and runs, first on every page1238 `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.page1239 )
Projects: what a workspace builds and runs, first on every page1240 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1241 .all<{ script: string }>();
1242 for (const app of apps.results) await this.removeApp(app.script);
1243 }
1244
1245 // ---- Events --------------------------------------------------------
1246
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1247 /** `attempts`: which delivery of the event this is, from 1. */
1248 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1249 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1250 case "workspace.renamed":
1251 await this.renamed(event.data, attempts);
1252 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1253 case "repo.transferred":
1254 case "repo.renamed":
1255 await this.moved(repoMove(event)!, attempts);
1256 break;
1257 case "repo.deleted":
1258 await this.repoDeleted(event.data.repoId);
1259 break;
1260 case "repo.restored":
1261 await this.repoRestored(event.data.repoId, attempts);
1262 break;
1263 case "repo.purged":
1264 await this.repoPurged(event.data.repoId);
1265 break;
1266 case "repo.default_branch_changed":
1267 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1268 break;
1269 case "branch.renamed":
1270 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1271 break;
1272
Deployments: a preview for every pull request, production on g1t.page1273 case "pull.opened":
1274 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1275 case "pull.updated":
1276 for (const project of await this.projects.byRepo(event.data.repoId)) {
1277 await this.deployPreview(project, event.data.number, "g1t");
1278 }
Deployments: a preview for every pull request, production on g1t.page1279 break;
1280 case "pull.closed":
1281 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1282 for (const project of await this.projects.byRepo(event.data.repoId)) {
1283 const apps = await this.db
1284 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1285 .bind(project.id, event.data.number)
1286 .all<{ script: string }>();
1287 for (const app of apps.results) await this.removeApp(app.script);
1288 }
Deployments: a preview for every pull request, production on g1t.page1289 break;
Projects: what a workspace builds and runs, first on every page1290 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1291 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1292 for (const project of await this.projects.byRepo(event.data.repoId)) {
1293 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1294 }
Deployments: a preview for every pull request, production on g1t.page1295 break;
1296 }
1297 }
1298
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1299 /**
1300 * A workspace's slug changed, and with it every app's name: production
1301 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1302 *
1303 * Its rows move to the slug it has now (asked of identity, so a delivery
1304 * 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 quotas1305 * each app (paused or not) is built again from the same commit under its
1306 * new name; see `followMoves`. The old name keeps serving the app until
1307 * the new one is live; then it redirects to the new name (see
1308 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1309 * 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 quotas1310 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1311 */
1312 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1313 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1314 const stale = staleSlugs(renamed, current);
1315 if (stale.length === 0) return;
1316 const marks = stale.map(() => "?").join(", ");
1317
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1318 // The workspace's projects, under any of its names: a second delivery
1319 // finds them under the new one, with whatever is left to do.
1320 const rows = await this.db
1321 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1322 .bind(...stale, current)
1323 .all<{ project_id: string }>();
1324 const projectIds = rows.results.map((row) => row.project_id);
1325 const projects = await this.projectsById(projectIds);
1326 // The projects service hears of the rename on its own queue: wait for
1327 // it a few deliveries, so the builds read the repository by its new name.
1328 const behind = [...projects.values()].some((p) => p.workspace !== current);
1329 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 domains1330
1331 // Every row moves at once.
1332 const at = now();
1333 const statements: D1PreparedStatement[] = [];
1334 for (const slug of stale) {
1335 statements.push(
1336 this.db
1337 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1338 `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 domains1339 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1340 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1341 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1342 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1343 this.db
1344 .prepare(
1345 `UPDATE deployments SET workspace = ?1,
1346 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1347 WHERE workspace = ?2`,
1348 )
1349 .bind(current, slug),
1350 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1351 this.domains.rename(slug, current),
1352 // Counters add up; the peak is the higher; a month charged stays charged.
1353 this.db
1354 .prepare(
1355 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1356 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1357 FROM meters WHERE namespace = ?2
1358 ON CONFLICT (namespace, month) DO UPDATE SET
1359 requests = requests + excluded.requests,
1360 cpu_ms = cpu_ms + excluded.cpu_ms,
1361 peak_apps = MAX(peak_apps, excluded.peak_apps),
1362 peak_domains = MAX(peak_domains, excluded.peak_domains),
1363 build_seconds = build_seconds + excluded.build_seconds,
1364 build_micros = build_micros + excluded.build_micros,
1365 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1366 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1367 )
1368 .bind(current, slug),
1369 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1370 );
1371 }
1372 await this.db.batch(statements);
1373
1374 // Each app again, under its new name. Its dependencies' addresses are
1375 // 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 quotas1376 const followed = await this.followMoves(projectIds, {
1377 projects,
1378 adjust: (project) => this.underSlug(project, current, stale),
1379 });
1380 if (followed.failed.length > 0) {
1381 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1382 }
1383 }
1384
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1385 /**
1386 * A repository moved: transferred to another workspace, and its projects
1387 * with it, or renamed within its own, when its own project's slug follows
1388 * its name (see the projects service). Each app's name is
1389 * `<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 quotas1390 * slug changed (production and every preview, paused or not) are built
1391 * again, from the same commit, under the new name; see `followMoves`. As
1392 * after a workspace rename, the old name keeps serving until the new one
1393 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1394 * The project's custom domains follow its production. Builds under way
1395 * are started again under the new name. What the apps used this month
1396 * stays on the old workspace's meter; from now on, the new workspace's
1397 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1398 *
1399 * Projects whose name did not change (a rename where the new name was
1400 * taken, or a project of another name) only learn the repository's new
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1401 * path. What is left to rebuild is read from the rows each time, so a
1402 * second or late delivery builds only what the first could not, and one
1403 * whose rebuild could not be queued is delivered again (and, past the
1404 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1405 */
1406 private async moved(move: RepoMove, attempts: number): Promise<void> {
1407 const current = await currentMovedPath(this.env.REPOS, move);
1408 if (staleMovedPaths(move, current).length === 0) return;
1409 const [workspace, name] = current.split("/") as [string, string];
1410 const settings = await this.db
1411 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1412 .bind(move.repoId)
1413 .all<{ project_id: string; workspace: string; slug: string }>();
1414 if (settings.results.length === 0) return;
1415
1416 // The projects service hears of the move on its own queue: wait for it
1417 // a few deliveries, so builds read the project where and as it is now.
1418 const projects = new Map<string, Project>();
1419 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1420 const behind = settings.results.some(({ project_id }) => {
1421 const project = projects.get(project_id);
1422 if (!project || project.source.kind !== "hosted") return false;
1423 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1424 });
1425 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1426
1427 // Its history goes with it, as the repository's issues do.
1428 const statements: D1PreparedStatement[] = [
1429 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1430 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1431 // The projects whose apps' names change, and have not been moved yet.
1432 const moving = settings.results
1433 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1434 .map((row) => row.project_id);
1435 if (moving.length > 0) {
1436 const ids = moving.map(() => "?").join(", ");
1437 statements.push(
1438 this.db
1439 .prepare(
1440 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1441 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1442 )
1443 .bind(MOVED_ERROR, now(), ...moving),
1444 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1445 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1446 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1447 const slug = projects.get(projectId)?.slug ?? null;
1448 statements.push(
1449 this.db
1450 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1451 .bind(workspace, slug, projectId),
1452 this.db
1453 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1454 .bind(workspace, slug, projectId),
1455 this.db
1456 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1457 .bind(workspace, slug, projectId),
1458 // Within the workspace its apps are listed under the project's name
1459 // now. One left behind in another workspace stays as it is until the
1460 // new name is live and redirects it.
1461 this.db
1462 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1463 .bind(workspace, slug, projectId),
1464 );
1465 }
1466 await this.db.batch(statements);
1467
1468 // Each app again, under its new name. App rows under the old name stay
1469 // 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 quotas1470 const followed = await this.followMoves(
1471 settings.results.map((row) => row.project_id),
1472 {
1473 projects,
1474 adjust: (found) =>
1475 found.source.kind === "hosted"
1476 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1477 : { ...found, workspace },
1478 },
1479 );
1480 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1481 }
1482
1483 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1484 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1485 const projects = new Map<string, Project>();
1486 if (projectIds.length === 0) return projects;
1487 const repoIds = new Set<string>();
1488 for (let i = 0; i < projectIds.length; i += 50) {
1489 const chunk = projectIds.slice(i, i + 50);
1490 const rows = await this.db
1491 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1492 .bind(...chunk)
1493 .all<{ repo_id: string }>();
1494 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1495 }
1496 const wanted = new Set(projectIds);
1497 for (const repoId of repoIds) {
1498 for (const project of await this.projects.byRepo(repoId)) {
1499 if (wanted.has(project.id)) projects.set(project.id, project);
1500 }
1501 }
1502 return projects;
1503 }
1504
1505 /** Whether `script` is the name an app of the project has where the project is now. */
1506 private async namedNow(
1507 script: string,
1508 settings: { project_id: string; workspace: string; slug: string },
1509 branch: string | null,
1510 ): Promise<boolean> {
1511 const base = await label(settings.workspace, settings.slug, branch);
1512 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1513 }
1514
1515 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1516 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1517 const stale: AppRow[] = [];
1518 for (const app of apps) {
1519 const row = settings.get(app.project_id);
1520 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1521 }
1522 return stale;
1523 }
1524
1525 /**
1526 * Brings the apps of the projects `projectIds` (every project when null)
1527 * under the names they have where the projects are now: each app still
1528 * under an older name, paused or not, and each build a move dropped, is
1529 * built again under its new name from the same commit, and once that is
1530 * live the old name redirects to it (`supersede`).
1531 *
1532 * Idempotent, and safe to run again at any time: an app already up under
1533 * its new name only has its old names redirected; one whose rebuild is
1534 * queued or under way is left to it; one whose workspace has no
1535 * Deployments or is over its limit waits, without a refused deployment
1536 * each time; with `backoff` (the sweep), one whose rebuild was tried
1537 * within `MOVE_RETRY_MS` waits. Returns what could not be queued, for an
1538 * event's delivery to be retried.
1539 */
1540 private async followMoves(
1541 projectIds: string[] | null,
1542 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1543 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1544 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1545 if (projectIds && projectIds.length === 0) return result;
1546 const cloudflare = this.cloudflare;
1547 if (!cloudflare) return result;
1548
1549 const settingsRows =
1550 projectIds == null
1551 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1552 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1553 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1554 if (settings.size === 0) return result;
1555 const ids = [...settings.keys()];
1556
1557 const apps: AppRow[] = [];
1558 const dropped: DroppedBuild[] = [];
1559 for (let i = 0; i < ids.length; i += 50) {
1560 const chunk = ids.slice(i, i + 50);
1561 const marks = chunk.map(() => "?").join(", ");
1562 const [appRows, droppedRows] = await Promise.all([
1563 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1564 // Builds a move dropped, that nothing has been built in place of since.
1565 this.db
1566 .prepare(
1567 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1568 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1569 AND NOT EXISTS (
1570 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1571 AND n.created_at > d.created_at AND n.status != 'skipped'
1572 )`,
1573 )
1574 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1575 .all<DeploymentRow>(),
1576 ]);
1577 apps.push(...appRows.results);
1578 dropped.push(...droppedRows.results);
1579 }
1580 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1581 if (targets.length === 0) return result;
1582
1583 const projects = new Map(options.projects ?? []);
1584 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1585 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1586 const billing = billingClient(this.env.BILLING);
1587 const open = new Map<string, boolean>();
1588 const actors = new Map<string, User | null>();
1589
1590 for (const target of targets) {
1591 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1592 const found = projects.get(target.projectId);
1593 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1594 const project = options.adjust ? options.adjust(found) : found;
1595 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1596 // Built only where deployments has the project now; the projects
1597 // service catches up on its own queue, and a later run builds then.
1598 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1599 result.waiting++;
1600 continue;
1601 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1602 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1603 const script = await this.scriptFor(project, target.branch);
1604 // Already up under its new name: only its old names are left to redirect.
1605 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1606 if (up) {
1607 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1608 continue;
1609 }
1610 const last = await this.db
1611 .prepare("SELECT status, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT 1")
1612 .bind(script)
1613 .first<{ status: string; created_at: string }>();
1614 if (last && (last.status === "queued" || last.status === "building")) {
1615 result.queued++;
1616 continue;
1617 }
1618 if (options.backoff && !retryDue(last?.created_at ?? null, Date.now(), MOVE_RETRY_MS)) {
1619 result.waiting++;
1620 continue;
1621 }
1622 // A workspace without Deployments, or over its limit, waits for it
1623 // rather than gathering refused deployments.
1624 if (!open.has(project.workspace)) {
1625 const [plan, limit] = await Promise.all([
1626 billing.hasFeature(project.workspace, "deployments"),
1627 billing.checkLimit(project.workspace),
1628 ]);
1629 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1630 }
1631 if (!open.get(project.workspace)) {
1632 result.waiting++;
1633 continue;
1634 }
1635 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 look1636 const started =
1637 target.kind === "production"
1638 ? await this.deployProduction(project, target.commit, "g1t")
1639 : target.number != null
1640 ? 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 quotas1641 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1642 const outcome = rebuildOutcome(started);
1643 if (outcome === "queued") result.queued++;
1644 else if (outcome === "failed") {
1645 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1646 result.failed.push(`${named}: ${why}`);
1647 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1648 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1649 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1650 }
1651 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1652 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1653 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1654 }
1655
1656 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1657 private async projectIdsFor(repoId: string): Promise<string[]> {
1658 const rows = await this.db
1659 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1660 .bind(repoId)
1661 .all<{ project_id: string }>();
1662 return rows.results.map((row) => row.project_id);
1663 }
1664
1665 /**
1666 * A repository was deleted, restorable for a while: every app of its
1667 * projects (production and previews) comes down, builds under way are
1668 * dropped, and nothing builds for it until it is restored. Its settings
1669 * and custom domains are kept for the restore; until then a domain has
1670 * nothing up to serve, as when production is turned off.
1671 */
1672 private async repoDeleted(repoId: string): Promise<void> {
1673 const projectIds = await this.projectIdsFor(repoId);
1674 if (projectIds.length === 0) return;
1675 const at = now();
1676 await this.db.batch([
1677 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1678 this.db
1679 .prepare(
1680 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1681 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1682 )
1683 .bind(at, repoId),
1684 ]);
1685 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1686 }
1687
1688 /**
1689 * A deleted repository is back: production goes up again from its
1690 * default branch, for each project that has it on. Previews come back
1691 * with the next push to their pull requests.
1692 */
1693 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1694 const deleted = await this.db
1695 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1696 .bind(repoId)
1697 .all<{ project_id: string }>();
1698 const ids = deleted.results.map((row) => row.project_id);
1699 if (ids.length === 0) return;
1700 // The projects service hears of the restore on its own queue, and hides
1701 // the projects until then: wait for it a few deliveries.
1702 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1703 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1704 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1705 for (const project of projects) {
1706 try {
1707 const started = await this.deployProduction(project, null, "g1t");
1708 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1709 } catch (error) {
1710 console.error("could not deploy after restore", project.slug, error);
1711 }
1712 }
1713 }
1714
1715 /**
1716 * A deleted repository is gone for good: its projects' custom domains are
1717 * removed (from the dispatcher and from Cloudflare), any app or redirect
1718 * still up comes down, and every row kept for them goes. What they used
1719 * stays on their workspace's meter.
1720 */
1721 private async repoPurged(repoId: string): Promise<void> {
1722 const projectIds = await this.projectIdsFor(repoId);
1723 const domains = this.domains;
1724 for (const projectId of projectIds) {
1725 await this.takeDownWhere(projectId, null);
1726 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1727 await domains.removeWhere("project_id", projectId);
1728 }
1729 // The redirects left at names its apps had before.
1730 const scripts = await this.db
1731 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1732 .bind(repoId)
1733 .all<{ script: string }>();
1734 const hosts = scripts.results.map(({ script }) => appHost(script));
1735 for (let i = 0; i < hosts.length; i += 50) {
1736 const chunk = hosts.slice(i, i + 50);
1737 const redirects = await this.db
1738 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1739 .bind(...chunk)
1740 .all<{ script: string }>();
1741 for (const { script } of redirects.results) {
1742 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1743 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 look1744 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1745 }
1746 }
1747 await this.db.batch([
1748 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1749 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1750 ]);
1751 }
1752
1753 /**
1754 * The default branch is another one now: production is built from it, as
1755 * from a push to it, unless production already serves (or is building)
1756 * its commit, as when the default branch was only renamed.
1757 */
1758 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1759 for (const found of await this.projects.byRepo(repoId)) {
1760 if (found.source.kind !== "hosted") continue;
1761 // Projects may not have heard yet: the event names the branch.
1762 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1763 const actor = await this.workspaceActor(project.workspace);
1764 if (!actor) continue;
1765 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1766 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1767 if (!head) continue;
1768 const same = await this.db
1769 .prepare(
1770 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1771 AND status IN ('queued', 'building', 'ready')`,
1772 )
1773 .bind(project.id, head)
1774 .first();
1775 if (same) continue;
1776 await this.deployProduction(project, head, createdBy);
1777 }
1778 }
1779
1780 /**
1781 * A branch was renamed: its preview is the same app, so its rows follow.
1782 * The app keeps its name until it is next built; then it goes up under
1783 * the new branch's name, and the old one redirects there (see `supersede`).
1784 */
1785 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1786 const projectIds = await this.projectIdsFor(repoId);
1787 if (projectIds.length === 0) return;
1788 const ids = projectIds.map(() => "?").join(", ");
1789 await this.db.batch([
1790 this.db
1791 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1792 .bind(to, from, ...projectIds),
1793 this.db
1794 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1795 .bind(to, from, ...projectIds),
1796 ]);
1797 }
1798
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1799 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1800 private underSlug(project: Project, current: string, stale: string[]): Project {
1801 const source =
1802 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1803 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1804 : project.source;
1805 return { ...project, workspace: current, source };
1806 }
1807
1808 /** A stack's preview (no pull request of its own) built again at `commit`. */
1809 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1810 if (!actor || branch == null) return null;
1811 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1812 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1813 return this.start({
1814 project,
1815 kind: "preview",
1816 branch,
1817 number: null,
1818 commit,
1819 source: repoOf(project).path,
1820 reader: actor,
1821 createdBy: "g1t",
1822 settings,
1823 // As `stack` built it: the project's own default branch.
1824 trusted: true,
1825 });
1826 }
1827
Deployments: a preview for every pull request, production on g1t.page1828 // ---- The sweep -----------------------------------------------------
1829
1830 /**
1831 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1832 * previews, the apps of workspaces whose plan ended, and scripts no app
1833 * holds come down; and a month that is over is charged past its
1834 * allowance.
Deployments: a preview for every pull request, production on g1t.page1835 */
1836 async sweep(): Promise<void> {
1837 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1838 const stuck = await this.db
1839 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1840 .bind(cutoff)
1841 .all<{ id: string }>();
1842 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1843
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1844 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1845 // Each app is its project's workspace's, as deployments has it now: an
1846 // app still under the name it had before its project moved is the new
1847 // workspace's, and plans and limits are checked there.
1848 const settings = new Map(
1849 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
1850 );
1851 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
1852 const workspaces = [...new Set(apps.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page1853
1854 // Apps of workspaces whose plan has ended come down.
1855 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1856 await this.holdToLimits(apps, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1857 for (const workspace of workspaces) {
1858 const plan = await billing.hasFeature(workspace, "deployments");
1859 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 quotas1860 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 domains1861 // Custom domains cost g1t by the month: they go with the plan.
1862 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.page1863 }
1864 }
1865
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1866 // Apps whose project moved and are not up under the new name yet: a
1867 // move's rebuild that could not start is tried again here.
1868 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
1869 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1870
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1871 await this.domains
1872 .sweep(async (projectId) => {
1873 const app = await this.db
1874 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1875 .bind(projectId)
1876 .first<{ script: string }>();
1877 return app?.script ?? null;
1878 })
1879 .catch((error) => console.error("could not check domains", error));
1880
Projects: what a workspace builds and runs, first on every page1881 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page1882 await this.count(apps).catch((error) => console.error("could not count usage", error));
1883 await this.takeDownIdle();
1884 await this.chargeMonths();
1885 }
1886
Usage limits: unpaid usage can only go so far1887 /**
1888 * Pauses the apps of workspaces that reached their limit for usage not
1889 * yet paid for, and rebuilds them from the same commit once they are
1890 * 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 quotas1891 *
1892 * The workspace is the project's now (see `ownerOf`), never the one an
1893 * app's row was written under, so an app left under its old name after
1894 * a transfer is paused only if its new workspace is over its limit. Such
1895 * an app is resumed by being built under its new name (`followMoves`),
1896 * not here.
Usage limits: unpaid usage can only go so far1897 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1898 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far1899 const cloudflare = this.cloudflare;
1900 if (!cloudflare) return;
1901 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1902 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
1903 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far1904 const limit = await billing.checkLimit(workspace);
1905 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1906 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far1907 if (limit.value.state === "stopped") {
1908 for (const app of theirs.filter((a) => !a.paused_at)) {
1909 await cloudflare.pauseScript(app.script);
1910 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1911 }
1912 continue;
1913 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1914 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
1915 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far1916 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1917 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far1918 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1919 const project = projects.get(app.project_id);
1920 if (!project) continue;
Usage limits: unpaid usage can only go so far1921 // A failed or refused rebuild leaves it paused, to try again next time.
1922 const rebuilt =
1923 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1924 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far1925 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1926 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far1927 : null;
1928 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1929 }
1930 }
1931 }
1932
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1933 /**
1934 * Scripts in the namespace that no app holds, such as ones renamed. An
1935 * old address that redirects to its app's new one is held until its
1936 * redirect expires, then removed with the rest.
1937 */
Projects: what a workspace builds and runs, first on every page1938 private async removeOrphans(apps: AppRow[]): Promise<void> {
1939 const cloudflare = this.cloudflare;
1940 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1941 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1942 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
1943 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
1944 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 page1945 const building = await this.db
1946 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1947 .all<{ script: string }>();
1948 for (const row of building.results) held.add(row.script);
1949 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1950 for (const script of await cloudflare.listScripts()) {
1951 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1952 }
1953 }
1954
Deployments: a preview for every pull request, production on g1t.page1955 /** Counts this month's requests and CPU time per workspace, from analytics. */
1956 private async count(apps: AppRow[]): Promise<void> {
1957 const cloudflare = this.cloudflare;
1958 if (!cloudflare || apps.length === 0) return;
1959 const start = `${month()}-01T00:00:00Z`;
1960 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1961 // Analytics only counts apps that are up; the meter keeps what earlier
1962 // apps used by never going down.
1963 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1964 for (const app of apps) {
1965 const used = totals.get(app.script);
1966 if (!used) continue;
Projects: what a workspace builds and runs, first on every page1967 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page1968 sum.requests += used.requests;
1969 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page1970 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page1971 }
1972 const at = now();
Projects: what a workspace builds and runs, first on every page1973 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page1974 await this.db
1975 .prepare(
1976 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1977 ON CONFLICT (namespace, month) DO UPDATE SET
1978 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1979 )
Projects: what a workspace builds and runs, first on every page1980 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page1981 .run();
1982 }
1983 // When each preview last answered anyone, for the idle sweep.
1984 const recent = await cloudflare.usage(
1985 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1986 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1987 at,
1988 );
1989 for (const [script, used] of recent) {
1990 if (used.requests > 0) {
1991 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1992 }
1993 }
Projects: what a workspace builds and runs, first on every page1994 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 quotas1995 // What this month's traffic and custom domains will cost, from the
1996 // first request and the first domain, so the workspace's limit counts
1997 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays1998 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1999 const billing = billingClient(this.env.BILLING);
2000 const meters = await this.db
2001 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2002 .bind(month())
2003 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2004 for (const meter of meters.results) {
2005 const cost = monthCost(meter, costs);
2006 await billing
2007 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2008 .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 quotas2009 if ((meter.peak_domains ?? 0) > 0) {
2010 await billing
2011 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2012 .catch((error) => console.error("could not note pending usage", error));
2013 }
Prices keep themselves current with what g1t pays2014 }
Deployments: a preview for every pull request, production on g1t.page2015 }
2016
Projects: what a workspace builds and runs, first on every page2017 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2018 private async takeDownIdle(): Promise<void> {
2019 const idle = await this.db
2020 .prepare(
Projects: what a workspace builds and runs, first on every page2021 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page2022 WHERE apps.kind = 'preview'
2023 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2024 )
2025 .all<{ script: string }>();
2026 for (const { script } of idle.results) await this.removeApp(script);
2027 }
2028
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2029 /** 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.page2030 private async chargeMonths(): Promise<void> {
2031 const due = await this.db
2032 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2033 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2034 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2035 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2036 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2037 const cost = monthCost(meter, costs);
2038 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2039 const charged = await billingClient(this.env.BILLING).chargeFeature({
2040 workspace: meter.namespace,
2041 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2042 costMicros: cost.micros,
2043 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2044 reference: `deployments/${meter.namespace}/${meter.month}`,
2045 });
2046 if (!charged.ok) continue;
2047 }
2048 await this.db
2049 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2050 .bind(now(), meter.namespace, meter.month)
2051 .run();
2052 }
2053 }
2054}
2055
2056/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2057async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2058 switch (method) {
2059 case "settings":
2060 return service.settings(args);
2061 case "update_settings":
2062 return service.updateSettings(args);
2063 case "list":
2064 return service.list(args);
2065 case "get":
2066 return service.get(args);
2067 case "redeploy":
2068 return service.redeploy(args);
2069 case "take_down":
2070 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2071 case "stack":
2072 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2073 case "overview":
2074 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2075 case "usage":
2076 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2077 case "domains":
2078 return service.listDomains(args);
2079 case "add_domain":
2080 return service.addDomain(args);
2081 case "remove_domain":
2082 return service.removeDomain(args);
2083 case "refresh_domain":
2084 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page2085 default:
2086 return undefined;
2087 }
2088}
2089
2090export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2091 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2092 const { pathname } = new URL(request.url);
2093 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2094 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2095 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2096 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2097 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2098 const opened = openD1(env.DB, request);
2099 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2100 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2101 return opened.finish(result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result));
Deployments: a preview for every pull request, production on g1t.page2102 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2103 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2104 // A build's reports, forwarded by the API.
2105 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2106 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2107 return new Response("Not found\n", { status: 404 });
2108 },
2109
2110 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2111 const service = new Deployments(env);
2112 for (const message of batch.messages) {
2113 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2114 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2115 message.ack();
2116 } catch (error) {
2117 console.error("deployments could not handle", message.body.type, error);
2118 message.retry();
2119 }
2120 }
2121 },
2122
2123 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2124 await new Deployments(env).sweep();
2125 },
2126} satisfies ExportedHandler<Env, G1tEvent>;