g1t/services/deployments/src/index.ts

2,238 lines100,115 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Deployments: a preview for every pull request, production on g1t.page1/**
Projects: what a workspace builds and runs, first on every page2 * The deployments service: a project's production, deployed from its
3 * default branch on every push, and a live preview of every branch with an
4 * open pull request, on g1t.page.
Deployments: a preview for every pull request, production on g1t.page5 *
6 * It reacts to events (a pull request opened, ready, pushed to, closed or
Projects: what a workspace builds and runs, first on every page7 * merged; a push to the default branch) for every project built from the
8 * repository, asks billing whether the workspace pays for Deployments, and
9 * asks the runner to build the commit in a sandbox. The sandbox reports
10 * back through the API with a token for that build alone; this service
11 * opens the upload of its files and puts the finished app in the Workers
12 * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by
13 * hostname.
Deployments: a preview for every pull request, production on g1t.page14 *
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas15 * Nothing here is free, and nothing is capped. On the plan, every build
16 * second is charged as it happens; requests, CPU time and custom domains
17 * are charged from the first once the month is over, all at cost plus the
18 * margin and from the plan's included usage first. No count of projects,
19 * previews or apps ever stops or pauses a workspace: apps are not metered
20 * at all. Only the workspace's spend limit pauses its apps
21 * (`holdToLimits`), and only the plan ending takes them down. A Worker
22 * runs only while it answers a request, so an app no one visits costs
23 * nothing, and a preview is taken down when its pull request closes or
24 * after its project's idle days.
Deployments: a preview for every pull request, production on g1t.page25 *
26 * Reached through service bindings (`POST /rpc/<method>`) and, for a
27 * build's reports, through the API (`POST /jobs/<id>/<step>`).
28 */
29
30import {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains31 CUSTOM_DOMAIN_TARGET,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas32 DEPLOYMENT_COSTS,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains33 SLUG_HOLD_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look34 ComputeGate,
35 allows,
Deployments: a preview for every pull request, production on g1t.page36 billingClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 can,
38 needs,
39 permission,
40 sandboxEstimateMicros,
41 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains42 currentWorkspaceSlug,
Deployments: a preview for every pull request, production on g1t.page43 fail,
44 identityClient,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member45 isProtectedWorkspace,
Deployments: a preview for every pull request, production on g1t.page46 newId,
47 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents48 openD1,
Projects: what a workspace builds and runs, first on every page49 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50 repoMove,
Deployments: a preview for every pull request, production on g1t.page51 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains53 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page54 workClient,
55 type DeployKind,
56 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page58 type DeployStatus,
59 type DeployUsage,
60 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains61 type Domain,
Deployments: a preview for every pull request, production on g1t.page62 type G1tEvent,
63 type LiveApp,
Projects: what a workspace builds and runs, first on every page64 type Project,
65 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains67 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page68 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights69 workOwner,
Deployments: a preview for every pull request, production on g1t.page70 type RepoPath,
71 type Result,
72 type ServiceBinding,
73 type User,
74 type Viewer,
75} from "@g1t/contracts";
76
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights77import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page78import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains79import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas80import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
81import { monthCost } from "./metering";
82import { moveTargets, ownerOf, rebuildOutcome, retryDue, type DroppedBuild, type MoveTarget } from "./moves";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains83import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page84
85type Env = {
86 DB: D1Database;
87 REPOS: ServiceBinding;
88 WORK: ServiceBinding;
89 IDENTITY: ServiceBinding;
90 BILLING: ServiceBinding;
91 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page92 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments93 /** Secrets and variables: the actions service holds the one store. */
94 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page95 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
96 CLOUDFLARE_API_TOKEN?: string;
97 CLOUDFLARE_ACCOUNT_ID: string;
98 DISPATCH_NAMESPACE: string;
99 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains100 /** Custom domains: hostname to app, read by the dispatcher. */
101 DOMAINS?: KVNamespace;
102 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
103 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look104 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
105 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page106};
107
108/** A build that has not reported in this long has died. */
109const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page110/** A script in the namespace that no app holds, older than this, is removed. */
111const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page112const LIST_LIMIT = 50;
113const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains114/**
115 * Deliveries of `workspace.renamed` that wait for the projects service to
116 * have seen it too, before going ahead with the new slug regardless.
117 */
118const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas119/** Why a build under way was dropped by a move; the move builds it again under the new name. */
120const MOVED_ERROR = "The repository moved: built again under its new name.";
121const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
122/** A move's rebuild that could not start is tried again by the sweep after this long. */
123const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page124
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look125/** A build's report of what it found the project to be, if it is one g1t knows. */
126export function detectedKind(value: unknown): DetectedKind | null {
127 return value === "workers" || value === "static" || value === "html" ? value : null;
128}
129
Deployments: a preview for every pull request, production on g1t.page130const now = () => new Date().toISOString();
131const month = (at = new Date()) => at.toISOString().slice(0, 7);
132
133async function sha256(text: string): Promise<string> {
134 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
135 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
136}
137
138function randomToken(): string {
139 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
140}
141
142function isMember(viewer: Viewer, slug: string): boolean {
143 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
144}
145
Projects: what a workspace builds and runs, first on every page146/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look147/** One compute gate per isolate, so entitlements and prices are kept between calls. */
148let computeGate: ComputeGate | null = null;
149function gateFor(billing: ServiceBinding): ComputeGate {
150 computeGate ??= new ComputeGate(billing);
151 return computeGate;
152}
153
154/** The longest a build may run, in minutes: as long as its read token lasts. */
155const BUILD_MINUTES = 30;
156
157/**
158 * A build that was skipped before it started (its plan, its limit, or
159 * billing's refusal) as a failure, so whoever asked for it sees why.
160 */
161function notStarted(result: Result<Deployment>): Result<Deployment> {
162 if (result.ok && result.value.status === "skipped" && result.value.error) {
163 return fail("payment_required", result.value.error);
164 }
165 return result;
166}
167
Projects: what a workspace builds and runs, first on every page168function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
169 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
170 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
171}
172
Deployments: a preview for every pull request, production on g1t.page173type SettingsRow = {
Projects: what a workspace builds and runs, first on every page174 project_id: string;
175 workspace: string;
176 slug: string;
Deployments: a preview for every pull request, production on g1t.page177 repo_id: string;
178 enabled: number;
179 previews: number;
180 production: number;
181 build_command: string | null;
182 output_dir: string | null;
183 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look184 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
185 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member186 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
187 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page188};
189
190type DeploymentRow = {
191 id: string;
Projects: what a workspace builds and runs, first on every page192 project_id: string;
193 workspace: string;
194 slug: string;
Deployments: a preview for every pull request, production on g1t.page195 repo_id: string;
Projects: what a workspace builds and runs, first on every page196 repo: string;
Deployments: a preview for every pull request, production on g1t.page197 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page198 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page199 number: number | null;
200 commit_sha: string;
201 script: string;
202 status: DeployStatus;
203 error: string | null;
204 warnings: string;
205 log: string | null;
206 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments207 trusted: number;
Deployments: a preview for every pull request, production on g1t.page208 build_seconds: number | null;
209 created_by: string;
210 created_at: string;
211 finished_at: string | null;
212};
213
214type AppRow = {
215 script: string;
Projects: what a workspace builds and runs, first on every page216 project_id: string;
217 workspace: string;
218 slug: string;
Deployments: a preview for every pull request, production on g1t.page219 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page220 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page221 number: number | null;
222 commit_sha: string;
223 deployed_at: string;
224 created_at: string;
225 last_request_at: string | null;
Usage limits: unpaid usage can only go so far226 /** Set while its workspace is over its limit; see `holdToLimits`. */
227 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page228};
229
230function toDeployment(row: DeploymentRow): Deployment {
231 return {
232 id: row.id,
233 kind: row.kind,
Projects: what a workspace builds and runs, first on every page234 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page235 number: row.number,
236 commit: row.commit_sha,
237 status: row.status,
238 url: appUrl(row.script),
239 error: row.error,
240 warnings: JSON.parse(row.warnings || "[]") as string[],
241 buildSeconds: row.build_seconds,
242 createdBy: row.created_by,
243 createdAt: row.created_at,
244 finishedAt: row.finished_at,
245 };
246}
247
Projects: what a workspace builds and runs, first on every page248function toLive(app: AppRow): LiveApp {
249 return {
250 kind: app.kind,
251 branch: app.branch,
252 number: app.number,
253 url: appUrl(app.script),
254 commit: app.commit_sha,
255 deployedAt: app.deployed_at,
256 };
257}
258
Deployments: a preview for every pull request, production on g1t.page259class Deployments {
260 constructor(private readonly env: Env) {}
261
262 private get cloudflare(): Cloudflare | null {
263 const token = this.env.CLOUDFLARE_API_TOKEN;
264 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
265 }
266
267 private get db() {
268 return this.env.DB;
269 }
270
Agents and memory, checks and conflicts, profiles, slug renames, custom domains271 private get domains(): Domains {
272 const token = this.env.CLOUDFLARE_API_TOKEN;
273 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
274 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
275 }
276
Projects: what a workspace builds and runs, first on every page277 private get projects() {
278 return projectsClient(this.env.PROJECTS);
279 }
280
Deployments: a preview for every pull request, production on g1t.page281 /** The workspace itself, as the service acts for it. */
282 private async workspaceActor(slug: string): Promise<User | null> {
283 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
284 if (!workspace) return null;
285 return {
286 id: workspace.id,
287 username: workspace.slug,
288 kind: "workspace",
289 verified: true,
290 workspaces: [{ slug: workspace.slug, role: "member" }],
291 };
292 }
293
Secrets and variables: one list, rows per environment, for workflows and deployments294 /**
Projects: what a workspace builds and runs, first on every page295 * What the project's secrets and variables available to deployments give
296 * production or a preview: its build's environment, and the same again as
297 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments298 */
299 private async resolve(
Projects: what a workspace builds and runs, first on every page300 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments301 environment: DeployKind,
302 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know303 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments304 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know305 const [rows, references] = await Promise.all([
306 this.rows(project, environment, trusted),
307 this.references(project.id, environment === "preview" ? branch : null),
308 ]);
309 // The project's own rows win over a dependency's address of the same name.
310 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
311 }
312
313 /**
314 * Each dependency's address, under the name the dependency gives it:
315 * for a preview, the same branch's preview of it if one is up, else its
316 * production; for production, its production.
317 */
318 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
319 const graph = await this.projects.graph(projectId).catch(() => null);
320 const out: Record<string, string> = {};
321 for (const dependency of graph?.dependsOn ?? []) {
322 if (!dependency.as) continue;
323 const app =
324 (branch
325 ? await this.db
326 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
327 .bind(dependency.id, branch)
328 .first<{ script: string }>()
329 : null) ??
330 (await this.db
331 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
332 .bind(dependency.id)
333 .first<{ script: string }>());
334 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
335 }
336 return out;
337 }
338
339 private async rows(
340 project: { id: string; slug: string; repoId: string; repo: RepoPath },
341 environment: DeployKind,
342 trusted: boolean,
343 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments344 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
345 method: "POST",
346 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page347 body: JSON.stringify({
348 repoId: project.repoId,
349 repo: project.repo,
350 projectId: project.id,
351 projectSlug: project.slug,
352 consumer: "deployments",
353 environment,
354 trusted,
355 }),
Secrets and variables: one list, rows per environment, for workflows and deployments356 });
357 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
358 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
359 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
360 }
361
362 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights363 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
364 * it, or its author) is trusted with the project's secrets: g1t itself,
365 * or someone who can push to the repository (Write or more, a member's or
366 * a collaborator's). Anyone else gets a preview built without them, as
367 * their workflows run. A change g1t made for someone is trusted as they
368 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments369 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights370 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
371 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look372 const found = await identityClient(this.env.IDENTITY)
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights373 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look374 .catch(() => null);
375 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments376 }
377
Projects: what a workspace builds and runs, first on every page378 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
379 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page380 }
381
Projects: what a workspace builds and runs, first on every page382 /** The name an app gets, unique among apps: production, or a branch's preview. */
383 private async scriptFor(project: Project, branch: string | null): Promise<string> {
384 const base = await label(project.workspace, project.slug, branch);
385 const holder = await this.db
386 .prepare(
387 `SELECT project_id, branch FROM apps WHERE script = ?1
388 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
389 )
390 .bind(base)
391 .first<{ project_id: string; branch: string | null }>();
392 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
393 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
394 }
395
396 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page397 return {
398 enabled: !!row?.enabled,
399 previews: row ? !!row.previews : true,
400 production: row ? !!row.production : true,
401 buildCommand: row?.build_command ?? null,
402 outputDir: row?.output_dir ?? null,
403 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page404 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains405 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page407 };
408 }
409
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look410 /** What the project's last finished build found it to be; null before one has. */
411 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
412 const row = await this.db
413 .prepare(
414 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
415 )
416 .bind(projectId)
417 .first<{ detected: string }>()
418 .catch(() => null);
419 return detectedKind(row?.detected);
420 }
421
422 /**
423 * The project, if `viewer` may do what `method` needs on its repository
424 * (see `NEEDS`): not found when they cannot read it, refused when they can
425 * but their role is too low.
426 */
427 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
428 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
429 if (!found.ok) return found;
430 const repo = repoRef(found.value);
431 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
432 const capability = NEEDS[method];
433 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
434 return found;
Deployments: a preview for every pull request, production on g1t.page435 }
436
437 // ---- Methods for the site and the API ------------------------------
438
Projects: what a workspace builds and runs, first on every page439 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look440 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page441 if (!project.ok) return project;
442 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page443 }
444
445 async updateSettings(a: {
446 actor: User;
Projects: what a workspace builds and runs, first on every page447 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page448 changes: Partial<DeploySettings>;
449 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look450 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page451 if (!found.ok) return found;
452 const project = found.value;
453 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page454 const next = { ...before, ...a.changes };
A project is an app or a library: libraries show their package and how to ship a release, not production455 if (next.enabled && !before.enabled && project.deploys === "no") {
456 return fail("conflict", "This project is set not to deploy. Change that in its General settings first.");
457 }
Deployments: a preview for every pull request, production on g1t.page458 if (next.enabled && !before.enabled) {
459 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page460 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page461 if (!plan.ok) return plan;
462 }
463 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
464 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
465 await this.db
466 .prepare(
Projects: what a workspace builds and runs, first on every page467 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
468 output_dir, idle_days, updated_by, updated_at)
469 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
470 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
471 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.page472 )
473 .bind(
Projects: what a workspace builds and runs, first on every page474 project.id,
475 project.workspace,
476 project.slug,
477 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page478 next.enabled ? 1 : 0,
479 next.previews ? 1 : 0,
480 next.production ? 1 : 0,
481 clip(next.buildCommand),
482 clip(next.outputDir),
483 idleDays,
484 a.actor.username,
485 now(),
486 )
487 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production488 // Projects keeps whether it deploys, so a project with Deployments on is an app.
489 if (next.enabled !== before.enabled) {
490 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
491 }
Deployments: a preview for every pull request, production on g1t.page492 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page493 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page494 else {
Projects: what a workspace builds and runs, first on every page495 if (!next.previews) await this.takeDownWhere(project.id, "preview");
496 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page497 }
498 // Turned on: production goes up from the default branch at once.
499 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page500 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page501 }
Projects: what a workspace builds and runs, first on every page502 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page503 }
504
A project is an app or a library: libraries show their package and how to ship a release, not production505 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
506 async isEnabled(a: { projectId: string }): Promise<boolean> {
507 return !!(await this.settingsRow(a.projectId))?.enabled;
508 }
509
Projects: what a workspace builds and runs, first on every page510 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 look511 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page512 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page513 const [deployments, apps] = await Promise.all([
514 this.db
Projects: what a workspace builds and runs, first on every page515 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
516 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page517 .all<DeploymentRow>(),
518 this.db
Projects: what a workspace builds and runs, first on every page519 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
520 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page521 .all<AppRow>(),
522 ]);
Projects: what a workspace builds and runs, first on every page523 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page524 }
525
Projects: what a workspace builds and runs, first on every page526 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 look527 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page528 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page529 const row = await this.db
Projects: what a workspace builds and runs, first on every page530 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
531 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page532 .first<DeploymentRow>();
533 if (!row) return fail("not_found", "No such deployment.");
534 return ok({ ...toDeployment(row), log: row.log });
535 }
536
Projects: what a workspace builds and runs, first on every page537 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 look538 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page539 if (!found.ok) return found;
540 const project = found.value;
541 const settings = await this.settingsRow(project.id);
542 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
543 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look544 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 page545 }
546 // A branch's preview comes from its pull request.
547 const app = await this.db
548 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
549 .bind(project.id, a.branch)
550 .first<{ number: number }>();
551 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 look552 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.page553 }
554
Project dependencies: addresses, preview stacks, Affects, and agents who know555 /**
556 * A preview stack: the projects that use this one get previews of their
557 * own default branch, under the same branch name, so each reaches this
558 * branch's preview through its dependency's variable. A change to an API
559 * can then be clicked through in the apps that call it.
560 */
561 async stack(
562 a: { actor: User; project: ProjectRef; branch: string },
563 background: (work: Promise<unknown>) => void,
564 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look565 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know566 if (!found.ok) return found;
567 const upstream = await this.db
568 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
569 .bind(found.value.id, a.branch)
570 .first();
571 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
572 const graph = await this.projects.graph(found.value.id);
573 const ready: { project: Project; settings: SettingsRow }[] = [];
574 for (const dependent of graph.usedBy) {
575 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 look576 // Each build spends compute on its own repository: only those the actor can run.
577 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know578 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look579 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know580 }
581 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
582 // The builds start after the answer: a person moving on from the page
583 // does not stop them.
584 background(
585 (async () => {
586 for (const { project, settings } of ready) {
587 const actor = await this.workspaceActor(project.workspace);
588 if (!actor) continue;
589 const repo = repoOf(project);
590 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
591 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
592 if (!head) continue;
593 await this.start({
594 project,
595 kind: "preview",
596 branch: a.branch,
597 number: null,
598 commit: head,
599 source: repo.path,
600 reader: actor,
601 createdBy: a.actor.username,
602 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look603 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know604 trusted: true,
605 });
606 }
607 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
608 );
609 return ok(ready.map(({ project }) => project.name));
610 }
611
Projects: what a workspace builds and runs, first on every page612 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 look613 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page614 if (!project.ok) return project;
615 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page616 return ok(true);
617 }
618
Projects: what a workspace builds and runs, first on every page619 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
620 const workspace = a.workspace.toLowerCase();
621 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 look622 // Only the projects whose repositories the viewer can read: the
623 // projects service lists no others.
624 const listed = await this.projects.list(workspace, a.viewer);
625 if (!listed.ok) return listed;
626 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page627 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look628 // A deleted repository's projects are hidden until it is restored.
629 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 page630 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
631 this.db
632 .prepare(
633 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
634 )
635 .bind(workspace)
636 .all<DeploymentRow>(),
637 ]);
638 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look639 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page640 const own = apps.results.filter((app) => app.slug === row.slug);
641 const production = own.find((app) => app.kind === "production");
642 const newest = latest.results.find((d) => d.slug === row.slug);
643 return {
644 slug: row.slug,
645 enabled: !!row.enabled,
646 production: production ? toLive(production) : null,
647 previews: own.filter((app) => app.kind === "preview").length,
648 latest: newest ? toDeployment(newest) : null,
649 };
650 }),
651 );
652 }
653
Deployments: a preview for every pull request, production on g1t.page654 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
655 const slug = a.workspace.toLowerCase();
656 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
657 const [meter, apps] = await Promise.all([
658 this.db
659 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
660 .bind(slug, month())
661 .first<{
662 requests: number;
663 cpu_ms: number;
664 peak_apps: number;
665 build_seconds: number;
666 build_micros: number;
667 counted_at: string | null;
668 }>(),
Projects: what a workspace builds and runs, first on every page669 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.page670 ]);
671 return ok({
672 month: month(),
673 requests: meter?.requests ?? 0,
674 cpuMs: meter?.cpu_ms ?? 0,
675 apps: apps?.n ?? 0,
676 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
677 buildSeconds: meter?.build_seconds ?? 0,
678 buildMicros: meter?.build_micros ?? 0,
679 countedAt: meter?.counted_at ?? null,
680 });
681 }
682
Agents and memory, checks and conflicts, profiles, slug renames, custom domains683 // ---- Custom domains ------------------------------------------------
684
685 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 look686 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains687 if (!project.ok) return project;
688 const domains = this.domains;
689 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 quotas690 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains691 domains.forProject(project.value.id),
692 domains.available(),
693 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas694 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains695 ]);
696 return ok({
697 domains: rows.map(toDomain),
698 target: CUSTOM_DOMAIN_TARGET,
699 available,
700 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas701 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains702 used,
703 });
704 }
705
706 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 look707 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains708 if (!found.ok) return found;
709 const project = found.value;
710 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
711 if (!plan.ok) return plan;
712 const added = await this.domains.add({
713 project: { id: project.id, workspace: project.workspace, slug: project.slug },
714 script: await this.productionScript(project),
715 hostname: String(a.hostname ?? ""),
716 twin: !!a.twin,
717 by: a.actor.username,
718 });
719 if (!added.ok) return fail(added.code, added.message);
720 await this.notePeak(project.workspace);
721 return ok(added.rows.map(toDomain));
722 }
723
724 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 look725 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains726 if (!found.ok) return found;
727 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
728 if (!row) return fail("not_found", "No such domain.");
729 await this.domains.remove(row);
730 return ok(true);
731 }
732
733 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 look734 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains735 if (!found.ok) return found;
736 const domains = this.domains;
737 const row = await domains.byId(found.value.id, String(a.id ?? ""));
738 if (!row) return fail("not_found", "No such domain.");
739 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
740 await this.notePeak(found.value.workspace);
741 return ok(toDomain(after));
742 }
743
744 /** The script production is up under, or the name it will have. */
745 private async productionScript(project: Project): Promise<string> {
746 const app = await this.db
747 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
748 .bind(project.id)
749 .first<{ script: string }>();
750 return app?.script ?? (await this.scriptFor(project, null));
751 }
752
Deployments: a preview for every pull request, production on g1t.page753 // ---- Starting builds -----------------------------------------------
754
755 /**
756 * Opens a deployment and starts its build. Skipped, with the reason
757 * recorded, when the workspace's plan is off.
758 */
759 private async start(input: {
Projects: what a workspace builds and runs, first on every page760 project: Project;
Deployments: a preview for every pull request, production on g1t.page761 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page762 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page763 number: number | null;
764 commit: string;
765 source: RepoPath;
766 reader: User;
767 createdBy: string;
768 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page769 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments770 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page771 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page772 const { project } = input;
773 const repo = repoOf(project);
774 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page775 const id = newId("dpl");
776 const token = randomToken();
Projects: what a workspace builds and runs, first on every page777 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far778 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page779 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look780 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page781 ? plan.error.message
Usage limits: unpaid usage can only go so far782 : limit.ok && limit.value.state === "stopped"
783 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page784 : !cloudflare
785 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
786 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look787 // A build is compute: reserved with billing before it starts, and
788 // settled by its sandbox when it stops. A refusal is the deployment's
789 // status, saying what to do.
790 const compute = gateFor(this.env.BILLING);
791 let reservation: string | null = null;
792 let microsPerSecond = 0;
793 let maxRunMinutes: number | null = null;
794 if (!refused) {
795 const ent = await compute.entitlements(project.workspace);
796 microsPerSecond = await compute.microsPerSecond();
797 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
798 const isPrivate = await reposClient(this.env.REPOS)
799 .get(repo.path, null)
800 .then((found) => !found.ok || found.value.isPrivate)
801 .catch(() => true);
802 const admitted = await compute.admit(
803 {
804 workspace: project.workspace,
805 repo: repo.path,
806 public: !isPrivate,
807 kind: "deploy",
808 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
809 },
810 ent,
811 );
812 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
813 else refused = admitted.message;
814 }
Deployments: a preview for every pull request, production on g1t.page815 await this.db
816 .prepare(
Projects: what a workspace builds and runs, first on every page817 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
818 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
819 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page820 )
821 .bind(
822 id,
Projects: what a workspace builds and runs, first on every page823 project.id,
824 project.workspace,
825 project.slug,
826 repo.id,
827 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page828 input.kind,
Projects: what a workspace builds and runs, first on every page829 input.branch,
Deployments: a preview for every pull request, production on g1t.page830 input.number,
831 input.commit,
832 script,
833 refused ? "skipped" : "queued",
834 refused,
835 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments836 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page837 input.createdBy,
838 now(),
839 refused ? now() : null,
840 )
841 .run();
842 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
843 // Older builds of the same app are replaced by this one.
844 await this.db
845 .prepare(
846 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
847 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
848 )
849 .bind(now(), script, id)
850 .run();
Projects: what a workspace builds and runs, first on every page851 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
852 // What the project's secrets and variables give builds of this kind.
853 const build = await this.resolve(
854 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
855 input.kind,
856 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know857 input.branch,
Projects: what a workspace builds and runs, first on every page858 );
Deployments: a preview for every pull request, production on g1t.page859 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
860 method: "POST",
861 headers: { "content-type": "application/json" },
862 body: JSON.stringify({
863 deployId: id,
864 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look865 workspace: project.workspace,
866 reservation,
867 microsPerSecond,
868 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page869 actor: input.reader,
870 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas871 // The project, whose guardrails the build runs under: a preview's
872 // source is its pull request's working copy, not the project.
873 repo: repo.path,
874 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page875 commit: input.commit,
Projects: what a workspace builds and runs, first on every page876 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page877 buildCommand: input.settings.build_command,
878 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments879 buildEnv: build.variables,
880 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page881 }),
882 });
883 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 look884 if (!started.ok) {
885 // Never reached a sandbox: what was reserved is given back.
886 if (reservation) await compute.settle(reservation, 0);
887 await this.finishFailed(id, started.error.message, null, null);
888 }
Deployments: a preview for every pull request, production on g1t.page889 return ok(toDeployment((await this.deploymentRow(id))!));
890 }
891
Projects: what a workspace builds and runs, first on every page892 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
893 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 member894 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 page895 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page896 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page897 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page898 let head = commit;
899 if (!head) {
Projects: what a workspace builds and runs, first on every page900 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
901 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page902 }
903 if (!head) return null;
904 return this.start({
Projects: what a workspace builds and runs, first on every page905 project,
Deployments: a preview for every pull request, production on g1t.page906 kind: "production",
Projects: what a workspace builds and runs, first on every page907 branch: null,
Deployments: a preview for every pull request, production on g1t.page908 number: null,
909 commit: head,
Projects: what a workspace builds and runs, first on every page910 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page911 reader: actor,
912 createdBy,
913 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments914 // The default branch only moves by people and agents with access.
915 trusted: true,
Deployments: a preview for every pull request, production on g1t.page916 });
917 }
918
Projects: what a workspace builds and runs, first on every page919 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
920 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 member921 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 page922 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page923 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page924 const repo = repoOf(project);
925 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page926 if (!detail.ok) return null;
927 const { pull } = detail.value;
928 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page929 // A pull request from a fork (as g1t's agents work) has no branch here.
930 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page931 if (!force) {
932 // Already built, or being built, at this commit.
933 const same = await this.db
934 .prepare(
Projects: what a workspace builds and runs, first on every page935 `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.page936 AND status IN ('queued', 'building', 'ready')`,
937 )
Projects: what a workspace builds and runs, first on every page938 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page939 .first();
940 if (same) return null;
941 }
942 return this.start({
Projects: what a workspace builds and runs, first on every page943 project,
Deployments: a preview for every pull request, production on g1t.page944 kind: "preview",
Projects: what a workspace builds and runs, first on every page945 branch,
Deployments: a preview for every pull request, production on g1t.page946 number,
947 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page948 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 rights949 // The pull request's fork may be private: read it as whoever it is
950 // for (whoever asked g1t for it, or its author), who is also who is
951 // trusted or not with the project's secrets.
952 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page953 createdBy,
954 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights955 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page956 });
957 }
958
959 // ---- A build's reports ---------------------------------------------
960
961 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
962 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
963 }
964
965 /** The build, if `token` is its own and it is still under way. */
966 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
967 const row = await this.deploymentRow(id);
968 if (!row?.token_hash || typeof token !== "string") return null;
969 if (row.token_hash !== (await sha256(token))) return null;
970 return row.status === "queued" || row.status === "building" ? row : null;
971 }
972
973 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run974 // Each report, for the logs: a build's own failure says why.
975 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page976 const row = await this.building(id, body.token);
977 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
978 const cloudflare = this.cloudflare;
979 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
980 switch (step) {
981 case "started":
982 await this.db
983 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
984 .bind(now(), id)
985 .run();
986 return Response.json(ok(true));
987 case "session": {
988 const manifest = body.manifest as Manifest | undefined;
989 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
990 const session = await cloudflare.openUpload(row.script, manifest);
991 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
992 }
993 case "finish": {
994 const worker = (body.worker ?? {}) as BuiltWorker;
995 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page996 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page997 try {
Secrets and variables: one list, rows per environment, for workflows and deployments998 // Running apps' secrets and variables are bound here, by g1t:
999 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page1000 const runtime = await this.resolve(
1001 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1002 row.kind,
1003 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know1004 row.branch,
Projects: what a workspace builds and runs, first on every page1005 );
Deployments: a preview for every pull request, production on g1t.page1006 await cloudflare.putScript(
1007 row.script,
1008 worker,
1009 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page1010 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments1011 runtime,
Deployments: a preview for every pull request, production on g1t.page1012 );
1013 } catch (error) {
1014 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1015 return Response.json(ok(false));
1016 }
1017 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1018 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.page1019 await this.db.batch([
1020 this.db
1021 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1022 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1023 WHERE id = ?`,
1024 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1025 .bind(
1026 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1027 String(body.log ?? ""),
1028 seconds,
1029 at,
1030 detectedKind(body.detected),
1031 id,
1032 ),
Builds say Replaced or Down once they are no longer live1033 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1034 this.db
Builds say Replaced or Down once they are no longer live1035 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1036 .bind(row.script, id),
1037 this.db
Deployments: a preview for every pull request, production on g1t.page1038 .prepare(
Projects: what a workspace builds and runs, first on every page1039 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1040 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1041 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.page1042 )
Projects: what a workspace builds and runs, first on every page1043 .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 quotas1044 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1045 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1046 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1047 if (wasRedirect) {
1048 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1049 }
1050 // The same app at an older name (its project moved) now redirects
1051 // here; it stays up as it was if the redirect cannot be put.
1052 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1053 // The project's own domains serve production wherever it is up.
1054 if (row.kind === "production") {
1055 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1056 }
Deployments: a preview for every pull request, production on g1t.page1057 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1058 await this.notePeak(row.workspace);
1059 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1060 // A screenshot of production as it now is, for the project's overview.
1061 if (row.kind === "production") {
1062 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1063 console.error("could not ask for a screenshot", error),
1064 );
1065 }
Deployments: a preview for every pull request, production on g1t.page1066 return Response.json(ok(true));
1067 }
1068 case "fail":
1069 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1070 return Response.json(ok(true));
1071 default:
1072 return Response.json(fail("not_found", "No such step."), { status: 404 });
1073 }
1074 }
1075
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1076 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1077 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1078 * 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 quotas1079 * same is the app under a name it had before its project moved (its
1080 * workspace renamed, its repository renamed or transferred). Each is
1081 * replaced in the namespace by a redirect to the new name, its app row
1082 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1083 * the sweep to hold the old script) and in `DOMAINS` under the old
1084 * hostname, which the dispatcher follows before running anything, so the
1085 * old address redirects even if the old script is paused. A name that
1086 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1087 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1088 private async supersede(
1089 cloudflare: Cloudflare,
1090 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1091 script: string,
1092 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1093 const older = await this.db
1094 .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 quotas1095 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1096 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1097 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1098 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1099 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1100 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1101 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1102 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1103 console.error("could not redirect", old, "to", script, error);
1104 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1105 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1106 const at = now();
1107 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1108 await this.db.batch([
1109 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1110 this.db
1111 .prepare(
1112 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1113 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1114 )
1115 .bind(old, target, app.workspace, at, expires),
1116 // Its builds are no longer live under the old name.
1117 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1118 ]);
1119 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1120 expiration: Math.floor(Date.parse(expires) / 1000),
1121 }).catch((error) => console.error("could not record redirect", old, error));
1122 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1123 }
1124 return done;
1125 }
1126
Deployments: a preview for every pull request, production on g1t.page1127 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1128 const row = await this.deploymentRow(id);
1129 if (!row || (row.status !== "queued" && row.status !== "building")) return;
1130 await this.db
1131 .prepare(
1132 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1133 WHERE id = ?`,
1134 )
1135 .bind(message.slice(0, 2000), log, seconds, now(), id)
1136 .run();
1137 // A failed build still used its sandbox.
1138 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1139 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Deployments: a preview for every pull request, production on g1t.page1140 }
1141
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1142 /** 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.page1143 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1144 const costs = await this.costs();
1145 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1146 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1147 const what =
1148 row.kind === "preview"
1149 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1150 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1151 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1152 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1153 feature: "deployments",
1154 costMicros: cost,
1155 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1156 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1157 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1158 // Every second is metered; billing prices it from its price book and
1159 // 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 put1160 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1161 });
1162 await this.db
1163 .prepare(
1164 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1165 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1166 )
Projects: what a workspace builds and runs, first on every page1167 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1168 .run();
1169 }
1170
Prices keep themselves current with what g1t pays1171 /**
1172 * What each unit costs g1t now, from billing's price book, which follows
1173 * what Cloudflare bills. The plan's figures if billing cannot say.
1174 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1175 private async costs(): Promise<{
1176 buildSecond: number;
1177 millionRequests: number;
1178 millionCpuMs: number;
1179 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1180 /** What one custom domain is charged a month: the cost plus the margin. */
1181 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1182 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1183 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1184 const book = await billingClient(this.env.BILLING)
1185 .prices()
1186 .catch(() => null);
1187 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1188 return {
1189 buildSecond: cost("build_second", a.microsPerBuildSecond),
1190 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1191 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1192 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 quotas1193 domainMonthPrice:
1194 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1195 };
1196 }
1197
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1198 /** 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 page1199 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1200 await this.db
1201 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1202 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1203 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1204 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1205 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1206 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1207 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.page1208 )
Projects: what a workspace builds and runs, first on every page1209 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1210 .run();
1211 }
1212
Projects: what a workspace builds and runs, first on every page1213 /**
1214 * The check on the commit: `g1t / deploy`, or, for one of several
1215 * projects on a repository, `g1t / deploy (<project>)`.
1216 */
1217 private async status(
1218 repoId: string,
1219 sha: string,
1220 project: { slug: string; primary: boolean },
1221 state: string,
1222 description: string,
1223 targetUrl: string,
1224 ): Promise<void> {
1225 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1226 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1227 method: "POST",
1228 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page1229 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page1230 }).catch(() => undefined);
1231 }
1232
Projects: what a workspace builds and runs, first on every page1233 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1234 const projects = await this.projects.byRepo(row.repo_id);
1235 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1236 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1237 }
1238
Deployments: a preview for every pull request, production on g1t.page1239 // ---- Taking apps down ----------------------------------------------
1240
1241 private async removeApp(script: string): Promise<void> {
1242 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1243 await this.db.batch([
1244 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1245 // Its build is no longer live anywhere.
1246 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1247 ]);
Deployments: a preview for every pull request, production on g1t.page1248 }
1249
Projects: what a workspace builds and runs, first on every page1250 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1251 const apps = await this.db
1252 .prepare(
Projects: what a workspace builds and runs, first on every page1253 `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.page1254 )
Projects: what a workspace builds and runs, first on every page1255 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1256 .all<{ script: string }>();
1257 for (const app of apps.results) await this.removeApp(app.script);
1258 }
1259
1260 // ---- Events --------------------------------------------------------
1261
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1262 /** `attempts`: which delivery of the event this is, from 1. */
1263 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1264 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1265 case "workspace.renamed":
1266 await this.renamed(event.data, attempts);
1267 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1268 case "repo.transferred":
1269 case "repo.renamed":
1270 await this.moved(repoMove(event)!, attempts);
1271 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1272 // A repository that went or came back with its workspace is the
1273 // 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 look1274 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1275 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 look1276 break;
1277 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1278 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 look1279 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1280 case "workspace.deleting":
1281 await this.workspaceDeleting(event.data.slug);
1282 break;
1283 case "workspace.restored":
1284 await this.workspaceRestored(event.data.slug);
1285 break;
1286 case "workspace.deleted":
1287 await this.workspacePurged(event.data.slug);
1288 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1289 case "repo.purged":
1290 await this.repoPurged(event.data.repoId);
1291 break;
1292 case "repo.default_branch_changed":
1293 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1294 break;
1295 case "branch.renamed":
1296 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1297 break;
1298
Deployments: a preview for every pull request, production on g1t.page1299 case "pull.opened":
1300 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1301 case "pull.updated":
1302 for (const project of await this.projects.byRepo(event.data.repoId)) {
1303 await this.deployPreview(project, event.data.number, "g1t");
1304 }
Deployments: a preview for every pull request, production on g1t.page1305 break;
1306 case "pull.closed":
1307 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1308 for (const project of await this.projects.byRepo(event.data.repoId)) {
1309 const apps = await this.db
1310 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1311 .bind(project.id, event.data.number)
1312 .all<{ script: string }>();
1313 for (const app of apps.results) await this.removeApp(app.script);
1314 }
Deployments: a preview for every pull request, production on g1t.page1315 break;
Projects: what a workspace builds and runs, first on every page1316 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1317 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1318 for (const project of await this.projects.byRepo(event.data.repoId)) {
1319 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1320 }
Deployments: a preview for every pull request, production on g1t.page1321 break;
1322 }
1323 }
1324
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1325 /**
1326 * A workspace's slug changed, and with it every app's name: production
1327 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1328 *
1329 * Its rows move to the slug it has now (asked of identity, so a delivery
1330 * 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 quotas1331 * each app (paused or not) is built again from the same commit under its
1332 * new name; see `followMoves`. The old name keeps serving the app until
1333 * the new one is live; then it redirects to the new name (see
1334 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1335 * 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 quotas1336 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1337 */
1338 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1339 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1340 const stale = staleSlugs(renamed, current);
1341 if (stale.length === 0) return;
1342 const marks = stale.map(() => "?").join(", ");
1343
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1344 // The workspace's projects, under any of its names: a second delivery
1345 // finds them under the new one, with whatever is left to do.
1346 const rows = await this.db
1347 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1348 .bind(...stale, current)
1349 .all<{ project_id: string }>();
1350 const projectIds = rows.results.map((row) => row.project_id);
1351 const projects = await this.projectsById(projectIds);
1352 // The projects service hears of the rename on its own queue: wait for
1353 // it a few deliveries, so the builds read the repository by its new name.
1354 const behind = [...projects.values()].some((p) => p.workspace !== current);
1355 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 domains1356
1357 // Every row moves at once.
1358 const at = now();
1359 const statements: D1PreparedStatement[] = [];
1360 for (const slug of stale) {
1361 statements.push(
1362 this.db
1363 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1364 `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 domains1365 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1366 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1367 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1368 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1369 this.db
1370 .prepare(
1371 `UPDATE deployments SET workspace = ?1,
1372 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1373 WHERE workspace = ?2`,
1374 )
1375 .bind(current, slug),
1376 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1377 this.domains.rename(slug, current),
1378 // Counters add up; the peak is the higher; a month charged stays charged.
1379 this.db
1380 .prepare(
1381 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1382 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1383 FROM meters WHERE namespace = ?2
1384 ON CONFLICT (namespace, month) DO UPDATE SET
1385 requests = requests + excluded.requests,
1386 cpu_ms = cpu_ms + excluded.cpu_ms,
1387 peak_apps = MAX(peak_apps, excluded.peak_apps),
1388 peak_domains = MAX(peak_domains, excluded.peak_domains),
1389 build_seconds = build_seconds + excluded.build_seconds,
1390 build_micros = build_micros + excluded.build_micros,
1391 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1392 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1393 )
1394 .bind(current, slug),
1395 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1396 );
1397 }
1398 await this.db.batch(statements);
1399
1400 // Each app again, under its new name. Its dependencies' addresses are
1401 // 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 quotas1402 const followed = await this.followMoves(projectIds, {
1403 projects,
1404 adjust: (project) => this.underSlug(project, current, stale),
1405 });
1406 if (followed.failed.length > 0) {
1407 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1408 }
1409 }
1410
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1411 /**
1412 * A repository moved: transferred to another workspace, and its projects
1413 * with it, or renamed within its own, when its own project's slug follows
1414 * its name (see the projects service). Each app's name is
1415 * `<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 quotas1416 * slug changed (production and every preview, paused or not) are built
1417 * again, from the same commit, under the new name; see `followMoves`. As
1418 * after a workspace rename, the old name keeps serving until the new one
1419 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1420 * The project's custom domains follow its production. Builds under way
1421 * are started again under the new name. What the apps used this month
1422 * stays on the old workspace's meter; from now on, the new workspace's
1423 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1424 *
1425 * Projects whose name did not change (a rename where the new name was
1426 * 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 quotas1427 * path. What is left to rebuild is read from the rows each time, so a
1428 * second or late delivery builds only what the first could not, and one
1429 * whose rebuild could not be queued is delivered again (and, past the
1430 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1431 */
1432 private async moved(move: RepoMove, attempts: number): Promise<void> {
1433 const current = await currentMovedPath(this.env.REPOS, move);
1434 if (staleMovedPaths(move, current).length === 0) return;
1435 const [workspace, name] = current.split("/") as [string, string];
1436 const settings = await this.db
1437 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1438 .bind(move.repoId)
1439 .all<{ project_id: string; workspace: string; slug: string }>();
1440 if (settings.results.length === 0) return;
1441
1442 // The projects service hears of the move on its own queue: wait for it
1443 // a few deliveries, so builds read the project where and as it is now.
1444 const projects = new Map<string, Project>();
1445 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1446 const behind = settings.results.some(({ project_id }) => {
1447 const project = projects.get(project_id);
1448 if (!project || project.source.kind !== "hosted") return false;
1449 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1450 });
1451 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1452
1453 // Its history goes with it, as the repository's issues do.
1454 const statements: D1PreparedStatement[] = [
1455 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1456 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1457 // The projects whose apps' names change, and have not been moved yet.
1458 const moving = settings.results
1459 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1460 .map((row) => row.project_id);
1461 if (moving.length > 0) {
1462 const ids = moving.map(() => "?").join(", ");
1463 statements.push(
1464 this.db
1465 .prepare(
1466 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1467 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1468 )
1469 .bind(MOVED_ERROR, now(), ...moving),
1470 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1471 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1472 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1473 const slug = projects.get(projectId)?.slug ?? null;
1474 statements.push(
1475 this.db
1476 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1477 .bind(workspace, slug, projectId),
1478 this.db
1479 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1480 .bind(workspace, slug, projectId),
1481 this.db
1482 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1483 .bind(workspace, slug, projectId),
1484 // Within the workspace its apps are listed under the project's name
1485 // now. One left behind in another workspace stays as it is until the
1486 // new name is live and redirects it.
1487 this.db
1488 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1489 .bind(workspace, slug, projectId),
1490 );
1491 }
1492 await this.db.batch(statements);
1493
1494 // Each app again, under its new name. App rows under the old name stay
1495 // 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 quotas1496 const followed = await this.followMoves(
1497 settings.results.map((row) => row.project_id),
1498 {
1499 projects,
1500 adjust: (found) =>
1501 found.source.kind === "hosted"
1502 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1503 : { ...found, workspace },
1504 },
1505 );
1506 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1507 }
1508
1509 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1510 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1511 const projects = new Map<string, Project>();
1512 if (projectIds.length === 0) return projects;
1513 const repoIds = new Set<string>();
1514 for (let i = 0; i < projectIds.length; i += 50) {
1515 const chunk = projectIds.slice(i, i + 50);
1516 const rows = await this.db
1517 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1518 .bind(...chunk)
1519 .all<{ repo_id: string }>();
1520 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1521 }
1522 const wanted = new Set(projectIds);
1523 for (const repoId of repoIds) {
1524 for (const project of await this.projects.byRepo(repoId)) {
1525 if (wanted.has(project.id)) projects.set(project.id, project);
1526 }
1527 }
1528 return projects;
1529 }
1530
1531 /** Whether `script` is the name an app of the project has where the project is now. */
1532 private async namedNow(
1533 script: string,
1534 settings: { project_id: string; workspace: string; slug: string },
1535 branch: string | null,
1536 ): Promise<boolean> {
1537 const base = await label(settings.workspace, settings.slug, branch);
1538 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1539 }
1540
1541 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1542 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1543 const stale: AppRow[] = [];
1544 for (const app of apps) {
1545 const row = settings.get(app.project_id);
1546 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1547 }
1548 return stale;
1549 }
1550
1551 /**
1552 * Brings the apps of the projects `projectIds` (every project when null)
1553 * under the names they have where the projects are now: each app still
1554 * under an older name, paused or not, and each build a move dropped, is
1555 * built again under its new name from the same commit, and once that is
1556 * live the old name redirects to it (`supersede`).
1557 *
1558 * Idempotent, and safe to run again at any time: an app already up under
1559 * its new name only has its old names redirected; one whose rebuild is
1560 * queued or under way is left to it; one whose workspace has no
1561 * Deployments or is over its limit waits, without a refused deployment
1562 * each time; with `backoff` (the sweep), one whose rebuild was tried
1563 * within `MOVE_RETRY_MS` waits. Returns what could not be queued, for an
1564 * event's delivery to be retried.
1565 */
1566 private async followMoves(
1567 projectIds: string[] | null,
1568 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1569 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1570 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1571 if (projectIds && projectIds.length === 0) return result;
1572 const cloudflare = this.cloudflare;
1573 if (!cloudflare) return result;
1574
1575 const settingsRows =
1576 projectIds == null
1577 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1578 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1579 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1580 if (settings.size === 0) return result;
1581 const ids = [...settings.keys()];
1582
1583 const apps: AppRow[] = [];
1584 const dropped: DroppedBuild[] = [];
1585 for (let i = 0; i < ids.length; i += 50) {
1586 const chunk = ids.slice(i, i + 50);
1587 const marks = chunk.map(() => "?").join(", ");
1588 const [appRows, droppedRows] = await Promise.all([
1589 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1590 // Builds a move dropped, that nothing has been built in place of since.
1591 this.db
1592 .prepare(
1593 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1594 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1595 AND NOT EXISTS (
1596 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1597 AND n.created_at > d.created_at AND n.status != 'skipped'
1598 )`,
1599 )
1600 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1601 .all<DeploymentRow>(),
1602 ]);
1603 apps.push(...appRows.results);
1604 dropped.push(...droppedRows.results);
1605 }
1606 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1607 if (targets.length === 0) return result;
1608
1609 const projects = new Map(options.projects ?? []);
1610 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1611 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1612 const billing = billingClient(this.env.BILLING);
1613 const open = new Map<string, boolean>();
1614 const actors = new Map<string, User | null>();
1615
1616 for (const target of targets) {
1617 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1618 const found = projects.get(target.projectId);
1619 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1620 const project = options.adjust ? options.adjust(found) : found;
1621 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1622 // Built only where deployments has the project now; the projects
1623 // service catches up on its own queue, and a later run builds then.
1624 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1625 result.waiting++;
1626 continue;
1627 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1628 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1629 const script = await this.scriptFor(project, target.branch);
1630 // Already up under its new name: only its old names are left to redirect.
1631 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1632 if (up) {
1633 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1634 continue;
1635 }
1636 const last = await this.db
1637 .prepare("SELECT status, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT 1")
1638 .bind(script)
1639 .first<{ status: string; created_at: string }>();
1640 if (last && (last.status === "queued" || last.status === "building")) {
1641 result.queued++;
1642 continue;
1643 }
1644 if (options.backoff && !retryDue(last?.created_at ?? null, Date.now(), MOVE_RETRY_MS)) {
1645 result.waiting++;
1646 continue;
1647 }
1648 // A workspace without Deployments, or over its limit, waits for it
1649 // rather than gathering refused deployments.
1650 if (!open.has(project.workspace)) {
1651 const [plan, limit] = await Promise.all([
1652 billing.hasFeature(project.workspace, "deployments"),
1653 billing.checkLimit(project.workspace),
1654 ]);
1655 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1656 }
1657 if (!open.get(project.workspace)) {
1658 result.waiting++;
1659 continue;
1660 }
1661 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 look1662 const started =
1663 target.kind === "production"
1664 ? await this.deployProduction(project, target.commit, "g1t")
1665 : target.number != null
1666 ? 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 quotas1667 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1668 const outcome = rebuildOutcome(started);
1669 if (outcome === "queued") result.queued++;
1670 else if (outcome === "failed") {
1671 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1672 result.failed.push(`${named}: ${why}`);
1673 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1674 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1675 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1676 }
1677 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1678 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1679 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1680 }
1681
1682 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1683 private async projectIdsFor(repoId: string): Promise<string[]> {
1684 const rows = await this.db
1685 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1686 .bind(repoId)
1687 .all<{ project_id: string }>();
1688 return rows.results.map((row) => row.project_id);
1689 }
1690
1691 /**
1692 * A repository was deleted, restorable for a while: every app of its
1693 * projects (production and previews) comes down, builds under way are
1694 * dropped, and nothing builds for it until it is restored. Its settings
1695 * and custom domains are kept for the restore; until then a domain has
1696 * nothing up to serve, as when production is turned off.
1697 */
1698 private async repoDeleted(repoId: string): Promise<void> {
1699 const projectIds = await this.projectIdsFor(repoId);
1700 if (projectIds.length === 0) return;
1701 const at = now();
1702 await this.db.batch([
1703 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1704 this.db
1705 .prepare(
1706 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1707 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1708 )
1709 .bind(at, repoId),
1710 ]);
1711 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1712 }
1713
1714 /**
1715 * A deleted repository is back: production goes up again from its
1716 * default branch, for each project that has it on. Previews come back
1717 * with the next push to their pull requests.
1718 */
1719 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1720 const deleted = await this.db
1721 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1722 .bind(repoId)
1723 .all<{ project_id: string }>();
1724 const ids = deleted.results.map((row) => row.project_id);
1725 if (ids.length === 0) return;
1726 // The projects service hears of the restore on its own queue, and hides
1727 // the projects until then: wait for it a few deliveries.
1728 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1729 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1730 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1731 for (const project of projects) {
1732 try {
1733 const started = await this.deployProduction(project, null, "g1t");
1734 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1735 } catch (error) {
1736 console.error("could not deploy after restore", project.slug, error);
1737 }
1738 }
1739 }
1740
1741 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1742 * A workspace was deleted, restorable by g1t's staff for a while: every
1743 * app of its projects (production and previews) is paused, answering with
1744 * a notice and running nothing, builds under way are dropped, and nothing
1745 * builds for it until it is restored. Nothing is taken down: scripts,
1746 * settings and custom domains are kept for the restore. Never for a
1747 * protected workspace, whatever was published.
1748 */
1749 private async workspaceDeleting(slug: string): Promise<void> {
1750 const workspace = slug.toLowerCase();
1751 if (isProtectedWorkspace(workspace)) {
1752 console.error("workspace.deleting ignored for protected", workspace);
1753 return;
1754 }
1755 const at = now();
1756 await this.db.batch([
1757 this.db
1758 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1759 .bind(at, workspace),
1760 this.db
1761 .prepare(
1762 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1763 WHERE workspace = ? AND status IN ('queued', 'building')`,
1764 )
1765 .bind(at, workspace),
1766 ]);
1767 const cloudflare = this.cloudflare;
1768 for (const app of await this.appsOfWorkspace(workspace)) {
1769 if (app.paused_at) continue;
1770 await cloudflare?.pauseScript(app.script);
1771 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1772 }
1773 }
1774
1775 /**
1776 * A deleted workspace is back: it builds again, and its paused apps are
1777 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1778 * (the sweep tries again any it could not).
1779 */
1780 private async workspaceRestored(slug: string): Promise<void> {
1781 const workspace = slug.toLowerCase();
1782 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1783 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1784 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1785 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1786 }
1787
1788 /**
1789 * A deleted workspace is purged: whatever its projects still have up
1790 * comes down and their custom domains go, as for a purged repository.
1791 * Its own repositories' projects are purged with them (`repo.purged`);
1792 * this catches any building from a repository it had transferred away.
1793 */
1794 private async workspacePurged(slug: string): Promise<void> {
1795 const workspace = slug.toLowerCase();
1796 if (isProtectedWorkspace(workspace)) return;
1797 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1798 for (const { project_id } of rows.results) {
1799 await this.takeDownWhere(project_id, null);
1800 await this.domains.removeWhere("project_id", project_id);
1801 }
1802 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1803 await this.db.batch([
1804 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1805 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1806 ]);
1807 }
1808
1809 /** The apps of a workspace's projects, and any still under its name. */
1810 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1811 const rows = await this.db
1812 .prepare(
1813 `SELECT * FROM apps WHERE workspace = ?1
1814 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1815 )
1816 .bind(workspace)
1817 .all<AppRow>();
1818 return rows.results;
1819 }
1820
1821 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1822 * A deleted repository is gone for good: its projects' custom domains are
1823 * removed (from the dispatcher and from Cloudflare), any app or redirect
1824 * still up comes down, and every row kept for them goes. What they used
1825 * stays on their workspace's meter.
1826 */
1827 private async repoPurged(repoId: string): Promise<void> {
1828 const projectIds = await this.projectIdsFor(repoId);
1829 const domains = this.domains;
1830 for (const projectId of projectIds) {
1831 await this.takeDownWhere(projectId, null);
1832 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1833 await domains.removeWhere("project_id", projectId);
1834 }
1835 // The redirects left at names its apps had before.
1836 const scripts = await this.db
1837 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1838 .bind(repoId)
1839 .all<{ script: string }>();
1840 const hosts = scripts.results.map(({ script }) => appHost(script));
1841 for (let i = 0; i < hosts.length; i += 50) {
1842 const chunk = hosts.slice(i, i + 50);
1843 const redirects = await this.db
1844 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1845 .bind(...chunk)
1846 .all<{ script: string }>();
1847 for (const { script } of redirects.results) {
1848 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1849 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 look1850 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1851 }
1852 }
1853 await this.db.batch([
1854 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1855 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1856 ]);
1857 }
1858
1859 /**
1860 * The default branch is another one now: production is built from it, as
1861 * from a push to it, unless production already serves (or is building)
1862 * its commit, as when the default branch was only renamed.
1863 */
1864 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1865 for (const found of await this.projects.byRepo(repoId)) {
1866 if (found.source.kind !== "hosted") continue;
1867 // Projects may not have heard yet: the event names the branch.
1868 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1869 const actor = await this.workspaceActor(project.workspace);
1870 if (!actor) continue;
1871 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1872 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1873 if (!head) continue;
1874 const same = await this.db
1875 .prepare(
1876 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1877 AND status IN ('queued', 'building', 'ready')`,
1878 )
1879 .bind(project.id, head)
1880 .first();
1881 if (same) continue;
1882 await this.deployProduction(project, head, createdBy);
1883 }
1884 }
1885
1886 /**
1887 * A branch was renamed: its preview is the same app, so its rows follow.
1888 * The app keeps its name until it is next built; then it goes up under
1889 * the new branch's name, and the old one redirects there (see `supersede`).
1890 */
1891 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1892 const projectIds = await this.projectIdsFor(repoId);
1893 if (projectIds.length === 0) return;
1894 const ids = projectIds.map(() => "?").join(", ");
1895 await this.db.batch([
1896 this.db
1897 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1898 .bind(to, from, ...projectIds),
1899 this.db
1900 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1901 .bind(to, from, ...projectIds),
1902 ]);
1903 }
1904
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1905 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1906 private underSlug(project: Project, current: string, stale: string[]): Project {
1907 const source =
1908 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1909 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1910 : project.source;
1911 return { ...project, workspace: current, source };
1912 }
1913
1914 /** A stack's preview (no pull request of its own) built again at `commit`. */
1915 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1916 if (!actor || branch == null) return null;
1917 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1918 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1919 return this.start({
1920 project,
1921 kind: "preview",
1922 branch,
1923 number: null,
1924 commit,
1925 source: repoOf(project).path,
1926 reader: actor,
1927 createdBy: "g1t",
1928 settings,
1929 // As `stack` built it: the project's own default branch.
1930 trusted: true,
1931 });
1932 }
1933
Deployments: a preview for every pull request, production on g1t.page1934 // ---- The sweep -----------------------------------------------------
1935
1936 /**
1937 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1938 * previews, the apps of workspaces whose plan ended, and scripts no app
1939 * holds come down; and a month that is over is charged past its
1940 * allowance.
Deployments: a preview for every pull request, production on g1t.page1941 */
1942 async sweep(): Promise<void> {
1943 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1944 const stuck = await this.db
1945 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1946 .bind(cutoff)
1947 .all<{ id: string }>();
1948 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1949
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1950 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1951 // Each app is its project's workspace's, as deployments has it now: an
1952 // app still under the name it had before its project moved is the new
1953 // workspace's, and plans and limits are checked there.
1954 const settings = new Map(
1955 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
1956 );
1957 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 member1958 // A deleted workspace's apps stay paused as they are, for a restore:
1959 // its plan ended with the deletion, and that must not take them down.
1960 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
1961 const live = apps.filter((app) => !held(app));
1962 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page1963
1964 // Apps of workspaces whose plan has ended come down.
1965 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1966 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1967 for (const workspace of workspaces) {
1968 const plan = await billing.hasFeature(workspace, "deployments");
1969 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 member1970 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 domains1971 // Custom domains cost g1t by the month: they go with the plan.
1972 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.page1973 }
1974 }
1975
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1976 // Apps whose project moved and are not up under the new name yet: a
1977 // move's rebuild that could not start is tried again here.
1978 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
1979 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1980
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1981 await this.domains
1982 .sweep(async (projectId) => {
1983 const app = await this.db
1984 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1985 .bind(projectId)
1986 .first<{ script: string }>();
1987 return app?.script ?? null;
1988 })
1989 .catch((error) => console.error("could not check domains", error));
1990
Projects: what a workspace builds and runs, first on every page1991 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page1992 await this.count(apps).catch((error) => console.error("could not count usage", error));
1993 await this.takeDownIdle();
1994 await this.chargeMonths();
1995 }
1996
Usage limits: unpaid usage can only go so far1997 /**
1998 * Pauses the apps of workspaces that reached their limit for usage not
1999 * yet paid for, and rebuilds them from the same commit once they are
2000 * 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 quotas2001 *
2002 * The workspace is the project's now (see `ownerOf`), never the one an
2003 * app's row was written under, so an app left under its old name after
2004 * a transfer is paused only if its new workspace is over its limit. Such
2005 * an app is resumed by being built under its new name (`followMoves`),
2006 * not here.
Usage limits: unpaid usage can only go so far2007 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2008 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far2009 const cloudflare = this.cloudflare;
2010 if (!cloudflare) return;
2011 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2012 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2013 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2014 const limit = await billing.checkLimit(workspace);
2015 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2016 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2017 if (limit.value.state === "stopped") {
2018 for (const app of theirs.filter((a) => !a.paused_at)) {
2019 await cloudflare.pauseScript(app.script);
2020 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2021 }
2022 continue;
2023 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2024 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2025 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2026 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2027 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2028 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2029 const project = projects.get(app.project_id);
2030 if (!project) continue;
Usage limits: unpaid usage can only go so far2031 // A failed or refused rebuild leaves it paused, to try again next time.
2032 const rebuilt =
2033 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2034 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2035 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2036 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2037 : null;
2038 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2039 }
2040 }
2041 }
2042
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2043 /**
2044 * Scripts in the namespace that no app holds, such as ones renamed. An
2045 * old address that redirects to its app's new one is held until its
2046 * redirect expires, then removed with the rest.
2047 */
Projects: what a workspace builds and runs, first on every page2048 private async removeOrphans(apps: AppRow[]): Promise<void> {
2049 const cloudflare = this.cloudflare;
2050 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2051 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2052 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2053 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2054 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 page2055 const building = await this.db
2056 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2057 .all<{ script: string }>();
2058 for (const row of building.results) held.add(row.script);
2059 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2060 for (const script of await cloudflare.listScripts()) {
2061 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2062 }
2063 }
2064
Deployments: a preview for every pull request, production on g1t.page2065 /** Counts this month's requests and CPU time per workspace, from analytics. */
2066 private async count(apps: AppRow[]): Promise<void> {
2067 const cloudflare = this.cloudflare;
2068 if (!cloudflare || apps.length === 0) return;
2069 const start = `${month()}-01T00:00:00Z`;
2070 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2071 // Analytics only counts apps that are up; the meter keeps what earlier
2072 // apps used by never going down.
2073 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2074 for (const app of apps) {
2075 const used = totals.get(app.script);
2076 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2077 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2078 sum.requests += used.requests;
2079 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2080 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2081 }
2082 const at = now();
Projects: what a workspace builds and runs, first on every page2083 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page2084 await this.db
2085 .prepare(
2086 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2087 ON CONFLICT (namespace, month) DO UPDATE SET
2088 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2089 )
Projects: what a workspace builds and runs, first on every page2090 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2091 .run();
2092 }
2093 // When each preview last answered anyone, for the idle sweep.
2094 const recent = await cloudflare.usage(
2095 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2096 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2097 at,
2098 );
2099 for (const [script, used] of recent) {
2100 if (used.requests > 0) {
2101 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2102 }
2103 }
Projects: what a workspace builds and runs, first on every page2104 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 quotas2105 // What this month's traffic and custom domains will cost, from the
2106 // first request and the first domain, so the workspace's limit counts
2107 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2108 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2109 const billing = billingClient(this.env.BILLING);
2110 const meters = await this.db
2111 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2112 .bind(month())
2113 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2114 for (const meter of meters.results) {
2115 const cost = monthCost(meter, costs);
2116 await billing
2117 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2118 .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 quotas2119 if ((meter.peak_domains ?? 0) > 0) {
2120 await billing
2121 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2122 .catch((error) => console.error("could not note pending usage", error));
2123 }
Prices keep themselves current with what g1t pays2124 }
Deployments: a preview for every pull request, production on g1t.page2125 }
2126
Projects: what a workspace builds and runs, first on every page2127 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2128 private async takeDownIdle(): Promise<void> {
2129 const idle = await this.db
2130 .prepare(
Projects: what a workspace builds and runs, first on every page2131 `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 member2132 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2133 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2134 )
2135 .all<{ script: string }>();
2136 for (const { script } of idle.results) await this.removeApp(script);
2137 }
2138
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2139 /** 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.page2140 private async chargeMonths(): Promise<void> {
2141 const due = await this.db
2142 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2143 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2144 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2145 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2146 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2147 const cost = monthCost(meter, costs);
2148 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2149 const charged = await billingClient(this.env.BILLING).chargeFeature({
2150 workspace: meter.namespace,
2151 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2152 costMicros: cost.micros,
2153 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2154 reference: `deployments/${meter.namespace}/${meter.month}`,
2155 });
2156 if (!charged.ok) continue;
2157 }
2158 await this.db
2159 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2160 .bind(now(), meter.namespace, meter.month)
2161 .run();
2162 }
2163 }
2164}
2165
2166/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2167async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2168 switch (method) {
2169 case "settings":
2170 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2171 case "is_enabled":
2172 return service.isEnabled(args);
Deployments: a preview for every pull request, production on g1t.page2173 case "update_settings":
2174 return service.updateSettings(args);
2175 case "list":
2176 return service.list(args);
2177 case "get":
2178 return service.get(args);
2179 case "redeploy":
2180 return service.redeploy(args);
2181 case "take_down":
2182 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2183 case "stack":
2184 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2185 case "overview":
2186 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2187 case "usage":
2188 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2189 case "domains":
2190 return service.listDomains(args);
2191 case "add_domain":
2192 return service.addDomain(args);
2193 case "remove_domain":
2194 return service.removeDomain(args);
2195 case "refresh_domain":
2196 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page2197 default:
2198 return undefined;
2199 }
2200}
2201
2202export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2203 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2204 const { pathname } = new URL(request.url);
2205 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2206 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2207 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2208 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2209 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2210 const opened = openD1(env.DB, request);
2211 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2212 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2213 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.page2214 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2215 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2216 // A build's reports, forwarded by the API.
2217 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2218 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2219 return new Response("Not found\n", { status: 404 });
2220 },
2221
2222 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2223 const service = new Deployments(env);
2224 for (const message of batch.messages) {
2225 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2226 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2227 message.ack();
2228 } catch (error) {
2229 console.error("deployments could not handle", message.body.type, error);
2230 message.retry();
2231 }
2232 }
2233 },
2234
2235 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2236 await new Deployments(env).sweep();
2237 },
2238} satisfies ExportedHandler<Env, G1tEvent>;