g1t/services/deployments/src/index.ts

2,224 lines99,375 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,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member45 isProtectedWorkspace,
Deployments: a preview for every pull request, production on g1t.page46 newId,
47 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents48 openD1,
Projects: what a workspace builds and runs, first on every page49 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50 repoMove,
Deployments: a preview for every pull request, production on g1t.page51 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains53 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page54 workClient,
55 type DeployKind,
56 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page58 type DeployStatus,
59 type DeployUsage,
60 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains61 type Domain,
Deployments: a preview for every pull request, production on g1t.page62 type G1tEvent,
63 type LiveApp,
Projects: what a workspace builds and runs, first on every page64 type Project,
65 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains67 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page68 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights69 workOwner,
Deployments: a preview for every pull request, production on g1t.page70 type RepoPath,
71 type Result,
72 type ServiceBinding,
73 type User,
74 type Viewer,
75} from "@g1t/contracts";
76
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights77import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page78import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains79import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas80import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
81import { monthCost } from "./metering";
82import { moveTargets, ownerOf, rebuildOutcome, retryDue, type DroppedBuild, type MoveTarget } from "./moves";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains83import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page84
85type Env = {
86 DB: D1Database;
87 REPOS: ServiceBinding;
88 WORK: ServiceBinding;
89 IDENTITY: ServiceBinding;
90 BILLING: ServiceBinding;
91 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page92 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments93 /** Secrets and variables: the actions service holds the one store. */
94 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page95 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
96 CLOUDFLARE_API_TOKEN?: string;
97 CLOUDFLARE_ACCOUNT_ID: string;
98 DISPATCH_NAMESPACE: string;
99 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains100 /** Custom domains: hostname to app, read by the dispatcher. */
101 DOMAINS?: KVNamespace;
102 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
103 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look104 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
105 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page106};
107
108/** A build that has not reported in this long has died. */
109const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page110/** A script in the namespace that no app holds, older than this, is removed. */
111const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page112const LIST_LIMIT = 50;
113const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains114/**
115 * Deliveries of `workspace.renamed` that wait for the projects service to
116 * have seen it too, before going ahead with the new slug regardless.
117 */
118const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas119/** Why a build under way was dropped by a move; the move builds it again under the new name. */
120const MOVED_ERROR = "The repository moved: built again under its new name.";
121const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
122/** A move's rebuild that could not start is tried again by the sweep after this long. */
123const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page124
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look125/** A build's report of what it found the project to be, if it is one g1t knows. */
126export function detectedKind(value: unknown): DetectedKind | null {
127 return value === "workers" || value === "static" || value === "html" ? value : null;
128}
129
Deployments: a preview for every pull request, production on g1t.page130const now = () => new Date().toISOString();
131const month = (at = new Date()) => at.toISOString().slice(0, 7);
132
133async function sha256(text: string): Promise<string> {
134 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
135 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
136}
137
138function randomToken(): string {
139 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
140}
141
142function isMember(viewer: Viewer, slug: string): boolean {
143 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
144}
145
Projects: what a workspace builds and runs, first on every page146/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look147/** One compute gate per isolate, so entitlements and prices are kept between calls. */
148let computeGate: ComputeGate | null = null;
149function gateFor(billing: ServiceBinding): ComputeGate {
150 computeGate ??= new ComputeGate(billing);
151 return computeGate;
152}
153
154/** The longest a build may run, in minutes: as long as its read token lasts. */
155const BUILD_MINUTES = 30;
156
157/**
158 * A build that was skipped before it started (its plan, its limit, or
159 * billing's refusal) as a failure, so whoever asked for it sees why.
160 */
161function notStarted(result: Result<Deployment>): Result<Deployment> {
162 if (result.ok && result.value.status === "skipped" && result.value.error) {
163 return fail("payment_required", result.value.error);
164 }
165 return result;
166}
167
Projects: what a workspace builds and runs, first on every page168function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
169 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
170 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
171}
172
Deployments: a preview for every pull request, production on g1t.page173type SettingsRow = {
Projects: what a workspace builds and runs, first on every page174 project_id: string;
175 workspace: string;
176 slug: string;
Deployments: a preview for every pull request, production on g1t.page177 repo_id: string;
178 enabled: number;
179 previews: number;
180 production: number;
181 build_command: string | null;
182 output_dir: string | null;
183 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look184 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
185 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member186 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
187 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page188};
189
190type DeploymentRow = {
191 id: string;
Projects: what a workspace builds and runs, first on every page192 project_id: string;
193 workspace: string;
194 slug: string;
Deployments: a preview for every pull request, production on g1t.page195 repo_id: string;
Projects: what a workspace builds and runs, first on every page196 repo: string;
Deployments: a preview for every pull request, production on g1t.page197 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page198 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page199 number: number | null;
200 commit_sha: string;
201 script: string;
202 status: DeployStatus;
203 error: string | null;
204 warnings: string;
205 log: string | null;
206 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments207 trusted: number;
Deployments: a preview for every pull request, production on g1t.page208 build_seconds: number | null;
209 created_by: string;
210 created_at: string;
211 finished_at: string | null;
212};
213
214type AppRow = {
215 script: string;
Projects: what a workspace builds and runs, first on every page216 project_id: string;
217 workspace: string;
218 slug: string;
Deployments: a preview for every pull request, production on g1t.page219 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page220 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page221 number: number | null;
222 commit_sha: string;
223 deployed_at: string;
224 created_at: string;
225 last_request_at: string | null;
Usage limits: unpaid usage can only go so far226 /** Set while its workspace is over its limit; see `holdToLimits`. */
227 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page228};
229
230function toDeployment(row: DeploymentRow): Deployment {
231 return {
232 id: row.id,
233 kind: row.kind,
Projects: what a workspace builds and runs, first on every page234 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page235 number: row.number,
236 commit: row.commit_sha,
237 status: row.status,
238 url: appUrl(row.script),
239 error: row.error,
240 warnings: JSON.parse(row.warnings || "[]") as string[],
241 buildSeconds: row.build_seconds,
242 createdBy: row.created_by,
243 createdAt: row.created_at,
244 finishedAt: row.finished_at,
245 };
246}
247
Projects: what a workspace builds and runs, first on every page248function toLive(app: AppRow): LiveApp {
249 return {
250 kind: app.kind,
251 branch: app.branch,
252 number: app.number,
253 url: appUrl(app.script),
254 commit: app.commit_sha,
255 deployedAt: app.deployed_at,
256 };
257}
258
Deployments: a preview for every pull request, production on g1t.page259class Deployments {
260 constructor(private readonly env: Env) {}
261
262 private get cloudflare(): Cloudflare | null {
263 const token = this.env.CLOUDFLARE_API_TOKEN;
264 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
265 }
266
267 private get db() {
268 return this.env.DB;
269 }
270
Agents and memory, checks and conflicts, profiles, slug renames, custom domains271 private get domains(): Domains {
272 const token = this.env.CLOUDFLARE_API_TOKEN;
273 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
274 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
275 }
276
Projects: what a workspace builds and runs, first on every page277 private get projects() {
278 return projectsClient(this.env.PROJECTS);
279 }
280
Deployments: a preview for every pull request, production on g1t.page281 /** The workspace itself, as the service acts for it. */
282 private async workspaceActor(slug: string): Promise<User | null> {
283 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
284 if (!workspace) return null;
285 return {
286 id: workspace.id,
287 username: workspace.slug,
288 kind: "workspace",
289 verified: true,
290 workspaces: [{ slug: workspace.slug, role: "member" }],
291 };
292 }
293
Secrets and variables: one list, rows per environment, for workflows and deployments294 /**
Projects: what a workspace builds and runs, first on every page295 * What the project's secrets and variables available to deployments give
296 * production or a preview: its build's environment, and the same again as
297 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments298 */
299 private async resolve(
Projects: what a workspace builds and runs, first on every page300 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments301 environment: DeployKind,
302 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know303 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments304 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know305 const [rows, references] = await Promise.all([
306 this.rows(project, environment, trusted),
307 this.references(project.id, environment === "preview" ? branch : null),
308 ]);
309 // The project's own rows win over a dependency's address of the same name.
310 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
311 }
312
313 /**
314 * Each dependency's address, under the name the dependency gives it:
315 * for a preview, the same branch's preview of it if one is up, else its
316 * production; for production, its production.
317 */
318 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
319 const graph = await this.projects.graph(projectId).catch(() => null);
320 const out: Record<string, string> = {};
321 for (const dependency of graph?.dependsOn ?? []) {
322 if (!dependency.as) continue;
323 const app =
324 (branch
325 ? await this.db
326 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
327 .bind(dependency.id, branch)
328 .first<{ script: string }>()
329 : null) ??
330 (await this.db
331 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
332 .bind(dependency.id)
333 .first<{ script: string }>());
334 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
335 }
336 return out;
337 }
338
339 private async rows(
340 project: { id: string; slug: string; repoId: string; repo: RepoPath },
341 environment: DeployKind,
342 trusted: boolean,
343 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments344 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
345 method: "POST",
346 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page347 body: JSON.stringify({
348 repoId: project.repoId,
349 repo: project.repo,
350 projectId: project.id,
351 projectSlug: project.slug,
352 consumer: "deployments",
353 environment,
354 trusted,
355 }),
Secrets and variables: one list, rows per environment, for workflows and deployments356 });
357 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
358 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
359 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
360 }
361
362 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights363 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
364 * it, or its author) is trusted with the project's secrets: g1t itself,
365 * or someone who can push to the repository (Write or more, a member's or
366 * a collaborator's). Anyone else gets a preview built without them, as
367 * their workflows run. A change g1t made for someone is trusted as they
368 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments369 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights370 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
371 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look372 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 rights373 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look374 .catch(() => null);
375 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments376 }
377
Projects: what a workspace builds and runs, first on every page378 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
379 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page380 }
381
Projects: what a workspace builds and runs, first on every page382 /** The name an app gets, unique among apps: production, or a branch's preview. */
383 private async scriptFor(project: Project, branch: string | null): Promise<string> {
384 const base = await label(project.workspace, project.slug, branch);
385 const holder = await this.db
386 .prepare(
387 `SELECT project_id, branch FROM apps WHERE script = ?1
388 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
389 )
390 .bind(base)
391 .first<{ project_id: string; branch: string | null }>();
392 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
393 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
394 }
395
396 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page397 return {
398 enabled: !!row?.enabled,
399 previews: row ? !!row.previews : true,
400 production: row ? !!row.production : true,
401 buildCommand: row?.build_command ?? null,
402 outputDir: row?.output_dir ?? null,
403 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page404 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains405 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page407 };
408 }
409
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look410 /** What the project's last finished build found it to be; null before one has. */
411 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
412 const row = await this.db
413 .prepare(
414 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
415 )
416 .bind(projectId)
417 .first<{ detected: string }>()
418 .catch(() => null);
419 return detectedKind(row?.detected);
420 }
421
422 /**
423 * The project, if `viewer` may do what `method` needs on its repository
424 * (see `NEEDS`): not found when they cannot read it, refused when they can
425 * but their role is too low.
426 */
427 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
428 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
429 if (!found.ok) return found;
430 const repo = repoRef(found.value);
431 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
432 const capability = NEEDS[method];
433 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
434 return found;
Deployments: a preview for every pull request, production on g1t.page435 }
436
437 // ---- Methods for the site and the API ------------------------------
438
Projects: what a workspace builds and runs, first on every page439 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 look440 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page441 if (!project.ok) return project;
442 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page443 }
444
445 async updateSettings(a: {
446 actor: User;
Projects: what a workspace builds and runs, first on every page447 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page448 changes: Partial<DeploySettings>;
449 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look450 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page451 if (!found.ok) return found;
452 const project = found.value;
453 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page454 const next = { ...before, ...a.changes };
455 if (next.enabled && !before.enabled) {
456 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page457 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page458 if (!plan.ok) return plan;
459 }
460 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
461 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
462 await this.db
463 .prepare(
Projects: what a workspace builds and runs, first on every page464 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
465 output_dir, idle_days, updated_by, updated_at)
466 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
467 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
468 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.page469 )
470 .bind(
Projects: what a workspace builds and runs, first on every page471 project.id,
472 project.workspace,
473 project.slug,
474 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page475 next.enabled ? 1 : 0,
476 next.previews ? 1 : 0,
477 next.production ? 1 : 0,
478 clip(next.buildCommand),
479 clip(next.outputDir),
480 idleDays,
481 a.actor.username,
482 now(),
483 )
484 .run();
485 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page486 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page487 else {
Projects: what a workspace builds and runs, first on every page488 if (!next.previews) await this.takeDownWhere(project.id, "preview");
489 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page490 }
491 // Turned on: production goes up from the default branch at once.
492 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page493 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page494 }
Projects: what a workspace builds and runs, first on every page495 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page496 }
497
Projects: what a workspace builds and runs, first on every page498 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 look499 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page500 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page501 const [deployments, apps] = await Promise.all([
502 this.db
Projects: what a workspace builds and runs, first on every page503 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
504 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page505 .all<DeploymentRow>(),
506 this.db
Projects: what a workspace builds and runs, first on every page507 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
508 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page509 .all<AppRow>(),
510 ]);
Projects: what a workspace builds and runs, first on every page511 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page512 }
513
Projects: what a workspace builds and runs, first on every page514 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 look515 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page516 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page517 const row = await this.db
Projects: what a workspace builds and runs, first on every page518 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
519 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page520 .first<DeploymentRow>();
521 if (!row) return fail("not_found", "No such deployment.");
522 return ok({ ...toDeployment(row), log: row.log });
523 }
524
Projects: what a workspace builds and runs, first on every page525 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 look526 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page527 if (!found.ok) return found;
528 const project = found.value;
529 const settings = await this.settingsRow(project.id);
530 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
531 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look532 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 page533 }
534 // A branch's preview comes from its pull request.
535 const app = await this.db
536 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
537 .bind(project.id, a.branch)
538 .first<{ number: number }>();
539 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 look540 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.page541 }
542
Project dependencies: addresses, preview stacks, Affects, and agents who know543 /**
544 * A preview stack: the projects that use this one get previews of their
545 * own default branch, under the same branch name, so each reaches this
546 * branch's preview through its dependency's variable. A change to an API
547 * can then be clicked through in the apps that call it.
548 */
549 async stack(
550 a: { actor: User; project: ProjectRef; branch: string },
551 background: (work: Promise<unknown>) => void,
552 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look553 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know554 if (!found.ok) return found;
555 const upstream = await this.db
556 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
557 .bind(found.value.id, a.branch)
558 .first();
559 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
560 const graph = await this.projects.graph(found.value.id);
561 const ready: { project: Project; settings: SettingsRow }[] = [];
562 for (const dependent of graph.usedBy) {
563 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 look564 // Each build spends compute on its own repository: only those the actor can run.
565 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know566 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look567 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know568 }
569 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
570 // The builds start after the answer: a person moving on from the page
571 // does not stop them.
572 background(
573 (async () => {
574 for (const { project, settings } of ready) {
575 const actor = await this.workspaceActor(project.workspace);
576 if (!actor) continue;
577 const repo = repoOf(project);
578 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
579 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
580 if (!head) continue;
581 await this.start({
582 project,
583 kind: "preview",
584 branch: a.branch,
585 number: null,
586 commit: head,
587 source: repo.path,
588 reader: actor,
589 createdBy: a.actor.username,
590 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look591 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know592 trusted: true,
593 });
594 }
595 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
596 );
597 return ok(ready.map(({ project }) => project.name));
598 }
599
Projects: what a workspace builds and runs, first on every page600 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 look601 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page602 if (!project.ok) return project;
603 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page604 return ok(true);
605 }
606
Projects: what a workspace builds and runs, first on every page607 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
608 const workspace = a.workspace.toLowerCase();
609 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 look610 // Only the projects whose repositories the viewer can read: the
611 // projects service lists no others.
612 const listed = await this.projects.list(workspace, a.viewer);
613 if (!listed.ok) return listed;
614 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page615 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look616 // A deleted repository's projects are hidden until it is restored.
617 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 page618 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
619 this.db
620 .prepare(
621 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
622 )
623 .bind(workspace)
624 .all<DeploymentRow>(),
625 ]);
626 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look627 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page628 const own = apps.results.filter((app) => app.slug === row.slug);
629 const production = own.find((app) => app.kind === "production");
630 const newest = latest.results.find((d) => d.slug === row.slug);
631 return {
632 slug: row.slug,
633 enabled: !!row.enabled,
634 production: production ? toLive(production) : null,
635 previews: own.filter((app) => app.kind === "preview").length,
636 latest: newest ? toDeployment(newest) : null,
637 };
638 }),
639 );
640 }
641
Deployments: a preview for every pull request, production on g1t.page642 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
643 const slug = a.workspace.toLowerCase();
644 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
645 const [meter, apps] = await Promise.all([
646 this.db
647 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
648 .bind(slug, month())
649 .first<{
650 requests: number;
651 cpu_ms: number;
652 peak_apps: number;
653 build_seconds: number;
654 build_micros: number;
655 counted_at: string | null;
656 }>(),
Projects: what a workspace builds and runs, first on every page657 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.page658 ]);
659 return ok({
660 month: month(),
661 requests: meter?.requests ?? 0,
662 cpuMs: meter?.cpu_ms ?? 0,
663 apps: apps?.n ?? 0,
664 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
665 buildSeconds: meter?.build_seconds ?? 0,
666 buildMicros: meter?.build_micros ?? 0,
667 countedAt: meter?.counted_at ?? null,
668 });
669 }
670
Agents and memory, checks and conflicts, profiles, slug renames, custom domains671 // ---- Custom domains ------------------------------------------------
672
673 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 look674 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains675 if (!project.ok) return project;
676 const domains = this.domains;
677 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 quotas678 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains679 domains.forProject(project.value.id),
680 domains.available(),
681 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas682 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains683 ]);
684 return ok({
685 domains: rows.map(toDomain),
686 target: CUSTOM_DOMAIN_TARGET,
687 available,
688 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas689 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains690 used,
691 });
692 }
693
694 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 look695 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains696 if (!found.ok) return found;
697 const project = found.value;
698 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
699 if (!plan.ok) return plan;
700 const added = await this.domains.add({
701 project: { id: project.id, workspace: project.workspace, slug: project.slug },
702 script: await this.productionScript(project),
703 hostname: String(a.hostname ?? ""),
704 twin: !!a.twin,
705 by: a.actor.username,
706 });
707 if (!added.ok) return fail(added.code, added.message);
708 await this.notePeak(project.workspace);
709 return ok(added.rows.map(toDomain));
710 }
711
712 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 look713 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains714 if (!found.ok) return found;
715 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
716 if (!row) return fail("not_found", "No such domain.");
717 await this.domains.remove(row);
718 return ok(true);
719 }
720
721 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 look722 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains723 if (!found.ok) return found;
724 const domains = this.domains;
725 const row = await domains.byId(found.value.id, String(a.id ?? ""));
726 if (!row) return fail("not_found", "No such domain.");
727 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
728 await this.notePeak(found.value.workspace);
729 return ok(toDomain(after));
730 }
731
732 /** The script production is up under, or the name it will have. */
733 private async productionScript(project: Project): Promise<string> {
734 const app = await this.db
735 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
736 .bind(project.id)
737 .first<{ script: string }>();
738 return app?.script ?? (await this.scriptFor(project, null));
739 }
740
Deployments: a preview for every pull request, production on g1t.page741 // ---- Starting builds -----------------------------------------------
742
743 /**
744 * Opens a deployment and starts its build. Skipped, with the reason
745 * recorded, when the workspace's plan is off.
746 */
747 private async start(input: {
Projects: what a workspace builds and runs, first on every page748 project: Project;
Deployments: a preview for every pull request, production on g1t.page749 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page750 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page751 number: number | null;
752 commit: string;
753 source: RepoPath;
754 reader: User;
755 createdBy: string;
756 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page757 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments758 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page759 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page760 const { project } = input;
761 const repo = repoOf(project);
762 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page763 const id = newId("dpl");
764 const token = randomToken();
Projects: what a workspace builds and runs, first on every page765 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far766 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page767 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look768 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page769 ? plan.error.message
Usage limits: unpaid usage can only go so far770 : limit.ok && limit.value.state === "stopped"
771 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page772 : !cloudflare
773 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
774 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look775 // A build is compute: reserved with billing before it starts, and
776 // settled by its sandbox when it stops. A refusal is the deployment's
777 // status, saying what to do.
778 const compute = gateFor(this.env.BILLING);
779 let reservation: string | null = null;
780 let microsPerSecond = 0;
781 let maxRunMinutes: number | null = null;
782 if (!refused) {
783 const ent = await compute.entitlements(project.workspace);
784 microsPerSecond = await compute.microsPerSecond();
785 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
786 const isPrivate = await reposClient(this.env.REPOS)
787 .get(repo.path, null)
788 .then((found) => !found.ok || found.value.isPrivate)
789 .catch(() => true);
790 const admitted = await compute.admit(
791 {
792 workspace: project.workspace,
793 repo: repo.path,
794 public: !isPrivate,
795 kind: "deploy",
796 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
797 },
798 ent,
799 );
800 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
801 else refused = admitted.message;
802 }
Deployments: a preview for every pull request, production on g1t.page803 await this.db
804 .prepare(
Projects: what a workspace builds and runs, first on every page805 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
806 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
807 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page808 )
809 .bind(
810 id,
Projects: what a workspace builds and runs, first on every page811 project.id,
812 project.workspace,
813 project.slug,
814 repo.id,
815 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page816 input.kind,
Projects: what a workspace builds and runs, first on every page817 input.branch,
Deployments: a preview for every pull request, production on g1t.page818 input.number,
819 input.commit,
820 script,
821 refused ? "skipped" : "queued",
822 refused,
823 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments824 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page825 input.createdBy,
826 now(),
827 refused ? now() : null,
828 )
829 .run();
830 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
831 // Older builds of the same app are replaced by this one.
832 await this.db
833 .prepare(
834 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
835 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
836 )
837 .bind(now(), script, id)
838 .run();
Projects: what a workspace builds and runs, first on every page839 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
840 // What the project's secrets and variables give builds of this kind.
841 const build = await this.resolve(
842 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
843 input.kind,
844 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know845 input.branch,
Projects: what a workspace builds and runs, first on every page846 );
Deployments: a preview for every pull request, production on g1t.page847 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
848 method: "POST",
849 headers: { "content-type": "application/json" },
850 body: JSON.stringify({
851 deployId: id,
852 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look853 workspace: project.workspace,
854 reservation,
855 microsPerSecond,
856 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page857 actor: input.reader,
858 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas859 // The project, whose guardrails the build runs under: a preview's
860 // source is its pull request's working copy, not the project.
861 repo: repo.path,
862 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page863 commit: input.commit,
Projects: what a workspace builds and runs, first on every page864 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page865 buildCommand: input.settings.build_command,
866 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments867 buildEnv: build.variables,
868 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page869 }),
870 });
871 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 look872 if (!started.ok) {
873 // Never reached a sandbox: what was reserved is given back.
874 if (reservation) await compute.settle(reservation, 0);
875 await this.finishFailed(id, started.error.message, null, null);
876 }
Deployments: a preview for every pull request, production on g1t.page877 return ok(toDeployment((await this.deploymentRow(id))!));
878 }
879
Projects: what a workspace builds and runs, first on every page880 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
881 const settings = await this.settingsRow(project.id);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member882 if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page883 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page884 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page885 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page886 let head = commit;
887 if (!head) {
Projects: what a workspace builds and runs, first on every page888 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
889 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page890 }
891 if (!head) return null;
892 return this.start({
Projects: what a workspace builds and runs, first on every page893 project,
Deployments: a preview for every pull request, production on g1t.page894 kind: "production",
Projects: what a workspace builds and runs, first on every page895 branch: null,
Deployments: a preview for every pull request, production on g1t.page896 number: null,
897 commit: head,
Projects: what a workspace builds and runs, first on every page898 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page899 reader: actor,
900 createdBy,
901 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments902 // The default branch only moves by people and agents with access.
903 trusted: true,
Deployments: a preview for every pull request, production on g1t.page904 });
905 }
906
Projects: what a workspace builds and runs, first on every page907 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
908 const settings = await this.settingsRow(project.id);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member909 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
Projects: what a workspace builds and runs, first on every page910 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page911 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page912 const repo = repoOf(project);
913 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page914 if (!detail.ok) return null;
915 const { pull } = detail.value;
916 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page917 // A pull request from a fork (as g1t's agents work) has no branch here.
918 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page919 if (!force) {
920 // Already built, or being built, at this commit.
921 const same = await this.db
922 .prepare(
Projects: what a workspace builds and runs, first on every page923 `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.page924 AND status IN ('queued', 'building', 'ready')`,
925 )
Projects: what a workspace builds and runs, first on every page926 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page927 .first();
928 if (same) return null;
929 }
930 return this.start({
Projects: what a workspace builds and runs, first on every page931 project,
Deployments: a preview for every pull request, production on g1t.page932 kind: "preview",
Projects: what a workspace builds and runs, first on every page933 branch,
Deployments: a preview for every pull request, production on g1t.page934 number,
935 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page936 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 rights937 // The pull request's fork may be private: read it as whoever it is
938 // for (whoever asked g1t for it, or its author), who is also who is
939 // trusted or not with the project's secrets.
940 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page941 createdBy,
942 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights943 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page944 });
945 }
946
947 // ---- A build's reports ---------------------------------------------
948
949 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
950 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
951 }
952
953 /** The build, if `token` is its own and it is still under way. */
954 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
955 const row = await this.deploymentRow(id);
956 if (!row?.token_hash || typeof token !== "string") return null;
957 if (row.token_hash !== (await sha256(token))) return null;
958 return row.status === "queued" || row.status === "building" ? row : null;
959 }
960
961 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run962 // Each report, for the logs: a build's own failure says why.
963 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page964 const row = await this.building(id, body.token);
965 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
966 const cloudflare = this.cloudflare;
967 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
968 switch (step) {
969 case "started":
970 await this.db
971 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
972 .bind(now(), id)
973 .run();
974 return Response.json(ok(true));
975 case "session": {
976 const manifest = body.manifest as Manifest | undefined;
977 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
978 const session = await cloudflare.openUpload(row.script, manifest);
979 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
980 }
981 case "finish": {
982 const worker = (body.worker ?? {}) as BuiltWorker;
983 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page984 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page985 try {
Secrets and variables: one list, rows per environment, for workflows and deployments986 // Running apps' secrets and variables are bound here, by g1t:
987 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page988 const runtime = await this.resolve(
989 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
990 row.kind,
991 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know992 row.branch,
Projects: what a workspace builds and runs, first on every page993 );
Deployments: a preview for every pull request, production on g1t.page994 await cloudflare.putScript(
995 row.script,
996 worker,
997 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page998 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments999 runtime,
Deployments: a preview for every pull request, production on g1t.page1000 );
1001 } catch (error) {
1002 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1003 return Response.json(ok(false));
1004 }
1005 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1006 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.page1007 await this.db.batch([
1008 this.db
1009 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1010 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1011 WHERE id = ?`,
1012 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1013 .bind(
1014 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1015 String(body.log ?? ""),
1016 seconds,
1017 at,
1018 detectedKind(body.detected),
1019 id,
1020 ),
Builds say Replaced or Down once they are no longer live1021 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1022 this.db
Builds say Replaced or Down once they are no longer live1023 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1024 .bind(row.script, id),
1025 this.db
Deployments: a preview for every pull request, production on g1t.page1026 .prepare(
Projects: what a workspace builds and runs, first on every page1027 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1028 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1029 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.page1030 )
Projects: what a workspace builds and runs, first on every page1031 .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 quotas1032 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1033 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1034 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1035 if (wasRedirect) {
1036 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1037 }
1038 // The same app at an older name (its project moved) now redirects
1039 // here; it stays up as it was if the redirect cannot be put.
1040 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1041 // The project's own domains serve production wherever it is up.
1042 if (row.kind === "production") {
1043 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1044 }
Deployments: a preview for every pull request, production on g1t.page1045 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1046 await this.notePeak(row.workspace);
1047 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 look1048 // A screenshot of production as it now is, for the project's overview.
1049 if (row.kind === "production") {
1050 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1051 console.error("could not ask for a screenshot", error),
1052 );
1053 }
Deployments: a preview for every pull request, production on g1t.page1054 return Response.json(ok(true));
1055 }
1056 case "fail":
1057 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1058 return Response.json(ok(true));
1059 default:
1060 return Response.json(fail("not_found", "No such step."), { status: 404 });
1061 }
1062 }
1063
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1064 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1065 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1066 * 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 quotas1067 * same is the app under a name it had before its project moved (its
1068 * workspace renamed, its repository renamed or transferred). Each is
1069 * replaced in the namespace by a redirect to the new name, its app row
1070 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1071 * the sweep to hold the old script) and in `DOMAINS` under the old
1072 * hostname, which the dispatcher follows before running anything, so the
1073 * old address redirects even if the old script is paused. A name that
1074 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1075 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1076 private async supersede(
1077 cloudflare: Cloudflare,
1078 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1079 script: string,
1080 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1081 const older = await this.db
1082 .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 quotas1083 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1084 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1085 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1086 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1087 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1088 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1089 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1090 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1091 console.error("could not redirect", old, "to", script, error);
1092 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1093 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1094 const at = now();
1095 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1096 await this.db.batch([
1097 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1098 this.db
1099 .prepare(
1100 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1101 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1102 )
1103 .bind(old, target, app.workspace, at, expires),
1104 // Its builds are no longer live under the old name.
1105 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1106 ]);
1107 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1108 expiration: Math.floor(Date.parse(expires) / 1000),
1109 }).catch((error) => console.error("could not record redirect", old, error));
1110 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1111 }
1112 return done;
1113 }
1114
Deployments: a preview for every pull request, production on g1t.page1115 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1116 const row = await this.deploymentRow(id);
1117 if (!row || (row.status !== "queued" && row.status !== "building")) return;
1118 await this.db
1119 .prepare(
1120 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1121 WHERE id = ?`,
1122 )
1123 .bind(message.slice(0, 2000), log, seconds, now(), id)
1124 .run();
1125 // A failed build still used its sandbox.
1126 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1127 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.page1128 }
1129
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1130 /** 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.page1131 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1132 const costs = await this.costs();
1133 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1134 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1135 const what =
1136 row.kind === "preview"
1137 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1138 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1139 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1140 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1141 feature: "deployments",
1142 costMicros: cost,
1143 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1144 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1145 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1146 // Every second is metered; billing prices it from its price book and
1147 // 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 put1148 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1149 });
1150 await this.db
1151 .prepare(
1152 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1153 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1154 )
Projects: what a workspace builds and runs, first on every page1155 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1156 .run();
1157 }
1158
Prices keep themselves current with what g1t pays1159 /**
1160 * What each unit costs g1t now, from billing's price book, which follows
1161 * what Cloudflare bills. The plan's figures if billing cannot say.
1162 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1163 private async costs(): Promise<{
1164 buildSecond: number;
1165 millionRequests: number;
1166 millionCpuMs: number;
1167 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1168 /** What one custom domain is charged a month: the cost plus the margin. */
1169 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1170 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1171 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1172 const book = await billingClient(this.env.BILLING)
1173 .prices()
1174 .catch(() => null);
1175 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1176 return {
1177 buildSecond: cost("build_second", a.microsPerBuildSecond),
1178 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1179 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1180 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 quotas1181 domainMonthPrice:
1182 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1183 };
1184 }
1185
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1186 /** 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 page1187 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1188 await this.db
1189 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1190 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1191 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1192 (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 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1194 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1195 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.page1196 )
Projects: what a workspace builds and runs, first on every page1197 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1198 .run();
1199 }
1200
Projects: what a workspace builds and runs, first on every page1201 /**
1202 * The check on the commit: `g1t / deploy`, or, for one of several
1203 * projects on a repository, `g1t / deploy (<project>)`.
1204 */
1205 private async status(
1206 repoId: string,
1207 sha: string,
1208 project: { slug: string; primary: boolean },
1209 state: string,
1210 description: string,
1211 targetUrl: string,
1212 ): Promise<void> {
1213 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1214 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1215 method: "POST",
1216 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1217 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1218 }).catch(() => undefined);
1219 }
1220
Projects: what a workspace builds and runs, first on every page1221 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1222 const projects = await this.projects.byRepo(row.repo_id);
1223 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1224 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1225 }
1226
Deployments: a preview for every pull request, production on g1t.page1227 // ---- Taking apps down ----------------------------------------------
1228
1229 private async removeApp(script: string): Promise<void> {
1230 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1231 await this.db.batch([
1232 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1233 // Its build is no longer live anywhere.
1234 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1235 ]);
Deployments: a preview for every pull request, production on g1t.page1236 }
1237
Projects: what a workspace builds and runs, first on every page1238 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1239 const apps = await this.db
1240 .prepare(
Projects: what a workspace builds and runs, first on every page1241 `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.page1242 )
Projects: what a workspace builds and runs, first on every page1243 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1244 .all<{ script: string }>();
1245 for (const app of apps.results) await this.removeApp(app.script);
1246 }
1247
1248 // ---- Events --------------------------------------------------------
1249
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1250 /** `attempts`: which delivery of the event this is, from 1. */
1251 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1252 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1253 case "workspace.renamed":
1254 await this.renamed(event.data, attempts);
1255 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1256 case "repo.transferred":
1257 case "repo.renamed":
1258 await this.moved(repoMove(event)!, attempts);
1259 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1260 // A repository that went or came back with its workspace is the
1261 // workspace's to handle: its apps are paused, not taken down.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1262 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1263 if (!event.data.withWorkspace) await this.repoDeleted(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1264 break;
1265 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1266 if (!event.data.withWorkspace) await this.repoRestored(event.data.repoId, attempts);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1267 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1268 case "workspace.deleting":
1269 await this.workspaceDeleting(event.data.slug);
1270 break;
1271 case "workspace.restored":
1272 await this.workspaceRestored(event.data.slug);
1273 break;
1274 case "workspace.deleted":
1275 await this.workspacePurged(event.data.slug);
1276 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1277 case "repo.purged":
1278 await this.repoPurged(event.data.repoId);
1279 break;
1280 case "repo.default_branch_changed":
1281 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1282 break;
1283 case "branch.renamed":
1284 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1285 break;
1286
Deployments: a preview for every pull request, production on g1t.page1287 case "pull.opened":
1288 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1289 case "pull.updated":
1290 for (const project of await this.projects.byRepo(event.data.repoId)) {
1291 await this.deployPreview(project, event.data.number, "g1t");
1292 }
Deployments: a preview for every pull request, production on g1t.page1293 break;
1294 case "pull.closed":
1295 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1296 for (const project of await this.projects.byRepo(event.data.repoId)) {
1297 const apps = await this.db
1298 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1299 .bind(project.id, event.data.number)
1300 .all<{ script: string }>();
1301 for (const app of apps.results) await this.removeApp(app.script);
1302 }
Deployments: a preview for every pull request, production on g1t.page1303 break;
Projects: what a workspace builds and runs, first on every page1304 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1305 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1306 for (const project of await this.projects.byRepo(event.data.repoId)) {
1307 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1308 }
Deployments: a preview for every pull request, production on g1t.page1309 break;
1310 }
1311 }
1312
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1313 /**
1314 * A workspace's slug changed, and with it every app's name: production
1315 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1316 *
1317 * Its rows move to the slug it has now (asked of identity, so a delivery
1318 * 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 quotas1319 * each app (paused or not) is built again from the same commit under its
1320 * new name; see `followMoves`. The old name keeps serving the app until
1321 * the new one is live; then it redirects to the new name (see
1322 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1323 * 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 quotas1324 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1325 */
1326 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1327 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1328 const stale = staleSlugs(renamed, current);
1329 if (stale.length === 0) return;
1330 const marks = stale.map(() => "?").join(", ");
1331
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1332 // The workspace's projects, under any of its names: a second delivery
1333 // finds them under the new one, with whatever is left to do.
1334 const rows = await this.db
1335 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1336 .bind(...stale, current)
1337 .all<{ project_id: string }>();
1338 const projectIds = rows.results.map((row) => row.project_id);
1339 const projects = await this.projectsById(projectIds);
1340 // The projects service hears of the rename on its own queue: wait for
1341 // it a few deliveries, so the builds read the repository by its new name.
1342 const behind = [...projects.values()].some((p) => p.workspace !== current);
1343 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 domains1344
1345 // Every row moves at once.
1346 const at = now();
1347 const statements: D1PreparedStatement[] = [];
1348 for (const slug of stale) {
1349 statements.push(
1350 this.db
1351 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1352 `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 domains1353 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1354 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1355 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1356 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1357 this.db
1358 .prepare(
1359 `UPDATE deployments SET workspace = ?1,
1360 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1361 WHERE workspace = ?2`,
1362 )
1363 .bind(current, slug),
1364 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1365 this.domains.rename(slug, current),
1366 // Counters add up; the peak is the higher; a month charged stays charged.
1367 this.db
1368 .prepare(
1369 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1370 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1371 FROM meters WHERE namespace = ?2
1372 ON CONFLICT (namespace, month) DO UPDATE SET
1373 requests = requests + excluded.requests,
1374 cpu_ms = cpu_ms + excluded.cpu_ms,
1375 peak_apps = MAX(peak_apps, excluded.peak_apps),
1376 peak_domains = MAX(peak_domains, excluded.peak_domains),
1377 build_seconds = build_seconds + excluded.build_seconds,
1378 build_micros = build_micros + excluded.build_micros,
1379 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1380 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1381 )
1382 .bind(current, slug),
1383 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1384 );
1385 }
1386 await this.db.batch(statements);
1387
1388 // Each app again, under its new name. Its dependencies' addresses are
1389 // 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 quotas1390 const followed = await this.followMoves(projectIds, {
1391 projects,
1392 adjust: (project) => this.underSlug(project, current, stale),
1393 });
1394 if (followed.failed.length > 0) {
1395 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1396 }
1397 }
1398
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1399 /**
1400 * A repository moved: transferred to another workspace, and its projects
1401 * with it, or renamed within its own, when its own project's slug follows
1402 * its name (see the projects service). Each app's name is
1403 * `<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 quotas1404 * slug changed (production and every preview, paused or not) are built
1405 * again, from the same commit, under the new name; see `followMoves`. As
1406 * after a workspace rename, the old name keeps serving until the new one
1407 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1408 * The project's custom domains follow its production. Builds under way
1409 * are started again under the new name. What the apps used this month
1410 * stays on the old workspace's meter; from now on, the new workspace's
1411 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1412 *
1413 * Projects whose name did not change (a rename where the new name was
1414 * 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 quotas1415 * path. What is left to rebuild is read from the rows each time, so a
1416 * second or late delivery builds only what the first could not, and one
1417 * whose rebuild could not be queued is delivered again (and, past the
1418 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1419 */
1420 private async moved(move: RepoMove, attempts: number): Promise<void> {
1421 const current = await currentMovedPath(this.env.REPOS, move);
1422 if (staleMovedPaths(move, current).length === 0) return;
1423 const [workspace, name] = current.split("/") as [string, string];
1424 const settings = await this.db
1425 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1426 .bind(move.repoId)
1427 .all<{ project_id: string; workspace: string; slug: string }>();
1428 if (settings.results.length === 0) return;
1429
1430 // The projects service hears of the move on its own queue: wait for it
1431 // a few deliveries, so builds read the project where and as it is now.
1432 const projects = new Map<string, Project>();
1433 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1434 const behind = settings.results.some(({ project_id }) => {
1435 const project = projects.get(project_id);
1436 if (!project || project.source.kind !== "hosted") return false;
1437 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1438 });
1439 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1440
1441 // Its history goes with it, as the repository's issues do.
1442 const statements: D1PreparedStatement[] = [
1443 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1444 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1445 // The projects whose apps' names change, and have not been moved yet.
1446 const moving = settings.results
1447 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1448 .map((row) => row.project_id);
1449 if (moving.length > 0) {
1450 const ids = moving.map(() => "?").join(", ");
1451 statements.push(
1452 this.db
1453 .prepare(
1454 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1455 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1456 )
1457 .bind(MOVED_ERROR, now(), ...moving),
1458 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1459 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1460 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1461 const slug = projects.get(projectId)?.slug ?? null;
1462 statements.push(
1463 this.db
1464 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1465 .bind(workspace, slug, projectId),
1466 this.db
1467 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1468 .bind(workspace, slug, projectId),
1469 this.db
1470 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1471 .bind(workspace, slug, projectId),
1472 // Within the workspace its apps are listed under the project's name
1473 // now. One left behind in another workspace stays as it is until the
1474 // new name is live and redirects it.
1475 this.db
1476 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1477 .bind(workspace, slug, projectId),
1478 );
1479 }
1480 await this.db.batch(statements);
1481
1482 // Each app again, under its new name. App rows under the old name stay
1483 // 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 quotas1484 const followed = await this.followMoves(
1485 settings.results.map((row) => row.project_id),
1486 {
1487 projects,
1488 adjust: (found) =>
1489 found.source.kind === "hosted"
1490 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1491 : { ...found, workspace },
1492 },
1493 );
1494 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1495 }
1496
1497 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1498 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1499 const projects = new Map<string, Project>();
1500 if (projectIds.length === 0) return projects;
1501 const repoIds = new Set<string>();
1502 for (let i = 0; i < projectIds.length; i += 50) {
1503 const chunk = projectIds.slice(i, i + 50);
1504 const rows = await this.db
1505 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1506 .bind(...chunk)
1507 .all<{ repo_id: string }>();
1508 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1509 }
1510 const wanted = new Set(projectIds);
1511 for (const repoId of repoIds) {
1512 for (const project of await this.projects.byRepo(repoId)) {
1513 if (wanted.has(project.id)) projects.set(project.id, project);
1514 }
1515 }
1516 return projects;
1517 }
1518
1519 /** Whether `script` is the name an app of the project has where the project is now. */
1520 private async namedNow(
1521 script: string,
1522 settings: { project_id: string; workspace: string; slug: string },
1523 branch: string | null,
1524 ): Promise<boolean> {
1525 const base = await label(settings.workspace, settings.slug, branch);
1526 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1527 }
1528
1529 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1530 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1531 const stale: AppRow[] = [];
1532 for (const app of apps) {
1533 const row = settings.get(app.project_id);
1534 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1535 }
1536 return stale;
1537 }
1538
1539 /**
1540 * Brings the apps of the projects `projectIds` (every project when null)
1541 * under the names they have where the projects are now: each app still
1542 * under an older name, paused or not, and each build a move dropped, is
1543 * built again under its new name from the same commit, and once that is
1544 * live the old name redirects to it (`supersede`).
1545 *
1546 * Idempotent, and safe to run again at any time: an app already up under
1547 * its new name only has its old names redirected; one whose rebuild is
1548 * queued or under way is left to it; one whose workspace has no
1549 * Deployments or is over its limit waits, without a refused deployment
1550 * each time; with `backoff` (the sweep), one whose rebuild was tried
1551 * within `MOVE_RETRY_MS` waits. Returns what could not be queued, for an
1552 * event's delivery to be retried.
1553 */
1554 private async followMoves(
1555 projectIds: string[] | null,
1556 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1557 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1558 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1559 if (projectIds && projectIds.length === 0) return result;
1560 const cloudflare = this.cloudflare;
1561 if (!cloudflare) return result;
1562
1563 const settingsRows =
1564 projectIds == null
1565 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1566 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1567 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1568 if (settings.size === 0) return result;
1569 const ids = [...settings.keys()];
1570
1571 const apps: AppRow[] = [];
1572 const dropped: DroppedBuild[] = [];
1573 for (let i = 0; i < ids.length; i += 50) {
1574 const chunk = ids.slice(i, i + 50);
1575 const marks = chunk.map(() => "?").join(", ");
1576 const [appRows, droppedRows] = await Promise.all([
1577 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1578 // Builds a move dropped, that nothing has been built in place of since.
1579 this.db
1580 .prepare(
1581 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1582 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1583 AND NOT EXISTS (
1584 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1585 AND n.created_at > d.created_at AND n.status != 'skipped'
1586 )`,
1587 )
1588 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1589 .all<DeploymentRow>(),
1590 ]);
1591 apps.push(...appRows.results);
1592 dropped.push(...droppedRows.results);
1593 }
1594 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1595 if (targets.length === 0) return result;
1596
1597 const projects = new Map(options.projects ?? []);
1598 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1599 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1600 const billing = billingClient(this.env.BILLING);
1601 const open = new Map<string, boolean>();
1602 const actors = new Map<string, User | null>();
1603
1604 for (const target of targets) {
1605 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1606 const found = projects.get(target.projectId);
1607 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1608 const project = options.adjust ? options.adjust(found) : found;
1609 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1610 // Built only where deployments has the project now; the projects
1611 // service catches up on its own queue, and a later run builds then.
1612 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1613 result.waiting++;
1614 continue;
1615 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1616 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1617 const script = await this.scriptFor(project, target.branch);
1618 // Already up under its new name: only its old names are left to redirect.
1619 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1620 if (up) {
1621 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1622 continue;
1623 }
1624 const last = await this.db
1625 .prepare("SELECT status, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT 1")
1626 .bind(script)
1627 .first<{ status: string; created_at: string }>();
1628 if (last && (last.status === "queued" || last.status === "building")) {
1629 result.queued++;
1630 continue;
1631 }
1632 if (options.backoff && !retryDue(last?.created_at ?? null, Date.now(), MOVE_RETRY_MS)) {
1633 result.waiting++;
1634 continue;
1635 }
1636 // A workspace without Deployments, or over its limit, waits for it
1637 // rather than gathering refused deployments.
1638 if (!open.has(project.workspace)) {
1639 const [plan, limit] = await Promise.all([
1640 billing.hasFeature(project.workspace, "deployments"),
1641 billing.checkLimit(project.workspace),
1642 ]);
1643 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1644 }
1645 if (!open.get(project.workspace)) {
1646 result.waiting++;
1647 continue;
1648 }
1649 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 look1650 const started =
1651 target.kind === "production"
1652 ? await this.deployProduction(project, target.commit, "g1t")
1653 : target.number != null
1654 ? 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 quotas1655 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1656 const outcome = rebuildOutcome(started);
1657 if (outcome === "queued") result.queued++;
1658 else if (outcome === "failed") {
1659 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1660 result.failed.push(`${named}: ${why}`);
1661 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1662 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1663 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1664 }
1665 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1666 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1667 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1668 }
1669
1670 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1671 private async projectIdsFor(repoId: string): Promise<string[]> {
1672 const rows = await this.db
1673 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1674 .bind(repoId)
1675 .all<{ project_id: string }>();
1676 return rows.results.map((row) => row.project_id);
1677 }
1678
1679 /**
1680 * A repository was deleted, restorable for a while: every app of its
1681 * projects (production and previews) comes down, builds under way are
1682 * dropped, and nothing builds for it until it is restored. Its settings
1683 * and custom domains are kept for the restore; until then a domain has
1684 * nothing up to serve, as when production is turned off.
1685 */
1686 private async repoDeleted(repoId: string): Promise<void> {
1687 const projectIds = await this.projectIdsFor(repoId);
1688 if (projectIds.length === 0) return;
1689 const at = now();
1690 await this.db.batch([
1691 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1692 this.db
1693 .prepare(
1694 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1695 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1696 )
1697 .bind(at, repoId),
1698 ]);
1699 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1700 }
1701
1702 /**
1703 * A deleted repository is back: production goes up again from its
1704 * default branch, for each project that has it on. Previews come back
1705 * with the next push to their pull requests.
1706 */
1707 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1708 const deleted = await this.db
1709 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1710 .bind(repoId)
1711 .all<{ project_id: string }>();
1712 const ids = deleted.results.map((row) => row.project_id);
1713 if (ids.length === 0) return;
1714 // The projects service hears of the restore on its own queue, and hides
1715 // the projects until then: wait for it a few deliveries.
1716 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1717 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1718 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1719 for (const project of projects) {
1720 try {
1721 const started = await this.deployProduction(project, null, "g1t");
1722 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1723 } catch (error) {
1724 console.error("could not deploy after restore", project.slug, error);
1725 }
1726 }
1727 }
1728
1729 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1730 * A workspace was deleted, restorable by g1t's staff for a while: every
1731 * app of its projects (production and previews) is paused, answering with
1732 * a notice and running nothing, builds under way are dropped, and nothing
1733 * builds for it until it is restored. Nothing is taken down: scripts,
1734 * settings and custom domains are kept for the restore. Never for a
1735 * protected workspace, whatever was published.
1736 */
1737 private async workspaceDeleting(slug: string): Promise<void> {
1738 const workspace = slug.toLowerCase();
1739 if (isProtectedWorkspace(workspace)) {
1740 console.error("workspace.deleting ignored for protected", workspace);
1741 return;
1742 }
1743 const at = now();
1744 await this.db.batch([
1745 this.db
1746 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1747 .bind(at, workspace),
1748 this.db
1749 .prepare(
1750 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1751 WHERE workspace = ? AND status IN ('queued', 'building')`,
1752 )
1753 .bind(at, workspace),
1754 ]);
1755 const cloudflare = this.cloudflare;
1756 for (const app of await this.appsOfWorkspace(workspace)) {
1757 if (app.paused_at) continue;
1758 await cloudflare?.pauseScript(app.script);
1759 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1760 }
1761 }
1762
1763 /**
1764 * A deleted workspace is back: it builds again, and its paused apps are
1765 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1766 * (the sweep tries again any it could not).
1767 */
1768 private async workspaceRestored(slug: string): Promise<void> {
1769 const workspace = slug.toLowerCase();
1770 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1771 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1772 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1773 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1774 }
1775
1776 /**
1777 * A deleted workspace is purged: whatever its projects still have up
1778 * comes down and their custom domains go, as for a purged repository.
1779 * Its own repositories' projects are purged with them (`repo.purged`);
1780 * this catches any building from a repository it had transferred away.
1781 */
1782 private async workspacePurged(slug: string): Promise<void> {
1783 const workspace = slug.toLowerCase();
1784 if (isProtectedWorkspace(workspace)) return;
1785 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1786 for (const { project_id } of rows.results) {
1787 await this.takeDownWhere(project_id, null);
1788 await this.domains.removeWhere("project_id", project_id);
1789 }
1790 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1791 await this.db.batch([
1792 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1793 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1794 ]);
1795 }
1796
1797 /** The apps of a workspace's projects, and any still under its name. */
1798 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1799 const rows = await this.db
1800 .prepare(
1801 `SELECT * FROM apps WHERE workspace = ?1
1802 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1803 )
1804 .bind(workspace)
1805 .all<AppRow>();
1806 return rows.results;
1807 }
1808
1809 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1810 * A deleted repository is gone for good: its projects' custom domains are
1811 * removed (from the dispatcher and from Cloudflare), any app or redirect
1812 * still up comes down, and every row kept for them goes. What they used
1813 * stays on their workspace's meter.
1814 */
1815 private async repoPurged(repoId: string): Promise<void> {
1816 const projectIds = await this.projectIdsFor(repoId);
1817 const domains = this.domains;
1818 for (const projectId of projectIds) {
1819 await this.takeDownWhere(projectId, null);
1820 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1821 await domains.removeWhere("project_id", projectId);
1822 }
1823 // The redirects left at names its apps had before.
1824 const scripts = await this.db
1825 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1826 .bind(repoId)
1827 .all<{ script: string }>();
1828 const hosts = scripts.results.map(({ script }) => appHost(script));
1829 for (let i = 0; i < hosts.length; i += 50) {
1830 const chunk = hosts.slice(i, i + 50);
1831 const redirects = await this.db
1832 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1833 .bind(...chunk)
1834 .all<{ script: string }>();
1835 for (const { script } of redirects.results) {
1836 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1837 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 look1838 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1839 }
1840 }
1841 await this.db.batch([
1842 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1843 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1844 ]);
1845 }
1846
1847 /**
1848 * The default branch is another one now: production is built from it, as
1849 * from a push to it, unless production already serves (or is building)
1850 * its commit, as when the default branch was only renamed.
1851 */
1852 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1853 for (const found of await this.projects.byRepo(repoId)) {
1854 if (found.source.kind !== "hosted") continue;
1855 // Projects may not have heard yet: the event names the branch.
1856 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1857 const actor = await this.workspaceActor(project.workspace);
1858 if (!actor) continue;
1859 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1860 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1861 if (!head) continue;
1862 const same = await this.db
1863 .prepare(
1864 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1865 AND status IN ('queued', 'building', 'ready')`,
1866 )
1867 .bind(project.id, head)
1868 .first();
1869 if (same) continue;
1870 await this.deployProduction(project, head, createdBy);
1871 }
1872 }
1873
1874 /**
1875 * A branch was renamed: its preview is the same app, so its rows follow.
1876 * The app keeps its name until it is next built; then it goes up under
1877 * the new branch's name, and the old one redirects there (see `supersede`).
1878 */
1879 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1880 const projectIds = await this.projectIdsFor(repoId);
1881 if (projectIds.length === 0) return;
1882 const ids = projectIds.map(() => "?").join(", ");
1883 await this.db.batch([
1884 this.db
1885 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1886 .bind(to, from, ...projectIds),
1887 this.db
1888 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1889 .bind(to, from, ...projectIds),
1890 ]);
1891 }
1892
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1893 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1894 private underSlug(project: Project, current: string, stale: string[]): Project {
1895 const source =
1896 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1897 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1898 : project.source;
1899 return { ...project, workspace: current, source };
1900 }
1901
1902 /** A stack's preview (no pull request of its own) built again at `commit`. */
1903 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1904 if (!actor || branch == null) return null;
1905 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1906 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1907 return this.start({
1908 project,
1909 kind: "preview",
1910 branch,
1911 number: null,
1912 commit,
1913 source: repoOf(project).path,
1914 reader: actor,
1915 createdBy: "g1t",
1916 settings,
1917 // As `stack` built it: the project's own default branch.
1918 trusted: true,
1919 });
1920 }
1921
Deployments: a preview for every pull request, production on g1t.page1922 // ---- The sweep -----------------------------------------------------
1923
1924 /**
1925 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1926 * previews, the apps of workspaces whose plan ended, and scripts no app
1927 * holds come down; and a month that is over is charged past its
1928 * allowance.
Deployments: a preview for every pull request, production on g1t.page1929 */
1930 async sweep(): Promise<void> {
1931 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1932 const stuck = await this.db
1933 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1934 .bind(cutoff)
1935 .all<{ id: string }>();
1936 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1937
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1938 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1939 // Each app is its project's workspace's, as deployments has it now: an
1940 // app still under the name it had before its project moved is the new
1941 // workspace's, and plans and limits are checked there.
1942 const settings = new Map(
1943 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
1944 );
1945 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1946 // A deleted workspace's apps stay paused as they are, for a restore:
1947 // its plan ended with the deletion, and that must not take them down.
1948 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
1949 const live = apps.filter((app) => !held(app));
1950 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page1951
1952 // Apps of workspaces whose plan has ended come down.
1953 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1954 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1955 for (const workspace of workspaces) {
1956 const plan = await billing.hasFeature(workspace, "deployments");
1957 if (!plan.ok && plan.error.code === "payment_required") {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1958 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1959 // Custom domains cost g1t by the month: they go with the plan.
1960 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.page1961 }
1962 }
1963
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1964 // Apps whose project moved and are not up under the new name yet: a
1965 // move's rebuild that could not start is tried again here.
1966 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
1967 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1968
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1969 await this.domains
1970 .sweep(async (projectId) => {
1971 const app = await this.db
1972 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1973 .bind(projectId)
1974 .first<{ script: string }>();
1975 return app?.script ?? null;
1976 })
1977 .catch((error) => console.error("could not check domains", error));
1978
Projects: what a workspace builds and runs, first on every page1979 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page1980 await this.count(apps).catch((error) => console.error("could not count usage", error));
1981 await this.takeDownIdle();
1982 await this.chargeMonths();
1983 }
1984
Usage limits: unpaid usage can only go so far1985 /**
1986 * Pauses the apps of workspaces that reached their limit for usage not
1987 * yet paid for, and rebuilds them from the same commit once they are
1988 * 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 quotas1989 *
1990 * The workspace is the project's now (see `ownerOf`), never the one an
1991 * app's row was written under, so an app left under its old name after
1992 * a transfer is paused only if its new workspace is over its limit. Such
1993 * an app is resumed by being built under its new name (`followMoves`),
1994 * not here.
Usage limits: unpaid usage can only go so far1995 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1996 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far1997 const cloudflare = this.cloudflare;
1998 if (!cloudflare) return;
1999 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2000 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2001 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2002 const limit = await billing.checkLimit(workspace);
2003 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2004 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2005 if (limit.value.state === "stopped") {
2006 for (const app of theirs.filter((a) => !a.paused_at)) {
2007 await cloudflare.pauseScript(app.script);
2008 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2009 }
2010 continue;
2011 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2012 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2013 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2014 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2015 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2016 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2017 const project = projects.get(app.project_id);
2018 if (!project) continue;
Usage limits: unpaid usage can only go so far2019 // A failed or refused rebuild leaves it paused, to try again next time.
2020 const rebuilt =
2021 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2022 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2023 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2024 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2025 : null;
2026 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2027 }
2028 }
2029 }
2030
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2031 /**
2032 * Scripts in the namespace that no app holds, such as ones renamed. An
2033 * old address that redirects to its app's new one is held until its
2034 * redirect expires, then removed with the rest.
2035 */
Projects: what a workspace builds and runs, first on every page2036 private async removeOrphans(apps: AppRow[]): Promise<void> {
2037 const cloudflare = this.cloudflare;
2038 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2039 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2040 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2041 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2042 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 page2043 const building = await this.db
2044 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2045 .all<{ script: string }>();
2046 for (const row of building.results) held.add(row.script);
2047 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2048 for (const script of await cloudflare.listScripts()) {
2049 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2050 }
2051 }
2052
Deployments: a preview for every pull request, production on g1t.page2053 /** Counts this month's requests and CPU time per workspace, from analytics. */
2054 private async count(apps: AppRow[]): Promise<void> {
2055 const cloudflare = this.cloudflare;
2056 if (!cloudflare || apps.length === 0) return;
2057 const start = `${month()}-01T00:00:00Z`;
2058 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2059 // Analytics only counts apps that are up; the meter keeps what earlier
2060 // apps used by never going down.
2061 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2062 for (const app of apps) {
2063 const used = totals.get(app.script);
2064 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2065 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2066 sum.requests += used.requests;
2067 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2068 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2069 }
2070 const at = now();
Projects: what a workspace builds and runs, first on every page2071 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page2072 await this.db
2073 .prepare(
2074 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2075 ON CONFLICT (namespace, month) DO UPDATE SET
2076 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2077 )
Projects: what a workspace builds and runs, first on every page2078 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2079 .run();
2080 }
2081 // When each preview last answered anyone, for the idle sweep.
2082 const recent = await cloudflare.usage(
2083 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2084 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2085 at,
2086 );
2087 for (const [script, used] of recent) {
2088 if (used.requests > 0) {
2089 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2090 }
2091 }
Projects: what a workspace builds and runs, first on every page2092 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 quotas2093 // What this month's traffic and custom domains will cost, from the
2094 // first request and the first domain, so the workspace's limit counts
2095 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2096 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2097 const billing = billingClient(this.env.BILLING);
2098 const meters = await this.db
2099 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2100 .bind(month())
2101 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2102 for (const meter of meters.results) {
2103 const cost = monthCost(meter, costs);
2104 await billing
2105 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2106 .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 quotas2107 if ((meter.peak_domains ?? 0) > 0) {
2108 await billing
2109 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2110 .catch((error) => console.error("could not note pending usage", error));
2111 }
Prices keep themselves current with what g1t pays2112 }
Deployments: a preview for every pull request, production on g1t.page2113 }
2114
Projects: what a workspace builds and runs, first on every page2115 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2116 private async takeDownIdle(): Promise<void> {
2117 const idle = await this.db
2118 .prepare(
Projects: what a workspace builds and runs, first on every page2119 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2120 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2121 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2122 )
2123 .all<{ script: string }>();
2124 for (const { script } of idle.results) await this.removeApp(script);
2125 }
2126
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2127 /** 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.page2128 private async chargeMonths(): Promise<void> {
2129 const due = await this.db
2130 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2131 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2132 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2133 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2134 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2135 const cost = monthCost(meter, costs);
2136 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2137 const charged = await billingClient(this.env.BILLING).chargeFeature({
2138 workspace: meter.namespace,
2139 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2140 costMicros: cost.micros,
2141 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2142 reference: `deployments/${meter.namespace}/${meter.month}`,
2143 });
2144 if (!charged.ok) continue;
2145 }
2146 await this.db
2147 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2148 .bind(now(), meter.namespace, meter.month)
2149 .run();
2150 }
2151 }
2152}
2153
2154/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2155async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2156 switch (method) {
2157 case "settings":
2158 return service.settings(args);
2159 case "update_settings":
2160 return service.updateSettings(args);
2161 case "list":
2162 return service.list(args);
2163 case "get":
2164 return service.get(args);
2165 case "redeploy":
2166 return service.redeploy(args);
2167 case "take_down":
2168 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2169 case "stack":
2170 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2171 case "overview":
2172 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2173 case "usage":
2174 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2175 case "domains":
2176 return service.listDomains(args);
2177 case "add_domain":
2178 return service.addDomain(args);
2179 case "remove_domain":
2180 return service.removeDomain(args);
2181 case "refresh_domain":
2182 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page2183 default:
2184 return undefined;
2185 }
2186}
2187
2188export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2189 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2190 const { pathname } = new URL(request.url);
2191 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2192 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2193 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2194 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2195 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2196 const opened = openD1(env.DB, request);
2197 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2198 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2199 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.page2200 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2201 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2202 // A build's reports, forwarded by the API.
2203 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2204 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2205 return new Response("Not found\n", { status: 404 });
2206 },
2207
2208 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2209 const service = new Deployments(env);
2210 for (const message of batch.messages) {
2211 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2212 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2213 message.ack();
2214 } catch (error) {
2215 console.error("deployments could not handle", message.body.type, error);
2216 message.retry();
2217 }
2218 }
2219 },
2220
2221 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2222 await new Deployments(env).sweep();
2223 },
2224} satisfies ExportedHandler<Env, G1tEvent>;