Skip to content

g1t/services/deployments/src/index.ts

2,287 lines102,105 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,
Events: review requests, assignments, stops and deployments are published43 eventsClient,
Deployments: a preview for every pull request, production on g1t.page44 fail,
45 identityClient,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member46 isProtectedWorkspace,
Deployments: a preview for every pull request, production on g1t.page47 newId,
48 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents49 openD1,
Projects: what a workspace builds and runs, first on every page50 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look51 repoMove,
Deployments: a preview for every pull request, production on g1t.page52 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains54 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page55 workClient,
56 type DeployKind,
57 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page59 type DeployStatus,
60 type DeployUsage,
61 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains62 type Domain,
Deployments: a preview for every pull request, production on g1t.page63 type G1tEvent,
64 type LiveApp,
Projects: what a workspace builds and runs, first on every page65 type Project,
66 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains68 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page69 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights70 workOwner,
Deployments: a preview for every pull request, production on g1t.page71 type RepoPath,
72 type Result,
73 type ServiceBinding,
74 type User,
75 type Viewer,
76} from "@g1t/contracts";
77
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights78import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page79import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains80import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas81import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
82import { monthCost } from "./metering";
83import { moveTargets, ownerOf, rebuildOutcome, retryDue, type DroppedBuild, type MoveTarget } from "./moves";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains84import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page85
86type Env = {
87 DB: D1Database;
88 REPOS: ServiceBinding;
89 WORK: ServiceBinding;
90 IDENTITY: ServiceBinding;
91 BILLING: ServiceBinding;
92 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page93 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments94 /** Secrets and variables: the actions service holds the one store. */
95 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published96 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
97 EVENTS?: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page98 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
99 CLOUDFLARE_API_TOKEN?: string;
100 CLOUDFLARE_ACCOUNT_ID: string;
101 DISPATCH_NAMESPACE: string;
102 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains103 /** Custom domains: hostname to app, read by the dispatcher. */
104 DOMAINS?: KVNamespace;
105 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
106 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look107 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
108 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page109};
110
111/** A build that has not reported in this long has died. */
112const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page113/** A script in the namespace that no app holds, older than this, is removed. */
114const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page115const LIST_LIMIT = 50;
116const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains117/**
118 * Deliveries of `workspace.renamed` that wait for the projects service to
119 * have seen it too, before going ahead with the new slug regardless.
120 */
121const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas122/** Why a build under way was dropped by a move; the move builds it again under the new name. */
123const MOVED_ERROR = "The repository moved: built again under its new name.";
124const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
125/** A move's rebuild that could not start is tried again by the sweep after this long. */
126const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page127
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look128/** A build's report of what it found the project to be, if it is one g1t knows. */
129export function detectedKind(value: unknown): DetectedKind | null {
130 return value === "workers" || value === "static" || value === "html" ? value : null;
131}
132
Deployments: a preview for every pull request, production on g1t.page133const now = () => new Date().toISOString();
134const month = (at = new Date()) => at.toISOString().slice(0, 7);
135
136async function sha256(text: string): Promise<string> {
137 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
138 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
139}
140
141function randomToken(): string {
142 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
143}
144
145function isMember(viewer: Viewer, slug: string): boolean {
146 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
147}
148
Projects: what a workspace builds and runs, first on every page149/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look150/** One compute gate per isolate, so entitlements and prices are kept between calls. */
151let computeGate: ComputeGate | null = null;
152function gateFor(billing: ServiceBinding): ComputeGate {
153 computeGate ??= new ComputeGate(billing);
154 return computeGate;
155}
156
157/** The longest a build may run, in minutes: as long as its read token lasts. */
158const BUILD_MINUTES = 30;
159
160/**
161 * A build that was skipped before it started (its plan, its limit, or
162 * billing's refusal) as a failure, so whoever asked for it sees why.
163 */
164function notStarted(result: Result<Deployment>): Result<Deployment> {
165 if (result.ok && result.value.status === "skipped" && result.value.error) {
166 return fail("payment_required", result.value.error);
167 }
168 return result;
169}
170
Projects: what a workspace builds and runs, first on every page171function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
172 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
173 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
174}
175
Deployments: a preview for every pull request, production on g1t.page176type SettingsRow = {
Projects: what a workspace builds and runs, first on every page177 project_id: string;
178 workspace: string;
179 slug: string;
Deployments: a preview for every pull request, production on g1t.page180 repo_id: string;
181 enabled: number;
182 previews: number;
183 production: number;
184 build_command: string | null;
185 output_dir: string | null;
186 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look187 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
188 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member189 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
190 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page191};
192
193type DeploymentRow = {
194 id: string;
Projects: what a workspace builds and runs, first on every page195 project_id: string;
196 workspace: string;
197 slug: string;
Deployments: a preview for every pull request, production on g1t.page198 repo_id: string;
Projects: what a workspace builds and runs, first on every page199 repo: string;
Deployments: a preview for every pull request, production on g1t.page200 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page201 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page202 number: number | null;
203 commit_sha: string;
204 script: string;
205 status: DeployStatus;
206 error: string | null;
207 warnings: string;
208 log: string | null;
209 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments210 trusted: number;
Deployments: a preview for every pull request, production on g1t.page211 build_seconds: number | null;
212 created_by: string;
213 created_at: string;
214 finished_at: string | null;
215};
216
217type AppRow = {
218 script: string;
Projects: what a workspace builds and runs, first on every page219 project_id: string;
220 workspace: string;
221 slug: string;
Deployments: a preview for every pull request, production on g1t.page222 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page223 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page224 number: number | null;
225 commit_sha: string;
226 deployed_at: string;
227 created_at: string;
228 last_request_at: string | null;
Usage limits: unpaid usage can only go so far229 /** Set while its workspace is over its limit; see `holdToLimits`. */
230 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page231};
232
233function toDeployment(row: DeploymentRow): Deployment {
234 return {
235 id: row.id,
236 kind: row.kind,
Projects: what a workspace builds and runs, first on every page237 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page238 number: row.number,
239 commit: row.commit_sha,
240 status: row.status,
241 url: appUrl(row.script),
242 error: row.error,
243 warnings: JSON.parse(row.warnings || "[]") as string[],
244 buildSeconds: row.build_seconds,
245 createdBy: row.created_by,
246 createdAt: row.created_at,
247 finishedAt: row.finished_at,
248 };
249}
250
Projects: what a workspace builds and runs, first on every page251function toLive(app: AppRow): LiveApp {
252 return {
253 kind: app.kind,
254 branch: app.branch,
255 number: app.number,
256 url: appUrl(app.script),
257 commit: app.commit_sha,
258 deployedAt: app.deployed_at,
259 };
260}
261
Deployments: a preview for every pull request, production on g1t.page262class Deployments {
263 constructor(private readonly env: Env) {}
264
265 private get cloudflare(): Cloudflare | null {
266 const token = this.env.CLOUDFLARE_API_TOKEN;
267 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
268 }
269
270 private get db() {
271 return this.env.DB;
272 }
273
Agents and memory, checks and conflicts, profiles, slug renames, custom domains274 private get domains(): Domains {
275 const token = this.env.CLOUDFLARE_API_TOKEN;
276 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
277 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
278 }
279
Projects: what a workspace builds and runs, first on every page280 private get projects() {
281 return projectsClient(this.env.PROJECTS);
282 }
283
Deployments: a preview for every pull request, production on g1t.page284 /** The workspace itself, as the service acts for it. */
285 private async workspaceActor(slug: string): Promise<User | null> {
286 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
287 if (!workspace) return null;
288 return {
289 id: workspace.id,
290 username: workspace.slug,
291 kind: "workspace",
292 verified: true,
293 workspaces: [{ slug: workspace.slug, role: "member" }],
294 };
295 }
296
Secrets and variables: one list, rows per environment, for workflows and deployments297 /**
Projects: what a workspace builds and runs, first on every page298 * What the project's secrets and variables available to deployments give
299 * production or a preview: its build's environment, and the same again as
300 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments301 */
302 private async resolve(
Projects: what a workspace builds and runs, first on every page303 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments304 environment: DeployKind,
305 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know306 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments307 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know308 const [rows, references] = await Promise.all([
309 this.rows(project, environment, trusted),
310 this.references(project.id, environment === "preview" ? branch : null),
311 ]);
312 // The project's own rows win over a dependency's address of the same name.
313 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
314 }
315
316 /**
317 * Each dependency's address, under the name the dependency gives it:
318 * for a preview, the same branch's preview of it if one is up, else its
319 * production; for production, its production.
320 */
321 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
322 const graph = await this.projects.graph(projectId).catch(() => null);
323 const out: Record<string, string> = {};
324 for (const dependency of graph?.dependsOn ?? []) {
325 if (!dependency.as) continue;
326 const app =
327 (branch
328 ? await this.db
329 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
330 .bind(dependency.id, branch)
331 .first<{ script: string }>()
332 : null) ??
333 (await this.db
334 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
335 .bind(dependency.id)
336 .first<{ script: string }>());
337 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
338 }
339 return out;
340 }
341
342 private async rows(
343 project: { id: string; slug: string; repoId: string; repo: RepoPath },
344 environment: DeployKind,
345 trusted: boolean,
346 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments347 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
348 method: "POST",
349 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page350 body: JSON.stringify({
351 repoId: project.repoId,
352 repo: project.repo,
353 projectId: project.id,
354 projectSlug: project.slug,
355 consumer: "deployments",
356 environment,
357 trusted,
358 }),
Secrets and variables: one list, rows per environment, for workflows and deployments359 });
360 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
361 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
362 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
363 }
364
365 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights366 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
367 * it, or its author) is trusted with the project's secrets: g1t itself,
368 * or someone who can push to the repository (Write or more, a member's or
369 * a collaborator's). Anyone else gets a preview built without them, as
370 * their workflows run. A change g1t made for someone is trusted as they
371 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments372 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights373 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
374 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look375 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 rights376 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look377 .catch(() => null);
378 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments379 }
380
Projects: what a workspace builds and runs, first on every page381 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
382 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page383 }
384
Projects: what a workspace builds and runs, first on every page385 /** The name an app gets, unique among apps: production, or a branch's preview. */
386 private async scriptFor(project: Project, branch: string | null): Promise<string> {
387 const base = await label(project.workspace, project.slug, branch);
388 const holder = await this.db
389 .prepare(
390 `SELECT project_id, branch FROM apps WHERE script = ?1
391 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
392 )
393 .bind(base)
394 .first<{ project_id: string; branch: string | null }>();
395 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
396 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
397 }
398
399 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page400 return {
401 enabled: !!row?.enabled,
402 previews: row ? !!row.previews : true,
403 production: row ? !!row.production : true,
404 buildCommand: row?.build_command ?? null,
405 outputDir: row?.output_dir ?? null,
406 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page407 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains408 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look409 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page410 };
411 }
412
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look413 /** What the project's last finished build found it to be; null before one has. */
414 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
415 const row = await this.db
416 .prepare(
417 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
418 )
419 .bind(projectId)
420 .first<{ detected: string }>()
421 .catch(() => null);
422 return detectedKind(row?.detected);
423 }
424
425 /**
426 * The project, if `viewer` may do what `method` needs on its repository
427 * (see `NEEDS`): not found when they cannot read it, refused when they can
428 * but their role is too low.
429 */
430 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
431 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
432 if (!found.ok) return found;
433 const repo = repoRef(found.value);
434 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
435 const capability = NEEDS[method];
436 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
437 return found;
Deployments: a preview for every pull request, production on g1t.page438 }
439
440 // ---- Methods for the site and the API ------------------------------
441
Projects: what a workspace builds and runs, first on every page442 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 look443 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page444 if (!project.ok) return project;
445 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page446 }
447
448 async updateSettings(a: {
449 actor: User;
Projects: what a workspace builds and runs, first on every page450 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page451 changes: Partial<DeploySettings>;
452 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look453 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page454 if (!found.ok) return found;
455 const project = found.value;
456 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page457 const next = { ...before, ...a.changes };
A project is an app or a library: libraries show their package and how to ship a release, not production458 if (next.enabled && !before.enabled && project.deploys === "no") {
459 return fail("conflict", "This project is set not to deploy. Change that in its General settings first.");
460 }
Deployments: a preview for every pull request, production on g1t.page461 if (next.enabled && !before.enabled) {
462 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page463 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page464 if (!plan.ok) return plan;
465 }
466 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
467 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
468 await this.db
469 .prepare(
Projects: what a workspace builds and runs, first on every page470 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
471 output_dir, idle_days, updated_by, updated_at)
472 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
473 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
474 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.page475 )
476 .bind(
Projects: what a workspace builds and runs, first on every page477 project.id,
478 project.workspace,
479 project.slug,
480 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page481 next.enabled ? 1 : 0,
482 next.previews ? 1 : 0,
483 next.production ? 1 : 0,
484 clip(next.buildCommand),
485 clip(next.outputDir),
486 idleDays,
487 a.actor.username,
488 now(),
489 )
490 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production491 // Projects keeps whether it deploys, so a project with Deployments on is an app.
492 if (next.enabled !== before.enabled) {
493 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
494 }
Deployments: a preview for every pull request, production on g1t.page495 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page496 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page497 else {
Projects: what a workspace builds and runs, first on every page498 if (!next.previews) await this.takeDownWhere(project.id, "preview");
499 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page500 }
501 // Turned on: production goes up from the default branch at once.
502 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page503 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page504 }
Projects: what a workspace builds and runs, first on every page505 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page506 }
507
A project is an app or a library: libraries show their package and how to ship a release, not production508 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
509 async isEnabled(a: { projectId: string }): Promise<boolean> {
510 return !!(await this.settingsRow(a.projectId))?.enabled;
511 }
512
Projects: what a workspace builds and runs, first on every page513 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 look514 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page515 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page516 const [deployments, apps] = await Promise.all([
517 this.db
Projects: what a workspace builds and runs, first on every page518 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
519 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page520 .all<DeploymentRow>(),
521 this.db
Projects: what a workspace builds and runs, first on every page522 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
523 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page524 .all<AppRow>(),
525 ]);
Projects: what a workspace builds and runs, first on every page526 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page527 }
528
Projects: what a workspace builds and runs, first on every page529 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 look530 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page531 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page532 const row = await this.db
Projects: what a workspace builds and runs, first on every page533 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
534 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page535 .first<DeploymentRow>();
536 if (!row) return fail("not_found", "No such deployment.");
537 return ok({ ...toDeployment(row), log: row.log });
538 }
539
Projects: what a workspace builds and runs, first on every page540 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 look541 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page542 if (!found.ok) return found;
543 const project = found.value;
544 const settings = await this.settingsRow(project.id);
545 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
546 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look547 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 page548 }
549 // A branch's preview comes from its pull request.
550 const app = await this.db
551 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
552 .bind(project.id, a.branch)
553 .first<{ number: number }>();
554 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 look555 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.page556 }
557
Project dependencies: addresses, preview stacks, Affects, and agents who know558 /**
559 * A preview stack: the projects that use this one get previews of their
560 * own default branch, under the same branch name, so each reaches this
561 * branch's preview through its dependency's variable. A change to an API
562 * can then be clicked through in the apps that call it.
563 */
564 async stack(
565 a: { actor: User; project: ProjectRef; branch: string },
566 background: (work: Promise<unknown>) => void,
567 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look568 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know569 if (!found.ok) return found;
570 const upstream = await this.db
571 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
572 .bind(found.value.id, a.branch)
573 .first();
574 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
575 const graph = await this.projects.graph(found.value.id);
576 const ready: { project: Project; settings: SettingsRow }[] = [];
577 for (const dependent of graph.usedBy) {
578 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 look579 // Each build spends compute on its own repository: only those the actor can run.
580 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know581 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look582 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know583 }
584 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
585 // The builds start after the answer: a person moving on from the page
586 // does not stop them.
587 background(
588 (async () => {
589 for (const { project, settings } of ready) {
590 const actor = await this.workspaceActor(project.workspace);
591 if (!actor) continue;
592 const repo = repoOf(project);
593 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
594 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
595 if (!head) continue;
596 await this.start({
597 project,
598 kind: "preview",
599 branch: a.branch,
600 number: null,
601 commit: head,
602 source: repo.path,
603 reader: actor,
604 createdBy: a.actor.username,
605 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look606 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know607 trusted: true,
608 });
609 }
610 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
611 );
612 return ok(ready.map(({ project }) => project.name));
613 }
614
Projects: what a workspace builds and runs, first on every page615 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 look616 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page617 if (!project.ok) return project;
618 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page619 return ok(true);
620 }
621
Projects: what a workspace builds and runs, first on every page622 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
623 const workspace = a.workspace.toLowerCase();
624 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 look625 // Only the projects whose repositories the viewer can read: the
626 // projects service lists no others.
627 const listed = await this.projects.list(workspace, a.viewer);
628 if (!listed.ok) return listed;
629 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page630 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look631 // A deleted repository's projects are hidden until it is restored.
632 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 page633 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
634 this.db
635 .prepare(
636 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
637 )
638 .bind(workspace)
639 .all<DeploymentRow>(),
640 ]);
641 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look642 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page643 const own = apps.results.filter((app) => app.slug === row.slug);
644 const production = own.find((app) => app.kind === "production");
645 const newest = latest.results.find((d) => d.slug === row.slug);
646 return {
647 slug: row.slug,
648 enabled: !!row.enabled,
649 production: production ? toLive(production) : null,
650 previews: own.filter((app) => app.kind === "preview").length,
651 latest: newest ? toDeployment(newest) : null,
652 };
653 }),
654 );
655 }
656
Deployments: a preview for every pull request, production on g1t.page657 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
658 const slug = a.workspace.toLowerCase();
659 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
660 const [meter, apps] = await Promise.all([
661 this.db
662 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
663 .bind(slug, month())
664 .first<{
665 requests: number;
666 cpu_ms: number;
667 peak_apps: number;
668 build_seconds: number;
669 build_micros: number;
670 counted_at: string | null;
671 }>(),
Projects: what a workspace builds and runs, first on every page672 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.page673 ]);
674 return ok({
675 month: month(),
676 requests: meter?.requests ?? 0,
677 cpuMs: meter?.cpu_ms ?? 0,
678 apps: apps?.n ?? 0,
679 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
680 buildSeconds: meter?.build_seconds ?? 0,
681 buildMicros: meter?.build_micros ?? 0,
682 countedAt: meter?.counted_at ?? null,
683 });
684 }
685
Agents and memory, checks and conflicts, profiles, slug renames, custom domains686 // ---- Custom domains ------------------------------------------------
687
688 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 look689 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains690 if (!project.ok) return project;
691 const domains = this.domains;
692 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 quotas693 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains694 domains.forProject(project.value.id),
695 domains.available(),
696 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas697 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains698 ]);
699 return ok({
700 domains: rows.map(toDomain),
701 target: CUSTOM_DOMAIN_TARGET,
702 available,
703 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas704 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains705 used,
706 });
707 }
708
709 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 look710 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains711 if (!found.ok) return found;
712 const project = found.value;
713 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
714 if (!plan.ok) return plan;
715 const added = await this.domains.add({
716 project: { id: project.id, workspace: project.workspace, slug: project.slug },
717 script: await this.productionScript(project),
718 hostname: String(a.hostname ?? ""),
719 twin: !!a.twin,
720 by: a.actor.username,
721 });
722 if (!added.ok) return fail(added.code, added.message);
723 await this.notePeak(project.workspace);
724 return ok(added.rows.map(toDomain));
725 }
726
727 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 look728 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains729 if (!found.ok) return found;
730 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
731 if (!row) return fail("not_found", "No such domain.");
732 await this.domains.remove(row);
733 return ok(true);
734 }
735
736 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 look737 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains738 if (!found.ok) return found;
739 const domains = this.domains;
740 const row = await domains.byId(found.value.id, String(a.id ?? ""));
741 if (!row) return fail("not_found", "No such domain.");
742 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
743 await this.notePeak(found.value.workspace);
744 return ok(toDomain(after));
745 }
746
747 /** The script production is up under, or the name it will have. */
748 private async productionScript(project: Project): Promise<string> {
749 const app = await this.db
750 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
751 .bind(project.id)
752 .first<{ script: string }>();
753 return app?.script ?? (await this.scriptFor(project, null));
754 }
755
Deployments: a preview for every pull request, production on g1t.page756 // ---- Starting builds -----------------------------------------------
757
758 /**
759 * Opens a deployment and starts its build. Skipped, with the reason
760 * recorded, when the workspace's plan is off.
761 */
762 private async start(input: {
Projects: what a workspace builds and runs, first on every page763 project: Project;
Deployments: a preview for every pull request, production on g1t.page764 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page765 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page766 number: number | null;
767 commit: string;
768 source: RepoPath;
769 reader: User;
770 createdBy: string;
771 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page772 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments773 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page774 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page775 const { project } = input;
776 const repo = repoOf(project);
777 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page778 const id = newId("dpl");
779 const token = randomToken();
Projects: what a workspace builds and runs, first on every page780 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far781 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page782 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look783 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page784 ? plan.error.message
Usage limits: unpaid usage can only go so far785 : limit.ok && limit.value.state === "stopped"
786 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page787 : !cloudflare
788 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
789 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look790 // A build is compute: reserved with billing before it starts, and
791 // settled by its sandbox when it stops. A refusal is the deployment's
792 // status, saying what to do.
793 const compute = gateFor(this.env.BILLING);
794 let reservation: string | null = null;
795 let microsPerSecond = 0;
796 let maxRunMinutes: number | null = null;
797 if (!refused) {
798 const ent = await compute.entitlements(project.workspace);
799 microsPerSecond = await compute.microsPerSecond();
800 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
801 const isPrivate = await reposClient(this.env.REPOS)
802 .get(repo.path, null)
803 .then((found) => !found.ok || found.value.isPrivate)
804 .catch(() => true);
805 const admitted = await compute.admit(
806 {
807 workspace: project.workspace,
808 repo: repo.path,
809 public: !isPrivate,
810 kind: "deploy",
811 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
812 },
813 ent,
814 );
815 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
816 else refused = admitted.message;
817 }
Deployments: a preview for every pull request, production on g1t.page818 await this.db
819 .prepare(
Projects: what a workspace builds and runs, first on every page820 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
821 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
822 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page823 )
824 .bind(
825 id,
Projects: what a workspace builds and runs, first on every page826 project.id,
827 project.workspace,
828 project.slug,
829 repo.id,
830 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page831 input.kind,
Projects: what a workspace builds and runs, first on every page832 input.branch,
Deployments: a preview for every pull request, production on g1t.page833 input.number,
834 input.commit,
835 script,
836 refused ? "skipped" : "queued",
837 refused,
838 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments839 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page840 input.createdBy,
841 now(),
842 refused ? now() : null,
843 )
844 .run();
845 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
846 // Older builds of the same app are replaced by this one.
847 await this.db
848 .prepare(
849 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
850 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
851 )
852 .bind(now(), script, id)
853 .run();
Projects: what a workspace builds and runs, first on every page854 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
855 // What the project's secrets and variables give builds of this kind.
856 const build = await this.resolve(
857 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
858 input.kind,
859 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know860 input.branch,
Projects: what a workspace builds and runs, first on every page861 );
Deployments: a preview for every pull request, production on g1t.page862 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
863 method: "POST",
864 headers: { "content-type": "application/json" },
865 body: JSON.stringify({
866 deployId: id,
867 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look868 workspace: project.workspace,
869 reservation,
870 microsPerSecond,
871 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page872 actor: input.reader,
873 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas874 // The project, whose guardrails the build runs under: a preview's
875 // source is its pull request's working copy, not the project.
876 repo: repo.path,
877 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page878 commit: input.commit,
Projects: what a workspace builds and runs, first on every page879 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page880 buildCommand: input.settings.build_command,
881 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments882 buildEnv: build.variables,
883 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page884 }),
885 });
886 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 look887 if (!started.ok) {
888 // Never reached a sandbox: what was reserved is given back.
889 if (reservation) await compute.settle(reservation, 0);
890 await this.finishFailed(id, started.error.message, null, null);
891 }
Deployments: a preview for every pull request, production on g1t.page892 return ok(toDeployment((await this.deploymentRow(id))!));
893 }
894
Projects: what a workspace builds and runs, first on every page895 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
896 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 member897 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 page898 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page899 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page900 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page901 let head = commit;
902 if (!head) {
Projects: what a workspace builds and runs, first on every page903 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
904 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page905 }
906 if (!head) return null;
907 return this.start({
Projects: what a workspace builds and runs, first on every page908 project,
Deployments: a preview for every pull request, production on g1t.page909 kind: "production",
Projects: what a workspace builds and runs, first on every page910 branch: null,
Deployments: a preview for every pull request, production on g1t.page911 number: null,
912 commit: head,
Projects: what a workspace builds and runs, first on every page913 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page914 reader: actor,
915 createdBy,
916 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments917 // The default branch only moves by people and agents with access.
918 trusted: true,
Deployments: a preview for every pull request, production on g1t.page919 });
920 }
921
Projects: what a workspace builds and runs, first on every page922 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
923 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 member924 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 page925 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page926 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page927 const repo = repoOf(project);
928 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page929 if (!detail.ok) return null;
930 const { pull } = detail.value;
931 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page932 // A pull request from a fork (as g1t's agents work) has no branch here.
933 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page934 if (!force) {
935 // Already built, or being built, at this commit.
936 const same = await this.db
937 .prepare(
Projects: what a workspace builds and runs, first on every page938 `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.page939 AND status IN ('queued', 'building', 'ready')`,
940 )
Projects: what a workspace builds and runs, first on every page941 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page942 .first();
943 if (same) return null;
944 }
945 return this.start({
Projects: what a workspace builds and runs, first on every page946 project,
Deployments: a preview for every pull request, production on g1t.page947 kind: "preview",
Projects: what a workspace builds and runs, first on every page948 branch,
Deployments: a preview for every pull request, production on g1t.page949 number,
950 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page951 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 rights952 // The pull request's fork may be private: read it as whoever it is
953 // for (whoever asked g1t for it, or its author), who is also who is
954 // trusted or not with the project's secrets.
955 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page956 createdBy,
957 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights958 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page959 });
960 }
961
962 // ---- A build's reports ---------------------------------------------
963
964 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
965 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
966 }
967
968 /** The build, if `token` is its own and it is still under way. */
969 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
970 const row = await this.deploymentRow(id);
971 if (!row?.token_hash || typeof token !== "string") return null;
972 if (row.token_hash !== (await sha256(token))) return null;
973 return row.status === "queued" || row.status === "building" ? row : null;
974 }
975
976 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run977 // Each report, for the logs: a build's own failure says why.
978 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page979 const row = await this.building(id, body.token);
980 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
981 const cloudflare = this.cloudflare;
982 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
983 switch (step) {
984 case "started":
985 await this.db
986 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
987 .bind(now(), id)
988 .run();
989 return Response.json(ok(true));
990 case "session": {
991 const manifest = body.manifest as Manifest | undefined;
992 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
993 const session = await cloudflare.openUpload(row.script, manifest);
994 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
995 }
996 case "finish": {
997 const worker = (body.worker ?? {}) as BuiltWorker;
998 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page999 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page1000 try {
Secrets and variables: one list, rows per environment, for workflows and deployments1001 // Running apps' secrets and variables are bound here, by g1t:
1002 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page1003 const runtime = await this.resolve(
1004 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1005 row.kind,
1006 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know1007 row.branch,
Projects: what a workspace builds and runs, first on every page1008 );
Deployments: a preview for every pull request, production on g1t.page1009 await cloudflare.putScript(
1010 row.script,
1011 worker,
1012 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page1013 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments1014 runtime,
Deployments: a preview for every pull request, production on g1t.page1015 );
1016 } catch (error) {
1017 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1018 return Response.json(ok(false));
1019 }
1020 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1021 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.page1022 await this.db.batch([
1023 this.db
1024 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1025 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1026 WHERE id = ?`,
1027 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1028 .bind(
1029 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1030 String(body.log ?? ""),
1031 seconds,
1032 at,
1033 detectedKind(body.detected),
1034 id,
1035 ),
Builds say Replaced or Down once they are no longer live1036 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1037 this.db
Builds say Replaced or Down once they are no longer live1038 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1039 .bind(row.script, id),
1040 this.db
Deployments: a preview for every pull request, production on g1t.page1041 .prepare(
Projects: what a workspace builds and runs, first on every page1042 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1043 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1044 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.page1045 )
Projects: what a workspace builds and runs, first on every page1046 .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 quotas1047 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1048 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1049 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1050 if (wasRedirect) {
1051 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1052 }
1053 // The same app at an older name (its project moved) now redirects
1054 // here; it stays up as it was if the redirect cannot be put.
1055 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1056 // The project's own domains serve production wherever it is up.
1057 if (row.kind === "production") {
1058 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1059 }
Deployments: a preview for every pull request, production on g1t.page1060 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1061 await this.notePeak(row.workspace);
1062 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
Events: review requests, assignments, stops and deployments are published1063 await this.announce(row, null);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1064 // A screenshot of production as it now is, for the project's overview.
1065 if (row.kind === "production") {
1066 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1067 console.error("could not ask for a screenshot", error),
1068 );
1069 }
Deployments: a preview for every pull request, production on g1t.page1070 return Response.json(ok(true));
1071 }
1072 case "fail":
1073 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1074 return Response.json(ok(true));
1075 default:
1076 return Response.json(fail("not_found", "No such step."), { status: 404 });
1077 }
1078 }
1079
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1080 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1081 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1082 * 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 quotas1083 * same is the app under a name it had before its project moved (its
1084 * workspace renamed, its repository renamed or transferred). Each is
1085 * replaced in the namespace by a redirect to the new name, its app row
1086 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1087 * the sweep to hold the old script) and in `DOMAINS` under the old
1088 * hostname, which the dispatcher follows before running anything, so the
1089 * old address redirects even if the old script is paused. A name that
1090 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1091 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1092 private async supersede(
1093 cloudflare: Cloudflare,
1094 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1095 script: string,
1096 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1097 const older = await this.db
1098 .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 quotas1099 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1100 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1101 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1102 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1103 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1104 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1105 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1106 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1107 console.error("could not redirect", old, "to", script, error);
1108 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1109 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1110 const at = now();
1111 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1112 await this.db.batch([
1113 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1114 this.db
1115 .prepare(
1116 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1117 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1118 )
1119 .bind(old, target, app.workspace, at, expires),
1120 // Its builds are no longer live under the old name.
1121 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1122 ]);
1123 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1124 expiration: Math.floor(Date.parse(expires) / 1000),
1125 }).catch((error) => console.error("could not record redirect", old, error));
1126 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1127 }
1128 return done;
1129 }
1130
Deployments: a preview for every pull request, production on g1t.page1131 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1132 const row = await this.deploymentRow(id);
1133 if (!row || (row.status !== "queued" && row.status !== "building")) return;
1134 await this.db
1135 .prepare(
1136 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1137 WHERE id = ?`,
1138 )
1139 .bind(message.slice(0, 2000), log, seconds, now(), id)
1140 .run();
1141 // A failed build still used its sandbox.
1142 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1143 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1144 await this.announce(row, message.slice(0, 300));
1145 }
1146
1147 /**
1148 * Publishes a finished build: `deployment.failed` with what went wrong,
1149 * or `deployment.succeeded`, saying whether the build of the same app
1150 * before it failed. Whoever started it is the actor when it was a
1151 * person known by id; otherwise `triggeredBy` names them, or g1t.
1152 */
1153 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1154 if (!this.env.EVENTS) return;
1155 const previous = error
1156 ? null
1157 : await this.db
1158 .prepare(
1159 `SELECT status FROM deployments
1160 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1161 ORDER BY created_at DESC LIMIT 1`,
1162 )
1163 .bind(row.script, row.id, row.created_at)
1164 .first<{ status: string }>();
1165 const byId = row.created_by.startsWith("usr_");
1166 const event = {
1167 source: "deployments",
1168 repoId: row.repo_id,
1169 actor: byId ? row.created_by : null,
1170 data: {
1171 deploymentId: row.id,
1172 projectId: row.project_id,
1173 repoId: row.repo_id,
1174 workspace: row.workspace,
1175 project: row.slug,
1176 kind: row.kind,
1177 branch: row.branch,
1178 number: row.kind === "preview" ? row.number : null,
1179 commit: row.commit_sha,
1180 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1181 error,
1182 recovered: previous?.status === "failed",
1183 triggeredBy: byId ? "" : row.created_by,
1184 },
1185 };
1186 await eventsClient(this.env.EVENTS)
1187 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1188 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Deployments: a preview for every pull request, production on g1t.page1189 }
1190
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1191 /** 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.page1192 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1193 const costs = await this.costs();
1194 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1195 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1196 const what =
1197 row.kind === "preview"
1198 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1199 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1200 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1201 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1202 feature: "deployments",
1203 costMicros: cost,
1204 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1205 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1206 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1207 // Every second is metered; billing prices it from its price book and
1208 // 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 put1209 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1210 });
1211 await this.db
1212 .prepare(
1213 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1214 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1215 )
Projects: what a workspace builds and runs, first on every page1216 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1217 .run();
1218 }
1219
Prices keep themselves current with what g1t pays1220 /**
1221 * What each unit costs g1t now, from billing's price book, which follows
1222 * what Cloudflare bills. The plan's figures if billing cannot say.
1223 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1224 private async costs(): Promise<{
1225 buildSecond: number;
1226 millionRequests: number;
1227 millionCpuMs: number;
1228 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1229 /** What one custom domain is charged a month: the cost plus the margin. */
1230 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1231 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1232 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1233 const book = await billingClient(this.env.BILLING)
1234 .prices()
1235 .catch(() => null);
1236 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1237 return {
1238 buildSecond: cost("build_second", a.microsPerBuildSecond),
1239 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1240 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1241 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 quotas1242 domainMonthPrice:
1243 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1244 };
1245 }
1246
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1247 /** 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 page1248 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1249 await this.db
1250 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1251 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1252 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1253 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1254 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1255 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1256 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.page1257 )
Projects: what a workspace builds and runs, first on every page1258 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1259 .run();
1260 }
1261
Projects: what a workspace builds and runs, first on every page1262 /**
1263 * The check on the commit: `g1t / deploy`, or, for one of several
1264 * projects on a repository, `g1t / deploy (<project>)`.
1265 */
1266 private async status(
1267 repoId: string,
1268 sha: string,
1269 project: { slug: string; primary: boolean },
1270 state: string,
1271 description: string,
1272 targetUrl: string,
1273 ): Promise<void> {
1274 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1275 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1276 method: "POST",
1277 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1278 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1279 }).catch(() => undefined);
1280 }
1281
Projects: what a workspace builds and runs, first on every page1282 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1283 const projects = await this.projects.byRepo(row.repo_id);
1284 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1285 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1286 }
1287
Deployments: a preview for every pull request, production on g1t.page1288 // ---- Taking apps down ----------------------------------------------
1289
1290 private async removeApp(script: string): Promise<void> {
1291 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1292 await this.db.batch([
1293 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1294 // Its build is no longer live anywhere.
1295 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1296 ]);
Deployments: a preview for every pull request, production on g1t.page1297 }
1298
Projects: what a workspace builds and runs, first on every page1299 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1300 const apps = await this.db
1301 .prepare(
Projects: what a workspace builds and runs, first on every page1302 `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.page1303 )
Projects: what a workspace builds and runs, first on every page1304 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1305 .all<{ script: string }>();
1306 for (const app of apps.results) await this.removeApp(app.script);
1307 }
1308
1309 // ---- Events --------------------------------------------------------
1310
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1311 /** `attempts`: which delivery of the event this is, from 1. */
1312 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1313 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1314 case "workspace.renamed":
1315 await this.renamed(event.data, attempts);
1316 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1317 case "repo.transferred":
1318 case "repo.renamed":
1319 await this.moved(repoMove(event)!, attempts);
1320 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1321 // A repository that went or came back with its workspace is the
1322 // 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 look1323 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1324 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 look1325 break;
1326 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1327 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 look1328 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1329 case "workspace.deleting":
1330 await this.workspaceDeleting(event.data.slug);
1331 break;
1332 case "workspace.restored":
1333 await this.workspaceRestored(event.data.slug);
1334 break;
1335 case "workspace.deleted":
1336 await this.workspacePurged(event.data.slug);
1337 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1338 case "repo.purged":
1339 await this.repoPurged(event.data.repoId);
1340 break;
1341 case "repo.default_branch_changed":
1342 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1343 break;
1344 case "branch.renamed":
1345 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1346 break;
1347
Deployments: a preview for every pull request, production on g1t.page1348 case "pull.opened":
1349 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1350 case "pull.updated":
1351 for (const project of await this.projects.byRepo(event.data.repoId)) {
1352 await this.deployPreview(project, event.data.number, "g1t");
1353 }
Deployments: a preview for every pull request, production on g1t.page1354 break;
1355 case "pull.closed":
1356 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1357 for (const project of await this.projects.byRepo(event.data.repoId)) {
1358 const apps = await this.db
1359 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1360 .bind(project.id, event.data.number)
1361 .all<{ script: string }>();
1362 for (const app of apps.results) await this.removeApp(app.script);
1363 }
Deployments: a preview for every pull request, production on g1t.page1364 break;
Projects: what a workspace builds and runs, first on every page1365 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1366 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1367 for (const project of await this.projects.byRepo(event.data.repoId)) {
1368 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1369 }
Deployments: a preview for every pull request, production on g1t.page1370 break;
1371 }
1372 }
1373
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1374 /**
1375 * A workspace's slug changed, and with it every app's name: production
1376 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1377 *
1378 * Its rows move to the slug it has now (asked of identity, so a delivery
1379 * 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 quotas1380 * each app (paused or not) is built again from the same commit under its
1381 * new name; see `followMoves`. The old name keeps serving the app until
1382 * the new one is live; then it redirects to the new name (see
1383 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1384 * 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 quotas1385 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1386 */
1387 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1388 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1389 const stale = staleSlugs(renamed, current);
1390 if (stale.length === 0) return;
1391 const marks = stale.map(() => "?").join(", ");
1392
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1393 // The workspace's projects, under any of its names: a second delivery
1394 // finds them under the new one, with whatever is left to do.
1395 const rows = await this.db
1396 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1397 .bind(...stale, current)
1398 .all<{ project_id: string }>();
1399 const projectIds = rows.results.map((row) => row.project_id);
1400 const projects = await this.projectsById(projectIds);
1401 // The projects service hears of the rename on its own queue: wait for
1402 // it a few deliveries, so the builds read the repository by its new name.
1403 const behind = [...projects.values()].some((p) => p.workspace !== current);
1404 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 domains1405
1406 // Every row moves at once.
1407 const at = now();
1408 const statements: D1PreparedStatement[] = [];
1409 for (const slug of stale) {
1410 statements.push(
1411 this.db
1412 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1413 `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 domains1414 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1415 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1416 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1417 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1418 this.db
1419 .prepare(
1420 `UPDATE deployments SET workspace = ?1,
1421 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1422 WHERE workspace = ?2`,
1423 )
1424 .bind(current, slug),
1425 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1426 this.domains.rename(slug, current),
1427 // Counters add up; the peak is the higher; a month charged stays charged.
1428 this.db
1429 .prepare(
1430 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1431 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1432 FROM meters WHERE namespace = ?2
1433 ON CONFLICT (namespace, month) DO UPDATE SET
1434 requests = requests + excluded.requests,
1435 cpu_ms = cpu_ms + excluded.cpu_ms,
1436 peak_apps = MAX(peak_apps, excluded.peak_apps),
1437 peak_domains = MAX(peak_domains, excluded.peak_domains),
1438 build_seconds = build_seconds + excluded.build_seconds,
1439 build_micros = build_micros + excluded.build_micros,
1440 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1441 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1442 )
1443 .bind(current, slug),
1444 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1445 );
1446 }
1447 await this.db.batch(statements);
1448
1449 // Each app again, under its new name. Its dependencies' addresses are
1450 // 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 quotas1451 const followed = await this.followMoves(projectIds, {
1452 projects,
1453 adjust: (project) => this.underSlug(project, current, stale),
1454 });
1455 if (followed.failed.length > 0) {
1456 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1457 }
1458 }
1459
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1460 /**
1461 * A repository moved: transferred to another workspace, and its projects
1462 * with it, or renamed within its own, when its own project's slug follows
1463 * its name (see the projects service). Each app's name is
1464 * `<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 quotas1465 * slug changed (production and every preview, paused or not) are built
1466 * again, from the same commit, under the new name; see `followMoves`. As
1467 * after a workspace rename, the old name keeps serving until the new one
1468 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1469 * The project's custom domains follow its production. Builds under way
1470 * are started again under the new name. What the apps used this month
1471 * stays on the old workspace's meter; from now on, the new workspace's
1472 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1473 *
1474 * Projects whose name did not change (a rename where the new name was
1475 * 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 quotas1476 * path. What is left to rebuild is read from the rows each time, so a
1477 * second or late delivery builds only what the first could not, and one
1478 * whose rebuild could not be queued is delivered again (and, past the
1479 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1480 */
1481 private async moved(move: RepoMove, attempts: number): Promise<void> {
1482 const current = await currentMovedPath(this.env.REPOS, move);
1483 if (staleMovedPaths(move, current).length === 0) return;
1484 const [workspace, name] = current.split("/") as [string, string];
1485 const settings = await this.db
1486 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1487 .bind(move.repoId)
1488 .all<{ project_id: string; workspace: string; slug: string }>();
1489 if (settings.results.length === 0) return;
1490
1491 // The projects service hears of the move on its own queue: wait for it
1492 // a few deliveries, so builds read the project where and as it is now.
1493 const projects = new Map<string, Project>();
1494 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1495 const behind = settings.results.some(({ project_id }) => {
1496 const project = projects.get(project_id);
1497 if (!project || project.source.kind !== "hosted") return false;
1498 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1499 });
1500 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1501
1502 // Its history goes with it, as the repository's issues do.
1503 const statements: D1PreparedStatement[] = [
1504 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1505 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1506 // The projects whose apps' names change, and have not been moved yet.
1507 const moving = settings.results
1508 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1509 .map((row) => row.project_id);
1510 if (moving.length > 0) {
1511 const ids = moving.map(() => "?").join(", ");
1512 statements.push(
1513 this.db
1514 .prepare(
1515 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1516 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1517 )
1518 .bind(MOVED_ERROR, now(), ...moving),
1519 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1520 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1521 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1522 const slug = projects.get(projectId)?.slug ?? null;
1523 statements.push(
1524 this.db
1525 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1526 .bind(workspace, slug, projectId),
1527 this.db
1528 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1529 .bind(workspace, slug, projectId),
1530 this.db
1531 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1532 .bind(workspace, slug, projectId),
1533 // Within the workspace its apps are listed under the project's name
1534 // now. One left behind in another workspace stays as it is until the
1535 // new name is live and redirects it.
1536 this.db
1537 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1538 .bind(workspace, slug, projectId),
1539 );
1540 }
1541 await this.db.batch(statements);
1542
1543 // Each app again, under its new name. App rows under the old name stay
1544 // 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 quotas1545 const followed = await this.followMoves(
1546 settings.results.map((row) => row.project_id),
1547 {
1548 projects,
1549 adjust: (found) =>
1550 found.source.kind === "hosted"
1551 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1552 : { ...found, workspace },
1553 },
1554 );
1555 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1556 }
1557
1558 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1559 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1560 const projects = new Map<string, Project>();
1561 if (projectIds.length === 0) return projects;
1562 const repoIds = new Set<string>();
1563 for (let i = 0; i < projectIds.length; i += 50) {
1564 const chunk = projectIds.slice(i, i + 50);
1565 const rows = await this.db
1566 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1567 .bind(...chunk)
1568 .all<{ repo_id: string }>();
1569 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1570 }
1571 const wanted = new Set(projectIds);
1572 for (const repoId of repoIds) {
1573 for (const project of await this.projects.byRepo(repoId)) {
1574 if (wanted.has(project.id)) projects.set(project.id, project);
1575 }
1576 }
1577 return projects;
1578 }
1579
1580 /** Whether `script` is the name an app of the project has where the project is now. */
1581 private async namedNow(
1582 script: string,
1583 settings: { project_id: string; workspace: string; slug: string },
1584 branch: string | null,
1585 ): Promise<boolean> {
1586 const base = await label(settings.workspace, settings.slug, branch);
1587 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1588 }
1589
1590 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1591 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1592 const stale: AppRow[] = [];
1593 for (const app of apps) {
1594 const row = settings.get(app.project_id);
1595 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1596 }
1597 return stale;
1598 }
1599
1600 /**
1601 * Brings the apps of the projects `projectIds` (every project when null)
1602 * under the names they have where the projects are now: each app still
1603 * under an older name, paused or not, and each build a move dropped, is
1604 * built again under its new name from the same commit, and once that is
1605 * live the old name redirects to it (`supersede`).
1606 *
1607 * Idempotent, and safe to run again at any time: an app already up under
1608 * its new name only has its old names redirected; one whose rebuild is
1609 * queued or under way is left to it; one whose workspace has no
1610 * Deployments or is over its limit waits, without a refused deployment
1611 * each time; with `backoff` (the sweep), one whose rebuild was tried
1612 * within `MOVE_RETRY_MS` waits. Returns what could not be queued, for an
1613 * event's delivery to be retried.
1614 */
1615 private async followMoves(
1616 projectIds: string[] | null,
1617 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1618 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1619 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1620 if (projectIds && projectIds.length === 0) return result;
1621 const cloudflare = this.cloudflare;
1622 if (!cloudflare) return result;
1623
1624 const settingsRows =
1625 projectIds == null
1626 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1627 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1628 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1629 if (settings.size === 0) return result;
1630 const ids = [...settings.keys()];
1631
1632 const apps: AppRow[] = [];
1633 const dropped: DroppedBuild[] = [];
1634 for (let i = 0; i < ids.length; i += 50) {
1635 const chunk = ids.slice(i, i + 50);
1636 const marks = chunk.map(() => "?").join(", ");
1637 const [appRows, droppedRows] = await Promise.all([
1638 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1639 // Builds a move dropped, that nothing has been built in place of since.
1640 this.db
1641 .prepare(
1642 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1643 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1644 AND NOT EXISTS (
1645 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1646 AND n.created_at > d.created_at AND n.status != 'skipped'
1647 )`,
1648 )
1649 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1650 .all<DeploymentRow>(),
1651 ]);
1652 apps.push(...appRows.results);
1653 dropped.push(...droppedRows.results);
1654 }
1655 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1656 if (targets.length === 0) return result;
1657
1658 const projects = new Map(options.projects ?? []);
1659 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1660 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1661 const billing = billingClient(this.env.BILLING);
1662 const open = new Map<string, boolean>();
1663 const actors = new Map<string, User | null>();
1664
1665 for (const target of targets) {
1666 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1667 const found = projects.get(target.projectId);
1668 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1669 const project = options.adjust ? options.adjust(found) : found;
1670 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1671 // Built only where deployments has the project now; the projects
1672 // service catches up on its own queue, and a later run builds then.
1673 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1674 result.waiting++;
1675 continue;
1676 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1677 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1678 const script = await this.scriptFor(project, target.branch);
1679 // Already up under its new name: only its old names are left to redirect.
1680 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1681 if (up) {
1682 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1683 continue;
1684 }
1685 const last = await this.db
1686 .prepare("SELECT status, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT 1")
1687 .bind(script)
1688 .first<{ status: string; created_at: string }>();
1689 if (last && (last.status === "queued" || last.status === "building")) {
1690 result.queued++;
1691 continue;
1692 }
1693 if (options.backoff && !retryDue(last?.created_at ?? null, Date.now(), MOVE_RETRY_MS)) {
1694 result.waiting++;
1695 continue;
1696 }
1697 // A workspace without Deployments, or over its limit, waits for it
1698 // rather than gathering refused deployments.
1699 if (!open.has(project.workspace)) {
1700 const [plan, limit] = await Promise.all([
1701 billing.hasFeature(project.workspace, "deployments"),
1702 billing.checkLimit(project.workspace),
1703 ]);
1704 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1705 }
1706 if (!open.get(project.workspace)) {
1707 result.waiting++;
1708 continue;
1709 }
1710 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 look1711 const started =
1712 target.kind === "production"
1713 ? await this.deployProduction(project, target.commit, "g1t")
1714 : target.number != null
1715 ? 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 quotas1716 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1717 const outcome = rebuildOutcome(started);
1718 if (outcome === "queued") result.queued++;
1719 else if (outcome === "failed") {
1720 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1721 result.failed.push(`${named}: ${why}`);
1722 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1723 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1724 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1725 }
1726 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1727 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1728 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1729 }
1730
1731 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1732 private async projectIdsFor(repoId: string): Promise<string[]> {
1733 const rows = await this.db
1734 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1735 .bind(repoId)
1736 .all<{ project_id: string }>();
1737 return rows.results.map((row) => row.project_id);
1738 }
1739
1740 /**
1741 * A repository was deleted, restorable for a while: every app of its
1742 * projects (production and previews) comes down, builds under way are
1743 * dropped, and nothing builds for it until it is restored. Its settings
1744 * and custom domains are kept for the restore; until then a domain has
1745 * nothing up to serve, as when production is turned off.
1746 */
1747 private async repoDeleted(repoId: string): Promise<void> {
1748 const projectIds = await this.projectIdsFor(repoId);
1749 if (projectIds.length === 0) return;
1750 const at = now();
1751 await this.db.batch([
1752 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1753 this.db
1754 .prepare(
1755 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1756 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1757 )
1758 .bind(at, repoId),
1759 ]);
1760 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1761 }
1762
1763 /**
1764 * A deleted repository is back: production goes up again from its
1765 * default branch, for each project that has it on. Previews come back
1766 * with the next push to their pull requests.
1767 */
1768 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1769 const deleted = await this.db
1770 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1771 .bind(repoId)
1772 .all<{ project_id: string }>();
1773 const ids = deleted.results.map((row) => row.project_id);
1774 if (ids.length === 0) return;
1775 // The projects service hears of the restore on its own queue, and hides
1776 // the projects until then: wait for it a few deliveries.
1777 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1778 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1779 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1780 for (const project of projects) {
1781 try {
1782 const started = await this.deployProduction(project, null, "g1t");
1783 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1784 } catch (error) {
1785 console.error("could not deploy after restore", project.slug, error);
1786 }
1787 }
1788 }
1789
1790 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1791 * A workspace was deleted, restorable by g1t's staff for a while: every
1792 * app of its projects (production and previews) is paused, answering with
1793 * a notice and running nothing, builds under way are dropped, and nothing
1794 * builds for it until it is restored. Nothing is taken down: scripts,
1795 * settings and custom domains are kept for the restore. Never for a
1796 * protected workspace, whatever was published.
1797 */
1798 private async workspaceDeleting(slug: string): Promise<void> {
1799 const workspace = slug.toLowerCase();
1800 if (isProtectedWorkspace(workspace)) {
1801 console.error("workspace.deleting ignored for protected", workspace);
1802 return;
1803 }
1804 const at = now();
1805 await this.db.batch([
1806 this.db
1807 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1808 .bind(at, workspace),
1809 this.db
1810 .prepare(
1811 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1812 WHERE workspace = ? AND status IN ('queued', 'building')`,
1813 )
1814 .bind(at, workspace),
1815 ]);
1816 const cloudflare = this.cloudflare;
1817 for (const app of await this.appsOfWorkspace(workspace)) {
1818 if (app.paused_at) continue;
1819 await cloudflare?.pauseScript(app.script);
1820 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1821 }
1822 }
1823
1824 /**
1825 * A deleted workspace is back: it builds again, and its paused apps are
1826 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1827 * (the sweep tries again any it could not).
1828 */
1829 private async workspaceRestored(slug: string): Promise<void> {
1830 const workspace = slug.toLowerCase();
1831 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1832 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1833 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1834 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1835 }
1836
1837 /**
1838 * A deleted workspace is purged: whatever its projects still have up
1839 * comes down and their custom domains go, as for a purged repository.
1840 * Its own repositories' projects are purged with them (`repo.purged`);
1841 * this catches any building from a repository it had transferred away.
1842 */
1843 private async workspacePurged(slug: string): Promise<void> {
1844 const workspace = slug.toLowerCase();
1845 if (isProtectedWorkspace(workspace)) return;
1846 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1847 for (const { project_id } of rows.results) {
1848 await this.takeDownWhere(project_id, null);
1849 await this.domains.removeWhere("project_id", project_id);
1850 }
1851 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1852 await this.db.batch([
1853 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1854 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1855 ]);
1856 }
1857
1858 /** The apps of a workspace's projects, and any still under its name. */
1859 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1860 const rows = await this.db
1861 .prepare(
1862 `SELECT * FROM apps WHERE workspace = ?1
1863 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1864 )
1865 .bind(workspace)
1866 .all<AppRow>();
1867 return rows.results;
1868 }
1869
1870 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1871 * A deleted repository is gone for good: its projects' custom domains are
1872 * removed (from the dispatcher and from Cloudflare), any app or redirect
1873 * still up comes down, and every row kept for them goes. What they used
1874 * stays on their workspace's meter.
1875 */
1876 private async repoPurged(repoId: string): Promise<void> {
1877 const projectIds = await this.projectIdsFor(repoId);
1878 const domains = this.domains;
1879 for (const projectId of projectIds) {
1880 await this.takeDownWhere(projectId, null);
1881 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1882 await domains.removeWhere("project_id", projectId);
1883 }
1884 // The redirects left at names its apps had before.
1885 const scripts = await this.db
1886 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1887 .bind(repoId)
1888 .all<{ script: string }>();
1889 const hosts = scripts.results.map(({ script }) => appHost(script));
1890 for (let i = 0; i < hosts.length; i += 50) {
1891 const chunk = hosts.slice(i, i + 50);
1892 const redirects = await this.db
1893 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1894 .bind(...chunk)
1895 .all<{ script: string }>();
1896 for (const { script } of redirects.results) {
1897 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1898 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 look1899 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1900 }
1901 }
1902 await this.db.batch([
1903 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1904 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1905 ]);
1906 }
1907
1908 /**
1909 * The default branch is another one now: production is built from it, as
1910 * from a push to it, unless production already serves (or is building)
1911 * its commit, as when the default branch was only renamed.
1912 */
1913 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1914 for (const found of await this.projects.byRepo(repoId)) {
1915 if (found.source.kind !== "hosted") continue;
1916 // Projects may not have heard yet: the event names the branch.
1917 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1918 const actor = await this.workspaceActor(project.workspace);
1919 if (!actor) continue;
1920 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1921 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1922 if (!head) continue;
1923 const same = await this.db
1924 .prepare(
1925 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1926 AND status IN ('queued', 'building', 'ready')`,
1927 )
1928 .bind(project.id, head)
1929 .first();
1930 if (same) continue;
1931 await this.deployProduction(project, head, createdBy);
1932 }
1933 }
1934
1935 /**
1936 * A branch was renamed: its preview is the same app, so its rows follow.
1937 * The app keeps its name until it is next built; then it goes up under
1938 * the new branch's name, and the old one redirects there (see `supersede`).
1939 */
1940 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1941 const projectIds = await this.projectIdsFor(repoId);
1942 if (projectIds.length === 0) return;
1943 const ids = projectIds.map(() => "?").join(", ");
1944 await this.db.batch([
1945 this.db
1946 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1947 .bind(to, from, ...projectIds),
1948 this.db
1949 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1950 .bind(to, from, ...projectIds),
1951 ]);
1952 }
1953
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1954 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1955 private underSlug(project: Project, current: string, stale: string[]): Project {
1956 const source =
1957 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1958 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1959 : project.source;
1960 return { ...project, workspace: current, source };
1961 }
1962
1963 /** A stack's preview (no pull request of its own) built again at `commit`. */
1964 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1965 if (!actor || branch == null) return null;
1966 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1967 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1968 return this.start({
1969 project,
1970 kind: "preview",
1971 branch,
1972 number: null,
1973 commit,
1974 source: repoOf(project).path,
1975 reader: actor,
1976 createdBy: "g1t",
1977 settings,
1978 // As `stack` built it: the project's own default branch.
1979 trusted: true,
1980 });
1981 }
1982
Deployments: a preview for every pull request, production on g1t.page1983 // ---- The sweep -----------------------------------------------------
1984
1985 /**
1986 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1987 * previews, the apps of workspaces whose plan ended, and scripts no app
1988 * holds come down; and a month that is over is charged past its
1989 * allowance.
Deployments: a preview for every pull request, production on g1t.page1990 */
1991 async sweep(): Promise<void> {
1992 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1993 const stuck = await this.db
1994 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1995 .bind(cutoff)
1996 .all<{ id: string }>();
1997 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1998
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1999 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2000 // Each app is its project's workspace's, as deployments has it now: an
2001 // app still under the name it had before its project moved is the new
2002 // workspace's, and plans and limits are checked there.
2003 const settings = new Map(
2004 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2005 );
2006 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 member2007 // A deleted workspace's apps stay paused as they are, for a restore:
2008 // its plan ended with the deletion, and that must not take them down.
2009 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2010 const live = apps.filter((app) => !held(app));
2011 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page2012
2013 // Apps of workspaces whose plan has ended come down.
2014 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2015 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page2016 for (const workspace of workspaces) {
2017 const plan = await billing.hasFeature(workspace, "deployments");
2018 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 member2019 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 domains2020 // Custom domains cost g1t by the month: they go with the plan.
2021 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.page2022 }
2023 }
2024
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2025 // Apps whose project moved and are not up under the new name yet: a
2026 // move's rebuild that could not start is tried again here.
2027 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2028 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2029
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2030 await this.domains
2031 .sweep(async (projectId) => {
2032 const app = await this.db
2033 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2034 .bind(projectId)
2035 .first<{ script: string }>();
2036 return app?.script ?? null;
2037 })
2038 .catch((error) => console.error("could not check domains", error));
2039
Projects: what a workspace builds and runs, first on every page2040 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page2041 await this.count(apps).catch((error) => console.error("could not count usage", error));
2042 await this.takeDownIdle();
2043 await this.chargeMonths();
2044 }
2045
Usage limits: unpaid usage can only go so far2046 /**
2047 * Pauses the apps of workspaces that reached their limit for usage not
2048 * yet paid for, and rebuilds them from the same commit once they are
2049 * 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 quotas2050 *
2051 * The workspace is the project's now (see `ownerOf`), never the one an
2052 * app's row was written under, so an app left under its old name after
2053 * a transfer is paused only if its new workspace is over its limit. Such
2054 * an app is resumed by being built under its new name (`followMoves`),
2055 * not here.
Usage limits: unpaid usage can only go so far2056 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2057 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far2058 const cloudflare = this.cloudflare;
2059 if (!cloudflare) return;
2060 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2061 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2062 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2063 const limit = await billing.checkLimit(workspace);
2064 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2065 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2066 if (limit.value.state === "stopped") {
2067 for (const app of theirs.filter((a) => !a.paused_at)) {
2068 await cloudflare.pauseScript(app.script);
2069 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2070 }
2071 continue;
2072 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2073 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2074 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2075 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2076 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2077 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2078 const project = projects.get(app.project_id);
2079 if (!project) continue;
Usage limits: unpaid usage can only go so far2080 // A failed or refused rebuild leaves it paused, to try again next time.
2081 const rebuilt =
2082 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2083 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2084 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2085 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2086 : null;
2087 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2088 }
2089 }
2090 }
2091
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2092 /**
2093 * Scripts in the namespace that no app holds, such as ones renamed. An
2094 * old address that redirects to its app's new one is held until its
2095 * redirect expires, then removed with the rest.
2096 */
Projects: what a workspace builds and runs, first on every page2097 private async removeOrphans(apps: AppRow[]): Promise<void> {
2098 const cloudflare = this.cloudflare;
2099 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2100 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2101 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2102 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2103 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 page2104 const building = await this.db
2105 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2106 .all<{ script: string }>();
2107 for (const row of building.results) held.add(row.script);
2108 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2109 for (const script of await cloudflare.listScripts()) {
2110 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2111 }
2112 }
2113
Deployments: a preview for every pull request, production on g1t.page2114 /** Counts this month's requests and CPU time per workspace, from analytics. */
2115 private async count(apps: AppRow[]): Promise<void> {
2116 const cloudflare = this.cloudflare;
2117 if (!cloudflare || apps.length === 0) return;
2118 const start = `${month()}-01T00:00:00Z`;
2119 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2120 // Analytics only counts apps that are up; the meter keeps what earlier
2121 // apps used by never going down.
2122 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2123 for (const app of apps) {
2124 const used = totals.get(app.script);
2125 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2126 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2127 sum.requests += used.requests;
2128 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2129 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2130 }
2131 const at = now();
Projects: what a workspace builds and runs, first on every page2132 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page2133 await this.db
2134 .prepare(
2135 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2136 ON CONFLICT (namespace, month) DO UPDATE SET
2137 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2138 )
Projects: what a workspace builds and runs, first on every page2139 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2140 .run();
2141 }
2142 // When each preview last answered anyone, for the idle sweep.
2143 const recent = await cloudflare.usage(
2144 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2145 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2146 at,
2147 );
2148 for (const [script, used] of recent) {
2149 if (used.requests > 0) {
2150 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2151 }
2152 }
Projects: what a workspace builds and runs, first on every page2153 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 quotas2154 // What this month's traffic and custom domains will cost, from the
2155 // first request and the first domain, so the workspace's limit counts
2156 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2157 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2158 const billing = billingClient(this.env.BILLING);
2159 const meters = await this.db
2160 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2161 .bind(month())
2162 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2163 for (const meter of meters.results) {
2164 const cost = monthCost(meter, costs);
2165 await billing
2166 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2167 .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 quotas2168 if ((meter.peak_domains ?? 0) > 0) {
2169 await billing
2170 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2171 .catch((error) => console.error("could not note pending usage", error));
2172 }
Prices keep themselves current with what g1t pays2173 }
Deployments: a preview for every pull request, production on g1t.page2174 }
2175
Projects: what a workspace builds and runs, first on every page2176 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2177 private async takeDownIdle(): Promise<void> {
2178 const idle = await this.db
2179 .prepare(
Projects: what a workspace builds and runs, first on every page2180 `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 member2181 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2182 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2183 )
2184 .all<{ script: string }>();
2185 for (const { script } of idle.results) await this.removeApp(script);
2186 }
2187
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2188 /** 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.page2189 private async chargeMonths(): Promise<void> {
2190 const due = await this.db
2191 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2192 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2193 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2194 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2195 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2196 const cost = monthCost(meter, costs);
2197 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2198 const charged = await billingClient(this.env.BILLING).chargeFeature({
2199 workspace: meter.namespace,
2200 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2201 costMicros: cost.micros,
2202 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2203 reference: `deployments/${meter.namespace}/${meter.month}`,
2204 });
2205 if (!charged.ok) continue;
2206 }
2207 await this.db
2208 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2209 .bind(now(), meter.namespace, meter.month)
2210 .run();
2211 }
2212 }
2213}
2214
2215/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2216async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2217 switch (method) {
2218 case "settings":
2219 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2220 case "is_enabled":
2221 return service.isEnabled(args);
Deployments: a preview for every pull request, production on g1t.page2222 case "update_settings":
2223 return service.updateSettings(args);
2224 case "list":
2225 return service.list(args);
2226 case "get":
2227 return service.get(args);
2228 case "redeploy":
2229 return service.redeploy(args);
2230 case "take_down":
2231 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2232 case "stack":
2233 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2234 case "overview":
2235 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2236 case "usage":
2237 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2238 case "domains":
2239 return service.listDomains(args);
2240 case "add_domain":
2241 return service.addDomain(args);
2242 case "remove_domain":
2243 return service.removeDomain(args);
2244 case "refresh_domain":
2245 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page2246 default:
2247 return undefined;
2248 }
2249}
2250
2251export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2252 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2253 const { pathname } = new URL(request.url);
2254 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2255 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2256 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2257 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2258 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2259 const opened = openD1(env.DB, request);
2260 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2261 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2262 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.page2263 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2264 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2265 // A build's reports, forwarded by the API.
2266 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2267 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2268 return new Response("Not found\n", { status: 404 });
2269 },
2270
2271 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2272 const service = new Deployments(env);
2273 for (const message of batch.messages) {
2274 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2275 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2276 message.ack();
2277 } catch (error) {
2278 console.error("deployments could not handle", message.body.type, error);
2279 message.retry();
2280 }
2281 }
2282 },
2283
2284 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2285 await new Deployments(env).sweep();
2286 },
2287} satisfies ExportedHandler<Env, G1tEvent>;