Skip to content

g1t/services/deployments/src/index.ts

2,315 lines103,640 bytesCodeBlame

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

Deployments: a preview for every pull request, production on g1t.page1/**
Projects: what a workspace builds and runs, first on every page2 * The deployments service: a project's production, deployed from its
3 * default branch on every push, and a live preview of every branch with an
4 * open pull request, on g1t.page.
Deployments: a preview for every pull request, production on g1t.page5 *
6 * It reacts to events (a pull request opened, ready, pushed to, closed or
Projects: what a workspace builds and runs, first on every page7 * merged; a push to the default branch) for every project built from the
8 * repository, asks billing whether the workspace pays for Deployments, and
9 * asks the runner to build the commit in a sandbox. The sandbox reports
10 * back through the API with a token for that build alone; this service
11 * opens the upload of its files and puts the finished app in the Workers
12 * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by
13 * hostname.
Deployments: a preview for every pull request, production on g1t.page14 *
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas15 * Nothing here is free, and nothing is capped. On the plan, every build
16 * second is charged as it happens; requests, CPU time and custom domains
17 * are charged from the first once the month is over, all at cost plus the
18 * margin and from the plan's included usage first. No count of projects,
19 * previews or apps ever stops or pauses a workspace: apps are not metered
20 * at all. Only the workspace's spend limit pauses its apps
21 * (`holdToLimits`), and only the plan ending takes them down. A Worker
22 * runs only while it answers a request, so an app no one visits costs
23 * nothing, and a preview is taken down when its pull request closes or
24 * after its project's idle days.
Deployments: a preview for every pull request, production on g1t.page25 *
26 * Reached through service bindings (`POST /rpc/<method>`) and, for a
27 * build's reports, through the API (`POST /jobs/<id>/<step>`).
28 */
29
30import {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains31 CUSTOM_DOMAIN_TARGET,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas32 DEPLOYMENT_COSTS,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains33 SLUG_HOLD_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look34 ComputeGate,
35 allows,
Deployments: a preview for every pull request, production on g1t.page36 billingClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 can,
38 needs,
39 permission,
40 sandboxEstimateMicros,
41 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains42 currentWorkspaceSlug,
Events: review requests, assignments, stops and deployments are published43 eventsClient,
Deployments: a preview for every pull request, production on g1t.page44 fail,
45 identityClient,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member46 isProtectedWorkspace,
Deployments: a preview for every pull request, production on g1t.page47 newId,
48 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents49 openD1,
Projects: what a workspace builds and runs, first on every page50 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look51 repoMove,
Deployments: a preview for every pull request, production on g1t.page52 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains54 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page55 workClient,
56 type DeployKind,
57 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page59 type DeployStatus,
60 type DeployUsage,
61 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains62 type Domain,
Deployments: a preview for every pull request, production on g1t.page63 type G1tEvent,
64 type LiveApp,
Projects: what a workspace builds and runs, first on every page65 type Project,
66 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains68 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page69 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights70 workOwner,
Deployments: a preview for every pull request, production on g1t.page71 type RepoPath,
72 type Result,
73 type ServiceBinding,
74 type User,
75 type Viewer,
76} from "@g1t/contracts";
77
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights78import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page79import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains80import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas81import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
82import { monthCost } from "./metering";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit83import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
84import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains85import { appHost, appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page86
87type Env = {
88 DB: D1Database;
89 REPOS: ServiceBinding;
90 WORK: ServiceBinding;
91 IDENTITY: ServiceBinding;
92 BILLING: ServiceBinding;
93 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page94 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments95 /** Secrets and variables: the actions service holds the one store. */
96 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published97 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
98 EVENTS?: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page99 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
100 CLOUDFLARE_API_TOKEN?: string;
101 CLOUDFLARE_ACCOUNT_ID: string;
102 DISPATCH_NAMESPACE: string;
103 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains104 /** Custom domains: hostname to app, read by the dispatcher. */
105 DOMAINS?: KVNamespace;
106 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
107 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look108 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
109 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page110};
111
112/** A build that has not reported in this long has died. */
113const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page114/** A script in the namespace that no app holds, older than this, is removed. */
115const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page116const LIST_LIMIT = 50;
117const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains118/**
119 * Deliveries of `workspace.renamed` that wait for the projects service to
120 * have seen it too, before going ahead with the new slug regardless.
121 */
122const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas123/** Why a build under way was dropped by a move; the move builds it again under the new name. */
124const MOVED_ERROR = "The repository moved: built again under its new name.";
125const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit126/**
127 * A move's rebuild that could not start, or failed, is tried again by the
128 * sweep after this long, doubled for each failure after the first, up to
129 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
130 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas131const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page132
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look133/** A build's report of what it found the project to be, if it is one g1t knows. */
134export function detectedKind(value: unknown): DetectedKind | null {
135 return value === "workers" || value === "static" || value === "html" ? value : null;
136}
137
Deployments: a preview for every pull request, production on g1t.page138const now = () => new Date().toISOString();
139const month = (at = new Date()) => at.toISOString().slice(0, 7);
140
141async function sha256(text: string): Promise<string> {
142 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
143 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
144}
145
146function randomToken(): string {
147 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
148}
149
150function isMember(viewer: Viewer, slug: string): boolean {
151 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
152}
153
Projects: what a workspace builds and runs, first on every page154/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look155/** One compute gate per isolate, so entitlements and prices are kept between calls. */
156let computeGate: ComputeGate | null = null;
157function gateFor(billing: ServiceBinding): ComputeGate {
158 computeGate ??= new ComputeGate(billing);
159 return computeGate;
160}
161
162/** The longest a build may run, in minutes: as long as its read token lasts. */
163const BUILD_MINUTES = 30;
164
165/**
166 * A build that was skipped before it started (its plan, its limit, or
167 * billing's refusal) as a failure, so whoever asked for it sees why.
168 */
169function notStarted(result: Result<Deployment>): Result<Deployment> {
170 if (result.ok && result.value.status === "skipped" && result.value.error) {
171 return fail("payment_required", result.value.error);
172 }
173 return result;
174}
175
Projects: what a workspace builds and runs, first on every page176function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
177 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
178 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
179}
180
Deployments: a preview for every pull request, production on g1t.page181type SettingsRow = {
Projects: what a workspace builds and runs, first on every page182 project_id: string;
183 workspace: string;
184 slug: string;
Deployments: a preview for every pull request, production on g1t.page185 repo_id: string;
186 enabled: number;
187 previews: number;
188 production: number;
189 build_command: string | null;
190 output_dir: string | null;
191 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look192 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
193 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member194 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
195 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page196};
197
198type DeploymentRow = {
199 id: string;
Projects: what a workspace builds and runs, first on every page200 project_id: string;
201 workspace: string;
202 slug: string;
Deployments: a preview for every pull request, production on g1t.page203 repo_id: string;
Projects: what a workspace builds and runs, first on every page204 repo: string;
Deployments: a preview for every pull request, production on g1t.page205 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page206 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page207 number: number | null;
208 commit_sha: string;
209 script: string;
210 status: DeployStatus;
211 error: string | null;
212 warnings: string;
213 log: string | null;
214 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments215 trusted: number;
Deployments: a preview for every pull request, production on g1t.page216 build_seconds: number | null;
217 created_by: string;
218 created_at: string;
219 finished_at: string | null;
220};
221
222type AppRow = {
223 script: string;
Projects: what a workspace builds and runs, first on every page224 project_id: string;
225 workspace: string;
226 slug: string;
Deployments: a preview for every pull request, production on g1t.page227 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page228 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page229 number: number | null;
230 commit_sha: string;
231 deployed_at: string;
232 created_at: string;
233 last_request_at: string | null;
Usage limits: unpaid usage can only go so far234 /** Set while its workspace is over its limit; see `holdToLimits`. */
235 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page236};
237
238function toDeployment(row: DeploymentRow): Deployment {
239 return {
240 id: row.id,
241 kind: row.kind,
Projects: what a workspace builds and runs, first on every page242 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page243 number: row.number,
244 commit: row.commit_sha,
245 status: row.status,
246 url: appUrl(row.script),
247 error: row.error,
248 warnings: JSON.parse(row.warnings || "[]") as string[],
249 buildSeconds: row.build_seconds,
250 createdBy: row.created_by,
251 createdAt: row.created_at,
252 finishedAt: row.finished_at,
253 };
254}
255
Projects: what a workspace builds and runs, first on every page256function toLive(app: AppRow): LiveApp {
257 return {
258 kind: app.kind,
259 branch: app.branch,
260 number: app.number,
261 url: appUrl(app.script),
262 commit: app.commit_sha,
263 deployedAt: app.deployed_at,
264 };
265}
266
Deployments: a preview for every pull request, production on g1t.page267class Deployments {
268 constructor(private readonly env: Env) {}
269
270 private get cloudflare(): Cloudflare | null {
271 const token = this.env.CLOUDFLARE_API_TOKEN;
272 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
273 }
274
275 private get db() {
276 return this.env.DB;
277 }
278
Agents and memory, checks and conflicts, profiles, slug renames, custom domains279 private get domains(): Domains {
280 const token = this.env.CLOUDFLARE_API_TOKEN;
281 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
282 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
283 }
284
Projects: what a workspace builds and runs, first on every page285 private get projects() {
286 return projectsClient(this.env.PROJECTS);
287 }
288
Deployments: a preview for every pull request, production on g1t.page289 /** The workspace itself, as the service acts for it. */
290 private async workspaceActor(slug: string): Promise<User | null> {
291 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
292 if (!workspace) return null;
293 return {
294 id: workspace.id,
295 username: workspace.slug,
296 kind: "workspace",
297 verified: true,
298 workspaces: [{ slug: workspace.slug, role: "member" }],
299 };
300 }
301
Secrets and variables: one list, rows per environment, for workflows and deployments302 /**
Projects: what a workspace builds and runs, first on every page303 * What the project's secrets and variables available to deployments give
304 * production or a preview: its build's environment, and the same again as
305 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments306 */
307 private async resolve(
Projects: what a workspace builds and runs, first on every page308 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments309 environment: DeployKind,
310 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know311 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments312 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know313 const [rows, references] = await Promise.all([
314 this.rows(project, environment, trusted),
315 this.references(project.id, environment === "preview" ? branch : null),
316 ]);
317 // The project's own rows win over a dependency's address of the same name.
318 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
319 }
320
321 /**
322 * Each dependency's address, under the name the dependency gives it:
323 * for a preview, the same branch's preview of it if one is up, else its
324 * production; for production, its production.
325 */
326 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
327 const graph = await this.projects.graph(projectId).catch(() => null);
328 const out: Record<string, string> = {};
329 for (const dependency of graph?.dependsOn ?? []) {
330 if (!dependency.as) continue;
331 const app =
332 (branch
333 ? await this.db
334 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
335 .bind(dependency.id, branch)
336 .first<{ script: string }>()
337 : null) ??
338 (await this.db
339 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
340 .bind(dependency.id)
341 .first<{ script: string }>());
342 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
343 }
344 return out;
345 }
346
347 private async rows(
348 project: { id: string; slug: string; repoId: string; repo: RepoPath },
349 environment: DeployKind,
350 trusted: boolean,
351 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments352 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
353 method: "POST",
354 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page355 body: JSON.stringify({
356 repoId: project.repoId,
357 repo: project.repo,
358 projectId: project.id,
359 projectSlug: project.slug,
360 consumer: "deployments",
361 environment,
362 trusted,
363 }),
Secrets and variables: one list, rows per environment, for workflows and deployments364 });
365 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
366 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
367 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
368 }
369
370 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights371 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
372 * it, or its author) is trusted with the project's secrets: g1t itself,
373 * or someone who can push to the repository (Write or more, a member's or
374 * a collaborator's). Anyone else gets a preview built without them, as
375 * their workflows run. A change g1t made for someone is trusted as they
376 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments377 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights378 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
379 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look380 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 rights381 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look382 .catch(() => null);
383 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments384 }
385
Projects: what a workspace builds and runs, first on every page386 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
387 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page388 }
389
Projects: what a workspace builds and runs, first on every page390 /** The name an app gets, unique among apps: production, or a branch's preview. */
391 private async scriptFor(project: Project, branch: string | null): Promise<string> {
392 const base = await label(project.workspace, project.slug, branch);
393 const holder = await this.db
394 .prepare(
395 `SELECT project_id, branch FROM apps WHERE script = ?1
396 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
397 )
398 .bind(base)
399 .first<{ project_id: string; branch: string | null }>();
400 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
401 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
402 }
403
404 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page405 return {
406 enabled: !!row?.enabled,
407 previews: row ? !!row.previews : true,
408 production: row ? !!row.production : true,
409 buildCommand: row?.build_command ?? null,
410 outputDir: row?.output_dir ?? null,
411 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page412 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains413 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look414 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page415 };
416 }
417
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look418 /** What the project's last finished build found it to be; null before one has. */
419 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
420 const row = await this.db
421 .prepare(
422 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
423 )
424 .bind(projectId)
425 .first<{ detected: string }>()
426 .catch(() => null);
427 return detectedKind(row?.detected);
428 }
429
430 /**
431 * The project, if `viewer` may do what `method` needs on its repository
432 * (see `NEEDS`): not found when they cannot read it, refused when they can
433 * but their role is too low.
434 */
435 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
436 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
437 if (!found.ok) return found;
438 const repo = repoRef(found.value);
439 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
440 const capability = NEEDS[method];
441 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
442 return found;
Deployments: a preview for every pull request, production on g1t.page443 }
444
445 // ---- Methods for the site and the API ------------------------------
446
Projects: what a workspace builds and runs, first on every page447 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 look448 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page449 if (!project.ok) return project;
450 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page451 }
452
453 async updateSettings(a: {
454 actor: User;
Projects: what a workspace builds and runs, first on every page455 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page456 changes: Partial<DeploySettings>;
457 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look458 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page459 if (!found.ok) return found;
460 const project = found.value;
461 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page462 const next = { ...before, ...a.changes };
A project is an app or a library: libraries show their package and how to ship a release, not production463 if (next.enabled && !before.enabled && project.deploys === "no") {
464 return fail("conflict", "This project is set not to deploy. Change that in its General settings first.");
465 }
Deployments: a preview for every pull request, production on g1t.page466 if (next.enabled && !before.enabled) {
467 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page468 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page469 if (!plan.ok) return plan;
470 }
471 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
472 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
473 await this.db
474 .prepare(
Projects: what a workspace builds and runs, first on every page475 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
476 output_dir, idle_days, updated_by, updated_at)
477 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
478 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
479 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.page480 )
481 .bind(
Projects: what a workspace builds and runs, first on every page482 project.id,
483 project.workspace,
484 project.slug,
485 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page486 next.enabled ? 1 : 0,
487 next.previews ? 1 : 0,
488 next.production ? 1 : 0,
489 clip(next.buildCommand),
490 clip(next.outputDir),
491 idleDays,
492 a.actor.username,
493 now(),
494 )
495 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production496 // Projects keeps whether it deploys, so a project with Deployments on is an app.
497 if (next.enabled !== before.enabled) {
498 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
499 }
Deployments: a preview for every pull request, production on g1t.page500 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page501 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page502 else {
Projects: what a workspace builds and runs, first on every page503 if (!next.previews) await this.takeDownWhere(project.id, "preview");
504 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page505 }
506 // Turned on: production goes up from the default branch at once.
507 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page508 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page509 }
Projects: what a workspace builds and runs, first on every page510 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page511 }
512
A project is an app or a library: libraries show their package and how to ship a release, not production513 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
514 async isEnabled(a: { projectId: string }): Promise<boolean> {
515 return !!(await this.settingsRow(a.projectId))?.enabled;
516 }
517
Projects: what a workspace builds and runs, first on every page518 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 look519 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page520 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page521 const [deployments, apps] = await Promise.all([
522 this.db
Projects: what a workspace builds and runs, first on every page523 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
524 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page525 .all<DeploymentRow>(),
526 this.db
Projects: what a workspace builds and runs, first on every page527 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
528 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page529 .all<AppRow>(),
530 ]);
Projects: what a workspace builds and runs, first on every page531 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page532 }
533
Projects: what a workspace builds and runs, first on every page534 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 look535 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page536 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page537 const row = await this.db
Projects: what a workspace builds and runs, first on every page538 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
539 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page540 .first<DeploymentRow>();
541 if (!row) return fail("not_found", "No such deployment.");
542 return ok({ ...toDeployment(row), log: row.log });
543 }
544
Projects: what a workspace builds and runs, first on every page545 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 look546 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page547 if (!found.ok) return found;
548 const project = found.value;
549 const settings = await this.settingsRow(project.id);
550 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
551 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look552 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 page553 }
554 // A branch's preview comes from its pull request.
555 const app = await this.db
556 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
557 .bind(project.id, a.branch)
558 .first<{ number: number }>();
559 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 look560 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.page561 }
562
Project dependencies: addresses, preview stacks, Affects, and agents who know563 /**
564 * A preview stack: the projects that use this one get previews of their
565 * own default branch, under the same branch name, so each reaches this
566 * branch's preview through its dependency's variable. A change to an API
567 * can then be clicked through in the apps that call it.
568 */
569 async stack(
570 a: { actor: User; project: ProjectRef; branch: string },
571 background: (work: Promise<unknown>) => void,
572 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look573 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know574 if (!found.ok) return found;
575 const upstream = await this.db
576 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
577 .bind(found.value.id, a.branch)
578 .first();
579 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
580 const graph = await this.projects.graph(found.value.id);
581 const ready: { project: Project; settings: SettingsRow }[] = [];
582 for (const dependent of graph.usedBy) {
583 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 look584 // Each build spends compute on its own repository: only those the actor can run.
585 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know586 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look587 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know588 }
589 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
590 // The builds start after the answer: a person moving on from the page
591 // does not stop them.
592 background(
593 (async () => {
594 for (const { project, settings } of ready) {
595 const actor = await this.workspaceActor(project.workspace);
596 if (!actor) continue;
597 const repo = repoOf(project);
598 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
599 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
600 if (!head) continue;
601 await this.start({
602 project,
603 kind: "preview",
604 branch: a.branch,
605 number: null,
606 commit: head,
607 source: repo.path,
608 reader: actor,
609 createdBy: a.actor.username,
610 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look611 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know612 trusted: true,
613 });
614 }
615 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
616 );
617 return ok(ready.map(({ project }) => project.name));
618 }
619
Projects: what a workspace builds and runs, first on every page620 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 look621 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page622 if (!project.ok) return project;
623 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page624 return ok(true);
625 }
626
Projects: what a workspace builds and runs, first on every page627 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
628 const workspace = a.workspace.toLowerCase();
629 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 look630 // Only the projects whose repositories the viewer can read: the
631 // projects service lists no others.
632 const listed = await this.projects.list(workspace, a.viewer);
633 if (!listed.ok) return listed;
634 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page635 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look636 // A deleted repository's projects are hidden until it is restored.
637 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 page638 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
639 this.db
640 .prepare(
641 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
642 )
643 .bind(workspace)
644 .all<DeploymentRow>(),
645 ]);
646 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look647 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page648 const own = apps.results.filter((app) => app.slug === row.slug);
649 const production = own.find((app) => app.kind === "production");
650 const newest = latest.results.find((d) => d.slug === row.slug);
651 return {
652 slug: row.slug,
653 enabled: !!row.enabled,
654 production: production ? toLive(production) : null,
655 previews: own.filter((app) => app.kind === "preview").length,
656 latest: newest ? toDeployment(newest) : null,
657 };
658 }),
659 );
660 }
661
Deployments: a preview for every pull request, production on g1t.page662 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
663 const slug = a.workspace.toLowerCase();
664 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
665 const [meter, apps] = await Promise.all([
666 this.db
667 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
668 .bind(slug, month())
669 .first<{
670 requests: number;
671 cpu_ms: number;
672 peak_apps: number;
673 build_seconds: number;
674 build_micros: number;
675 counted_at: string | null;
676 }>(),
Projects: what a workspace builds and runs, first on every page677 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.page678 ]);
679 return ok({
680 month: month(),
681 requests: meter?.requests ?? 0,
682 cpuMs: meter?.cpu_ms ?? 0,
683 apps: apps?.n ?? 0,
684 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
685 buildSeconds: meter?.build_seconds ?? 0,
686 buildMicros: meter?.build_micros ?? 0,
687 countedAt: meter?.counted_at ?? null,
688 });
689 }
690
Agents and memory, checks and conflicts, profiles, slug renames, custom domains691 // ---- Custom domains ------------------------------------------------
692
693 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 look694 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains695 if (!project.ok) return project;
696 const domains = this.domains;
697 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 quotas698 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains699 domains.forProject(project.value.id),
700 domains.available(),
701 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas702 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains703 ]);
704 return ok({
705 domains: rows.map(toDomain),
706 target: CUSTOM_DOMAIN_TARGET,
707 available,
708 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas709 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains710 used,
711 });
712 }
713
714 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 look715 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains716 if (!found.ok) return found;
717 const project = found.value;
718 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
719 if (!plan.ok) return plan;
720 const added = await this.domains.add({
721 project: { id: project.id, workspace: project.workspace, slug: project.slug },
722 script: await this.productionScript(project),
723 hostname: String(a.hostname ?? ""),
724 twin: !!a.twin,
725 by: a.actor.username,
726 });
727 if (!added.ok) return fail(added.code, added.message);
728 await this.notePeak(project.workspace);
729 return ok(added.rows.map(toDomain));
730 }
731
732 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 look733 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains734 if (!found.ok) return found;
735 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
736 if (!row) return fail("not_found", "No such domain.");
737 await this.domains.remove(row);
738 return ok(true);
739 }
740
741 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 look742 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains743 if (!found.ok) return found;
744 const domains = this.domains;
745 const row = await domains.byId(found.value.id, String(a.id ?? ""));
746 if (!row) return fail("not_found", "No such domain.");
747 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
748 await this.notePeak(found.value.workspace);
749 return ok(toDomain(after));
750 }
751
752 /** The script production is up under, or the name it will have. */
753 private async productionScript(project: Project): Promise<string> {
754 const app = await this.db
755 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
756 .bind(project.id)
757 .first<{ script: string }>();
758 return app?.script ?? (await this.scriptFor(project, null));
759 }
760
Deployments: a preview for every pull request, production on g1t.page761 // ---- Starting builds -----------------------------------------------
762
763 /**
764 * Opens a deployment and starts its build. Skipped, with the reason
765 * recorded, when the workspace's plan is off.
766 */
767 private async start(input: {
Projects: what a workspace builds and runs, first on every page768 project: Project;
Deployments: a preview for every pull request, production on g1t.page769 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page770 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page771 number: number | null;
772 commit: string;
773 source: RepoPath;
774 reader: User;
775 createdBy: string;
776 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page777 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments778 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page779 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page780 const { project } = input;
781 const repo = repoOf(project);
782 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page783 const id = newId("dpl");
784 const token = randomToken();
Projects: what a workspace builds and runs, first on every page785 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far786 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page787 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look788 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page789 ? plan.error.message
Usage limits: unpaid usage can only go so far790 : limit.ok && limit.value.state === "stopped"
791 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page792 : !cloudflare
793 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
794 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look795 // A build is compute: reserved with billing before it starts, and
796 // settled by its sandbox when it stops. A refusal is the deployment's
797 // status, saying what to do.
798 const compute = gateFor(this.env.BILLING);
799 let reservation: string | null = null;
800 let microsPerSecond = 0;
801 let maxRunMinutes: number | null = null;
802 if (!refused) {
803 const ent = await compute.entitlements(project.workspace);
804 microsPerSecond = await compute.microsPerSecond();
805 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
806 const isPrivate = await reposClient(this.env.REPOS)
807 .get(repo.path, null)
808 .then((found) => !found.ok || found.value.isPrivate)
809 .catch(() => true);
810 const admitted = await compute.admit(
811 {
812 workspace: project.workspace,
813 repo: repo.path,
814 public: !isPrivate,
815 kind: "deploy",
816 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
817 },
818 ent,
819 );
820 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
821 else refused = admitted.message;
822 }
Deployments: a preview for every pull request, production on g1t.page823 await this.db
824 .prepare(
Projects: what a workspace builds and runs, first on every page825 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
826 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
827 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page828 )
829 .bind(
830 id,
Projects: what a workspace builds and runs, first on every page831 project.id,
832 project.workspace,
833 project.slug,
834 repo.id,
835 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page836 input.kind,
Projects: what a workspace builds and runs, first on every page837 input.branch,
Deployments: a preview for every pull request, production on g1t.page838 input.number,
839 input.commit,
840 script,
841 refused ? "skipped" : "queued",
842 refused,
843 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments844 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page845 input.createdBy,
846 now(),
847 refused ? now() : null,
848 )
849 .run();
850 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
851 // Older builds of the same app are replaced by this one.
852 await this.db
853 .prepare(
854 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
855 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
856 )
857 .bind(now(), script, id)
858 .run();
Projects: what a workspace builds and runs, first on every page859 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
860 // What the project's secrets and variables give builds of this kind.
861 const build = await this.resolve(
862 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
863 input.kind,
864 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know865 input.branch,
Projects: what a workspace builds and runs, first on every page866 );
Deployments: a preview for every pull request, production on g1t.page867 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
868 method: "POST",
869 headers: { "content-type": "application/json" },
870 body: JSON.stringify({
871 deployId: id,
872 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look873 workspace: project.workspace,
874 reservation,
875 microsPerSecond,
876 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page877 actor: input.reader,
878 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas879 // The project, whose guardrails the build runs under: a preview's
880 // source is its pull request's working copy, not the project.
881 repo: repo.path,
882 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page883 commit: input.commit,
Projects: what a workspace builds and runs, first on every page884 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page885 buildCommand: input.settings.build_command,
886 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments887 buildEnv: build.variables,
888 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page889 }),
890 });
891 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 look892 if (!started.ok) {
893 // Never reached a sandbox: what was reserved is given back.
894 if (reservation) await compute.settle(reservation, 0);
895 await this.finishFailed(id, started.error.message, null, null);
896 }
Deployments: a preview for every pull request, production on g1t.page897 return ok(toDeployment((await this.deploymentRow(id))!));
898 }
899
Projects: what a workspace builds and runs, first on every page900 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
901 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 member902 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 page903 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page904 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page905 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page906 let head = commit;
907 if (!head) {
Projects: what a workspace builds and runs, first on every page908 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
909 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page910 }
911 if (!head) return null;
912 return this.start({
Projects: what a workspace builds and runs, first on every page913 project,
Deployments: a preview for every pull request, production on g1t.page914 kind: "production",
Projects: what a workspace builds and runs, first on every page915 branch: null,
Deployments: a preview for every pull request, production on g1t.page916 number: null,
917 commit: head,
Projects: what a workspace builds and runs, first on every page918 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page919 reader: actor,
920 createdBy,
921 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments922 // The default branch only moves by people and agents with access.
923 trusted: true,
Deployments: a preview for every pull request, production on g1t.page924 });
925 }
926
Projects: what a workspace builds and runs, first on every page927 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
928 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 member929 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 page930 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page931 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page932 const repo = repoOf(project);
933 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page934 if (!detail.ok) return null;
935 const { pull } = detail.value;
936 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page937 // A pull request from a fork (as g1t's agents work) has no branch here.
938 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page939 if (!force) {
940 // Already built, or being built, at this commit.
941 const same = await this.db
942 .prepare(
Projects: what a workspace builds and runs, first on every page943 `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.page944 AND status IN ('queued', 'building', 'ready')`,
945 )
Projects: what a workspace builds and runs, first on every page946 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page947 .first();
948 if (same) return null;
949 }
950 return this.start({
Projects: what a workspace builds and runs, first on every page951 project,
Deployments: a preview for every pull request, production on g1t.page952 kind: "preview",
Projects: what a workspace builds and runs, first on every page953 branch,
Deployments: a preview for every pull request, production on g1t.page954 number,
955 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page956 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 rights957 // The pull request's fork may be private: read it as whoever it is
958 // for (whoever asked g1t for it, or its author), who is also who is
959 // trusted or not with the project's secrets.
960 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page961 createdBy,
962 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights963 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page964 });
965 }
966
967 // ---- A build's reports ---------------------------------------------
968
969 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
970 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
971 }
972
973 /** The build, if `token` is its own and it is still under way. */
974 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
975 const row = await this.deploymentRow(id);
976 if (!row?.token_hash || typeof token !== "string") return null;
977 if (row.token_hash !== (await sha256(token))) return null;
978 return row.status === "queued" || row.status === "building" ? row : null;
979 }
980
981 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run982 // Each report, for the logs: a build's own failure says why.
983 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page984 const row = await this.building(id, body.token);
985 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
986 const cloudflare = this.cloudflare;
987 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
988 switch (step) {
989 case "started":
990 await this.db
991 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
992 .bind(now(), id)
993 .run();
994 return Response.json(ok(true));
995 case "session": {
996 const manifest = body.manifest as Manifest | undefined;
997 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
998 const session = await cloudflare.openUpload(row.script, manifest);
999 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
1000 }
1001 case "finish": {
1002 const worker = (body.worker ?? {}) as BuiltWorker;
1003 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page1004 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page1005 try {
Secrets and variables: one list, rows per environment, for workflows and deployments1006 // Running apps' secrets and variables are bound here, by g1t:
1007 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page1008 const runtime = await this.resolve(
1009 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1010 row.kind,
1011 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know1012 row.branch,
Projects: what a workspace builds and runs, first on every page1013 );
Deployments: a preview for every pull request, production on g1t.page1014 await cloudflare.putScript(
1015 row.script,
1016 worker,
1017 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page1018 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments1019 runtime,
Deployments: a preview for every pull request, production on g1t.page1020 );
1021 } catch (error) {
1022 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1023 return Response.json(ok(false));
1024 }
1025 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1026 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.page1027 await this.db.batch([
1028 this.db
1029 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1030 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1031 WHERE id = ?`,
1032 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1033 .bind(
1034 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1035 String(body.log ?? ""),
1036 seconds,
1037 at,
1038 detectedKind(body.detected),
1039 id,
1040 ),
Builds say Replaced or Down once they are no longer live1041 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1042 this.db
Builds say Replaced or Down once they are no longer live1043 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1044 .bind(row.script, id),
1045 this.db
Deployments: a preview for every pull request, production on g1t.page1046 .prepare(
Projects: what a workspace builds and runs, first on every page1047 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1048 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1049 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.page1050 )
Projects: what a workspace builds and runs, first on every page1051 .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 quotas1052 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1053 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1054 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1055 if (wasRedirect) {
1056 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1057 }
1058 // The same app at an older name (its project moved) now redirects
1059 // here; it stays up as it was if the redirect cannot be put.
1060 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1061 // The project's own domains serve production wherever it is up.
1062 if (row.kind === "production") {
1063 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1064 }
Deployments: a preview for every pull request, production on g1t.page1065 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1066 await this.notePeak(row.workspace);
1067 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
Events: review requests, assignments, stops and deployments are published1068 await this.announce(row, null);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1069 // A screenshot of production as it now is, for the project's overview.
1070 if (row.kind === "production") {
1071 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1072 console.error("could not ask for a screenshot", error),
1073 );
1074 }
Deployments: a preview for every pull request, production on g1t.page1075 return Response.json(ok(true));
1076 }
1077 case "fail":
1078 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1079 return Response.json(ok(true));
1080 default:
1081 return Response.json(fail("not_found", "No such step."), { status: 404 });
1082 }
1083 }
1084
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1085 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1086 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1087 * 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 quotas1088 * same is the app under a name it had before its project moved (its
1089 * workspace renamed, its repository renamed or transferred). Each is
1090 * replaced in the namespace by a redirect to the new name, its app row
1091 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1092 * the sweep to hold the old script) and in `DOMAINS` under the old
1093 * hostname, which the dispatcher follows before running anything, so the
1094 * old address redirects even if the old script is paused. A name that
1095 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1096 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1097 private async supersede(
1098 cloudflare: Cloudflare,
1099 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1100 script: string,
1101 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1102 const older = await this.db
1103 .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 quotas1104 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1105 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1106 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1107 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1108 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1109 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1110 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1111 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1112 console.error("could not redirect", old, "to", script, error);
1113 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1114 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1115 const at = now();
1116 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1117 await this.db.batch([
1118 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1119 this.db
1120 .prepare(
1121 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1122 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1123 )
1124 .bind(old, target, app.workspace, at, expires),
1125 // Its builds are no longer live under the old name.
1126 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1127 ]);
1128 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1129 expiration: Math.floor(Date.parse(expires) / 1000),
1130 }).catch((error) => console.error("could not record redirect", old, error));
1131 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1132 }
1133 return done;
1134 }
1135
Deployments: a preview for every pull request, production on g1t.page1136 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1137 const row = await this.deploymentRow(id);
1138 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1139 // A commit that is gone says so plainly; git's own words stay in the log.
1140 if (commitMissing(message)) {
1141 log = log ?? message;
1142 message = missingCommitMessage(row);
1143 }
Deployments: a preview for every pull request, production on g1t.page1144 await this.db
1145 .prepare(
1146 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1147 WHERE id = ?`,
1148 )
1149 .bind(message.slice(0, 2000), log, seconds, now(), id)
1150 .run();
1151 // A failed build still used its sandbox.
1152 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1153 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1154 await this.announce(row, message.slice(0, 300));
1155 }
1156
1157 /**
1158 * Publishes a finished build: `deployment.failed` with what went wrong,
1159 * or `deployment.succeeded`, saying whether the build of the same app
1160 * before it failed. Whoever started it is the actor when it was a
1161 * person known by id; otherwise `triggeredBy` names them, or g1t.
1162 */
1163 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1164 if (!this.env.EVENTS) return;
1165 const previous = error
1166 ? null
1167 : await this.db
1168 .prepare(
1169 `SELECT status FROM deployments
1170 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1171 ORDER BY created_at DESC LIMIT 1`,
1172 )
1173 .bind(row.script, row.id, row.created_at)
1174 .first<{ status: string }>();
1175 const byId = row.created_by.startsWith("usr_");
1176 const event = {
1177 source: "deployments",
1178 repoId: row.repo_id,
1179 actor: byId ? row.created_by : null,
1180 data: {
1181 deploymentId: row.id,
1182 projectId: row.project_id,
1183 repoId: row.repo_id,
1184 workspace: row.workspace,
1185 project: row.slug,
1186 kind: row.kind,
1187 branch: row.branch,
1188 number: row.kind === "preview" ? row.number : null,
1189 commit: row.commit_sha,
1190 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1191 error,
1192 recovered: previous?.status === "failed",
1193 triggeredBy: byId ? "" : row.created_by,
1194 },
1195 };
1196 await eventsClient(this.env.EVENTS)
1197 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1198 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Deployments: a preview for every pull request, production on g1t.page1199 }
1200
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1201 /** 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.page1202 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1203 const costs = await this.costs();
1204 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1205 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1206 const what =
1207 row.kind === "preview"
1208 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1209 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1210 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1211 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1212 feature: "deployments",
1213 costMicros: cost,
1214 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1215 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1216 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1217 // Every second is metered; billing prices it from its price book and
1218 // 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 put1219 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1220 });
1221 await this.db
1222 .prepare(
1223 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1224 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1225 )
Projects: what a workspace builds and runs, first on every page1226 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1227 .run();
1228 }
1229
Prices keep themselves current with what g1t pays1230 /**
1231 * What each unit costs g1t now, from billing's price book, which follows
1232 * what Cloudflare bills. The plan's figures if billing cannot say.
1233 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1234 private async costs(): Promise<{
1235 buildSecond: number;
1236 millionRequests: number;
1237 millionCpuMs: number;
1238 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1239 /** What one custom domain is charged a month: the cost plus the margin. */
1240 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1241 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1242 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1243 const book = await billingClient(this.env.BILLING)
1244 .prices()
1245 .catch(() => null);
1246 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1247 return {
1248 buildSecond: cost("build_second", a.microsPerBuildSecond),
1249 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1250 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1251 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 quotas1252 domainMonthPrice:
1253 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1254 };
1255 }
1256
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1257 /** 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 page1258 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1259 await this.db
1260 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1261 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1262 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1263 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1264 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1265 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1266 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.page1267 )
Projects: what a workspace builds and runs, first on every page1268 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1269 .run();
1270 }
1271
Projects: what a workspace builds and runs, first on every page1272 /**
1273 * The check on the commit: `g1t / deploy`, or, for one of several
1274 * projects on a repository, `g1t / deploy (<project>)`.
1275 */
1276 private async status(
1277 repoId: string,
1278 sha: string,
1279 project: { slug: string; primary: boolean },
1280 state: string,
1281 description: string,
1282 targetUrl: string,
1283 ): Promise<void> {
1284 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1285 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1286 method: "POST",
1287 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1288 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
Deployments: a preview for every pull request, production on g1t.page1289 }).catch(() => undefined);
1290 }
1291
Projects: what a workspace builds and runs, first on every page1292 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1293 const projects = await this.projects.byRepo(row.repo_id);
1294 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1295 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1296 }
1297
Deployments: a preview for every pull request, production on g1t.page1298 // ---- Taking apps down ----------------------------------------------
1299
1300 private async removeApp(script: string): Promise<void> {
1301 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1302 await this.db.batch([
1303 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1304 // Its build is no longer live anywhere.
1305 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1306 ]);
Deployments: a preview for every pull request, production on g1t.page1307 }
1308
Projects: what a workspace builds and runs, first on every page1309 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1310 const apps = await this.db
1311 .prepare(
Projects: what a workspace builds and runs, first on every page1312 `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.page1313 )
Projects: what a workspace builds and runs, first on every page1314 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1315 .all<{ script: string }>();
1316 for (const app of apps.results) await this.removeApp(app.script);
1317 }
1318
1319 // ---- Events --------------------------------------------------------
1320
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1321 /** `attempts`: which delivery of the event this is, from 1. */
1322 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1323 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1324 case "workspace.renamed":
1325 await this.renamed(event.data, attempts);
1326 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1327 case "repo.transferred":
1328 case "repo.renamed":
1329 await this.moved(repoMove(event)!, attempts);
1330 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1331 // A repository that went or came back with its workspace is the
1332 // 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 look1333 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1334 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 look1335 break;
1336 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1337 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 look1338 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1339 case "workspace.deleting":
1340 await this.workspaceDeleting(event.data.slug);
1341 break;
1342 case "workspace.restored":
1343 await this.workspaceRestored(event.data.slug);
1344 break;
1345 case "workspace.deleted":
1346 await this.workspacePurged(event.data.slug);
1347 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1348 case "repo.purged":
1349 await this.repoPurged(event.data.repoId);
1350 break;
1351 case "repo.default_branch_changed":
1352 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1353 break;
1354 case "branch.renamed":
1355 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1356 break;
1357
Deployments: a preview for every pull request, production on g1t.page1358 case "pull.opened":
1359 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1360 case "pull.updated":
1361 for (const project of await this.projects.byRepo(event.data.repoId)) {
1362 await this.deployPreview(project, event.data.number, "g1t");
1363 }
Deployments: a preview for every pull request, production on g1t.page1364 break;
1365 case "pull.closed":
1366 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1367 for (const project of await this.projects.byRepo(event.data.repoId)) {
1368 const apps = await this.db
1369 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1370 .bind(project.id, event.data.number)
1371 .all<{ script: string }>();
1372 for (const app of apps.results) await this.removeApp(app.script);
1373 }
Deployments: a preview for every pull request, production on g1t.page1374 break;
Projects: what a workspace builds and runs, first on every page1375 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1376 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1377 for (const project of await this.projects.byRepo(event.data.repoId)) {
1378 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1379 }
Deployments: a preview for every pull request, production on g1t.page1380 break;
1381 }
1382 }
1383
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1384 /**
1385 * A workspace's slug changed, and with it every app's name: production
1386 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1387 *
1388 * Its rows move to the slug it has now (asked of identity, so a delivery
1389 * 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 quotas1390 * each app (paused or not) is built again from the same commit under its
1391 * new name; see `followMoves`. The old name keeps serving the app until
1392 * the new one is live; then it redirects to the new name (see
1393 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1394 * 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 quotas1395 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1396 */
1397 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1398 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1399 const stale = staleSlugs(renamed, current);
1400 if (stale.length === 0) return;
1401 const marks = stale.map(() => "?").join(", ");
1402
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1403 // The workspace's projects, under any of its names: a second delivery
1404 // finds them under the new one, with whatever is left to do.
1405 const rows = await this.db
1406 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1407 .bind(...stale, current)
1408 .all<{ project_id: string }>();
1409 const projectIds = rows.results.map((row) => row.project_id);
1410 const projects = await this.projectsById(projectIds);
1411 // The projects service hears of the rename on its own queue: wait for
1412 // it a few deliveries, so the builds read the repository by its new name.
1413 const behind = [...projects.values()].some((p) => p.workspace !== current);
1414 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 domains1415
1416 // Every row moves at once.
1417 const at = now();
1418 const statements: D1PreparedStatement[] = [];
1419 for (const slug of stale) {
1420 statements.push(
1421 this.db
1422 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1423 `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 domains1424 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1425 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1426 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1427 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1428 this.db
1429 .prepare(
1430 `UPDATE deployments SET workspace = ?1,
1431 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1432 WHERE workspace = ?2`,
1433 )
1434 .bind(current, slug),
1435 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1436 this.domains.rename(slug, current),
1437 // Counters add up; the peak is the higher; a month charged stays charged.
1438 this.db
1439 .prepare(
1440 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1441 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1442 FROM meters WHERE namespace = ?2
1443 ON CONFLICT (namespace, month) DO UPDATE SET
1444 requests = requests + excluded.requests,
1445 cpu_ms = cpu_ms + excluded.cpu_ms,
1446 peak_apps = MAX(peak_apps, excluded.peak_apps),
1447 peak_domains = MAX(peak_domains, excluded.peak_domains),
1448 build_seconds = build_seconds + excluded.build_seconds,
1449 build_micros = build_micros + excluded.build_micros,
1450 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1451 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1452 )
1453 .bind(current, slug),
1454 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1455 );
1456 }
1457 await this.db.batch(statements);
1458
1459 // Each app again, under its new name. Its dependencies' addresses are
1460 // 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 quotas1461 const followed = await this.followMoves(projectIds, {
1462 projects,
1463 adjust: (project) => this.underSlug(project, current, stale),
1464 });
1465 if (followed.failed.length > 0) {
1466 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1467 }
1468 }
1469
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1470 /**
1471 * A repository moved: transferred to another workspace, and its projects
1472 * with it, or renamed within its own, when its own project's slug follows
1473 * its name (see the projects service). Each app's name is
1474 * `<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 quotas1475 * slug changed (production and every preview, paused or not) are built
1476 * again, from the same commit, under the new name; see `followMoves`. As
1477 * after a workspace rename, the old name keeps serving until the new one
1478 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1479 * The project's custom domains follow its production. Builds under way
1480 * are started again under the new name. What the apps used this month
1481 * stays on the old workspace's meter; from now on, the new workspace's
1482 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1483 *
1484 * Projects whose name did not change (a rename where the new name was
1485 * 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 quotas1486 * path. What is left to rebuild is read from the rows each time, so a
1487 * second or late delivery builds only what the first could not, and one
1488 * whose rebuild could not be queued is delivered again (and, past the
1489 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1490 */
1491 private async moved(move: RepoMove, attempts: number): Promise<void> {
1492 const current = await currentMovedPath(this.env.REPOS, move);
1493 if (staleMovedPaths(move, current).length === 0) return;
1494 const [workspace, name] = current.split("/") as [string, string];
1495 const settings = await this.db
1496 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1497 .bind(move.repoId)
1498 .all<{ project_id: string; workspace: string; slug: string }>();
1499 if (settings.results.length === 0) return;
1500
1501 // The projects service hears of the move on its own queue: wait for it
1502 // a few deliveries, so builds read the project where and as it is now.
1503 const projects = new Map<string, Project>();
1504 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1505 const behind = settings.results.some(({ project_id }) => {
1506 const project = projects.get(project_id);
1507 if (!project || project.source.kind !== "hosted") return false;
1508 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1509 });
1510 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1511
1512 // Its history goes with it, as the repository's issues do.
1513 const statements: D1PreparedStatement[] = [
1514 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1515 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1516 // The projects whose apps' names change, and have not been moved yet.
1517 const moving = settings.results
1518 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1519 .map((row) => row.project_id);
1520 if (moving.length > 0) {
1521 const ids = moving.map(() => "?").join(", ");
1522 statements.push(
1523 this.db
1524 .prepare(
1525 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1526 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1527 )
1528 .bind(MOVED_ERROR, now(), ...moving),
1529 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1530 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1531 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1532 const slug = projects.get(projectId)?.slug ?? null;
1533 statements.push(
1534 this.db
1535 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1536 .bind(workspace, slug, projectId),
1537 this.db
1538 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1539 .bind(workspace, slug, projectId),
1540 this.db
1541 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1542 .bind(workspace, slug, projectId),
1543 // Within the workspace its apps are listed under the project's name
1544 // now. One left behind in another workspace stays as it is until the
1545 // new name is live and redirects it.
1546 this.db
1547 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1548 .bind(workspace, slug, projectId),
1549 );
1550 }
1551 await this.db.batch(statements);
1552
1553 // Each app again, under its new name. App rows under the old name stay
1554 // 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 quotas1555 const followed = await this.followMoves(
1556 settings.results.map((row) => row.project_id),
1557 {
1558 projects,
1559 adjust: (found) =>
1560 found.source.kind === "hosted"
1561 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1562 : { ...found, workspace },
1563 },
1564 );
1565 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1566 }
1567
1568 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1569 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1570 const projects = new Map<string, Project>();
1571 if (projectIds.length === 0) return projects;
1572 const repoIds = new Set<string>();
1573 for (let i = 0; i < projectIds.length; i += 50) {
1574 const chunk = projectIds.slice(i, i + 50);
1575 const rows = await this.db
1576 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1577 .bind(...chunk)
1578 .all<{ repo_id: string }>();
1579 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1580 }
1581 const wanted = new Set(projectIds);
1582 for (const repoId of repoIds) {
1583 for (const project of await this.projects.byRepo(repoId)) {
1584 if (wanted.has(project.id)) projects.set(project.id, project);
1585 }
1586 }
1587 return projects;
1588 }
1589
1590 /** Whether `script` is the name an app of the project has where the project is now. */
1591 private async namedNow(
1592 script: string,
1593 settings: { project_id: string; workspace: string; slug: string },
1594 branch: string | null,
1595 ): Promise<boolean> {
1596 const base = await label(settings.workspace, settings.slug, branch);
1597 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1598 }
1599
1600 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1601 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1602 const stale: AppRow[] = [];
1603 for (const app of apps) {
1604 const row = settings.get(app.project_id);
1605 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1606 }
1607 return stale;
1608 }
1609
1610 /**
1611 * Brings the apps of the projects `projectIds` (every project when null)
1612 * under the names they have where the projects are now: each app still
1613 * under an older name, paused or not, and each build a move dropped, is
1614 * built again under its new name from the same commit, and once that is
1615 * live the old name redirects to it (`supersede`).
1616 *
1617 * Idempotent, and safe to run again at any time: an app already up under
1618 * its new name only has its old names redirected; one whose rebuild is
1619 * queued or under way is left to it; one whose workspace has no
1620 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1621 * each time; one whose commit is gone, or whose rebuild failed the same
1622 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1623 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1624 * doubled for each failure, waits. Returns what could not be queued, for an
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1625 * event's delivery to be retried.
1626 */
1627 private async followMoves(
1628 projectIds: string[] | null,
1629 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1630 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1631 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1632 if (projectIds && projectIds.length === 0) return result;
1633 const cloudflare = this.cloudflare;
1634 if (!cloudflare) return result;
1635
1636 const settingsRows =
1637 projectIds == null
1638 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1639 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1640 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1641 if (settings.size === 0) return result;
1642 const ids = [...settings.keys()];
1643
1644 const apps: AppRow[] = [];
1645 const dropped: DroppedBuild[] = [];
1646 for (let i = 0; i < ids.length; i += 50) {
1647 const chunk = ids.slice(i, i + 50);
1648 const marks = chunk.map(() => "?").join(", ");
1649 const [appRows, droppedRows] = await Promise.all([
1650 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1651 // Builds a move dropped, that nothing has been built in place of since.
1652 this.db
1653 .prepare(
1654 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1655 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1656 AND NOT EXISTS (
1657 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1658 AND n.created_at > d.created_at AND n.status != 'skipped'
1659 )`,
1660 )
1661 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1662 .all<DeploymentRow>(),
1663 ]);
1664 apps.push(...appRows.results);
1665 dropped.push(...droppedRows.results);
1666 }
1667 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1668 if (targets.length === 0) return result;
1669
1670 const projects = new Map(options.projects ?? []);
1671 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1672 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1673 const billing = billingClient(this.env.BILLING);
1674 const open = new Map<string, boolean>();
1675 const actors = new Map<string, User | null>();
1676
1677 for (const target of targets) {
1678 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1679 const found = projects.get(target.projectId);
1680 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1681 const project = options.adjust ? options.adjust(found) : found;
1682 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1683 // Built only where deployments has the project now; the projects
1684 // service catches up on its own queue, and a later run builds then.
1685 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1686 result.waiting++;
1687 continue;
1688 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1689 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1690 const script = await this.scriptFor(project, target.branch);
1691 // Already up under its new name: only its old names are left to redirect.
1692 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1693 if (up) {
1694 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1695 continue;
1696 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1697 const history = await this.recentBuilds(script);
1698 const last = history[0];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1699 if (last && (last.status === "queued" || last.status === "building")) {
1700 result.queued++;
1701 continue;
1702 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1703 // A commit that is gone, or a build that failed the same way a few
1704 // times, is not tried again; a push or a redeploy builds it.
1705 // Otherwise the sweep waits longer after each failure.
1706 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff: options.backoff }).kind !== "build") {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1707 result.waiting++;
1708 continue;
1709 }
1710 // A workspace without Deployments, or over its limit, waits for it
1711 // rather than gathering refused deployments.
1712 if (!open.has(project.workspace)) {
1713 const [plan, limit] = await Promise.all([
1714 billing.hasFeature(project.workspace, "deployments"),
1715 billing.checkLimit(project.workspace),
1716 ]);
1717 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1718 }
1719 if (!open.get(project.workspace)) {
1720 result.waiting++;
1721 continue;
1722 }
1723 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 look1724 const started =
1725 target.kind === "production"
1726 ? await this.deployProduction(project, target.commit, "g1t")
1727 : target.number != null
1728 ? 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 quotas1729 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1730 const outcome = rebuildOutcome(started);
1731 if (outcome === "queued") result.queued++;
1732 else if (outcome === "failed") {
1733 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1734 result.failed.push(`${named}: ${why}`);
1735 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1736 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1737 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1738 }
1739 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1740 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1741 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1742 }
1743
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1744 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1745 private async recentBuilds(script: string): Promise<PastBuild[]> {
1746 const rows = await this.db
1747 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1748 .bind(script, MAX_IDENTICAL_FAILURES)
1749 .all<PastBuild>();
1750 return rows.results;
1751 }
1752
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1753 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1754 private async projectIdsFor(repoId: string): Promise<string[]> {
1755 const rows = await this.db
1756 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1757 .bind(repoId)
1758 .all<{ project_id: string }>();
1759 return rows.results.map((row) => row.project_id);
1760 }
1761
1762 /**
1763 * A repository was deleted, restorable for a while: every app of its
1764 * projects (production and previews) comes down, builds under way are
1765 * dropped, and nothing builds for it until it is restored. Its settings
1766 * and custom domains are kept for the restore; until then a domain has
1767 * nothing up to serve, as when production is turned off.
1768 */
1769 private async repoDeleted(repoId: string): Promise<void> {
1770 const projectIds = await this.projectIdsFor(repoId);
1771 if (projectIds.length === 0) return;
1772 const at = now();
1773 await this.db.batch([
1774 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1775 this.db
1776 .prepare(
1777 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1778 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1779 )
1780 .bind(at, repoId),
1781 ]);
1782 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1783 }
1784
1785 /**
1786 * A deleted repository is back: production goes up again from its
1787 * default branch, for each project that has it on. Previews come back
1788 * with the next push to their pull requests.
1789 */
1790 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1791 const deleted = await this.db
1792 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1793 .bind(repoId)
1794 .all<{ project_id: string }>();
1795 const ids = deleted.results.map((row) => row.project_id);
1796 if (ids.length === 0) return;
1797 // The projects service hears of the restore on its own queue, and hides
1798 // the projects until then: wait for it a few deliveries.
1799 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1800 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1801 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1802 for (const project of projects) {
1803 try {
1804 const started = await this.deployProduction(project, null, "g1t");
1805 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1806 } catch (error) {
1807 console.error("could not deploy after restore", project.slug, error);
1808 }
1809 }
1810 }
1811
1812 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1813 * A workspace was deleted, restorable by g1t's staff for a while: every
1814 * app of its projects (production and previews) is paused, answering with
1815 * a notice and running nothing, builds under way are dropped, and nothing
1816 * builds for it until it is restored. Nothing is taken down: scripts,
1817 * settings and custom domains are kept for the restore. Never for a
1818 * protected workspace, whatever was published.
1819 */
1820 private async workspaceDeleting(slug: string): Promise<void> {
1821 const workspace = slug.toLowerCase();
1822 if (isProtectedWorkspace(workspace)) {
1823 console.error("workspace.deleting ignored for protected", workspace);
1824 return;
1825 }
1826 const at = now();
1827 await this.db.batch([
1828 this.db
1829 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1830 .bind(at, workspace),
1831 this.db
1832 .prepare(
1833 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1834 WHERE workspace = ? AND status IN ('queued', 'building')`,
1835 )
1836 .bind(at, workspace),
1837 ]);
1838 const cloudflare = this.cloudflare;
1839 for (const app of await this.appsOfWorkspace(workspace)) {
1840 if (app.paused_at) continue;
1841 await cloudflare?.pauseScript(app.script);
1842 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1843 }
1844 }
1845
1846 /**
1847 * A deleted workspace is back: it builds again, and its paused apps are
1848 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1849 * (the sweep tries again any it could not).
1850 */
1851 private async workspaceRestored(slug: string): Promise<void> {
1852 const workspace = slug.toLowerCase();
1853 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1854 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1855 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1856 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1857 }
1858
1859 /**
1860 * A deleted workspace is purged: whatever its projects still have up
1861 * comes down and their custom domains go, as for a purged repository.
1862 * Its own repositories' projects are purged with them (`repo.purged`);
1863 * this catches any building from a repository it had transferred away.
1864 */
1865 private async workspacePurged(slug: string): Promise<void> {
1866 const workspace = slug.toLowerCase();
1867 if (isProtectedWorkspace(workspace)) return;
1868 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1869 for (const { project_id } of rows.results) {
1870 await this.takeDownWhere(project_id, null);
1871 await this.domains.removeWhere("project_id", project_id);
1872 }
1873 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1874 await this.db.batch([
1875 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1876 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1877 ]);
1878 }
1879
1880 /** The apps of a workspace's projects, and any still under its name. */
1881 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1882 const rows = await this.db
1883 .prepare(
1884 `SELECT * FROM apps WHERE workspace = ?1
1885 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1886 )
1887 .bind(workspace)
1888 .all<AppRow>();
1889 return rows.results;
1890 }
1891
1892 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1893 * A deleted repository is gone for good: its projects' custom domains are
1894 * removed (from the dispatcher and from Cloudflare), any app or redirect
1895 * still up comes down, and every row kept for them goes. What they used
1896 * stays on their workspace's meter.
1897 */
1898 private async repoPurged(repoId: string): Promise<void> {
1899 const projectIds = await this.projectIdsFor(repoId);
1900 const domains = this.domains;
1901 for (const projectId of projectIds) {
1902 await this.takeDownWhere(projectId, null);
1903 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1904 await domains.removeWhere("project_id", projectId);
1905 }
1906 // The redirects left at names its apps had before.
1907 const scripts = await this.db
1908 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1909 .bind(repoId)
1910 .all<{ script: string }>();
1911 const hosts = scripts.results.map(({ script }) => appHost(script));
1912 for (let i = 0; i < hosts.length; i += 50) {
1913 const chunk = hosts.slice(i, i + 50);
1914 const redirects = await this.db
1915 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1916 .bind(...chunk)
1917 .all<{ script: string }>();
1918 for (const { script } of redirects.results) {
1919 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1920 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 look1921 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1922 }
1923 }
1924 await this.db.batch([
1925 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1926 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1927 ]);
1928 }
1929
1930 /**
1931 * The default branch is another one now: production is built from it, as
1932 * from a push to it, unless production already serves (or is building)
1933 * its commit, as when the default branch was only renamed.
1934 */
1935 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1936 for (const found of await this.projects.byRepo(repoId)) {
1937 if (found.source.kind !== "hosted") continue;
1938 // Projects may not have heard yet: the event names the branch.
1939 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1940 const actor = await this.workspaceActor(project.workspace);
1941 if (!actor) continue;
1942 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1943 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1944 if (!head) continue;
1945 const same = await this.db
1946 .prepare(
1947 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1948 AND status IN ('queued', 'building', 'ready')`,
1949 )
1950 .bind(project.id, head)
1951 .first();
1952 if (same) continue;
1953 await this.deployProduction(project, head, createdBy);
1954 }
1955 }
1956
1957 /**
1958 * A branch was renamed: its preview is the same app, so its rows follow.
1959 * The app keeps its name until it is next built; then it goes up under
1960 * the new branch's name, and the old one redirects there (see `supersede`).
1961 */
1962 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1963 const projectIds = await this.projectIdsFor(repoId);
1964 if (projectIds.length === 0) return;
1965 const ids = projectIds.map(() => "?").join(", ");
1966 await this.db.batch([
1967 this.db
1968 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1969 .bind(to, from, ...projectIds),
1970 this.db
1971 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1972 .bind(to, from, ...projectIds),
1973 ]);
1974 }
1975
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1976 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1977 private underSlug(project: Project, current: string, stale: string[]): Project {
1978 const source =
1979 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1980 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1981 : project.source;
1982 return { ...project, workspace: current, source };
1983 }
1984
1985 /** A stack's preview (no pull request of its own) built again at `commit`. */
1986 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1987 if (!actor || branch == null) return null;
1988 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1989 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1990 return this.start({
1991 project,
1992 kind: "preview",
1993 branch,
1994 number: null,
1995 commit,
1996 source: repoOf(project).path,
1997 reader: actor,
1998 createdBy: "g1t",
1999 settings,
2000 // As `stack` built it: the project's own default branch.
2001 trusted: true,
2002 });
2003 }
2004
Deployments: a preview for every pull request, production on g1t.page2005 // ---- The sweep -----------------------------------------------------
2006
2007 /**
2008 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page2009 * previews, the apps of workspaces whose plan ended, and scripts no app
2010 * holds come down; and a month that is over is charged past its
2011 * allowance.
Deployments: a preview for every pull request, production on g1t.page2012 */
2013 async sweep(): Promise<void> {
2014 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2015 const stuck = await this.db
2016 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2017 .bind(cutoff)
2018 .all<{ id: string }>();
2019 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2020
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2021 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2022 // Each app is its project's workspace's, as deployments has it now: an
2023 // app still under the name it had before its project moved is the new
2024 // workspace's, and plans and limits are checked there.
2025 const settings = new Map(
2026 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2027 );
2028 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 member2029 // A deleted workspace's apps stay paused as they are, for a restore:
2030 // its plan ended with the deletion, and that must not take them down.
2031 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2032 const live = apps.filter((app) => !held(app));
2033 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page2034
2035 // Apps of workspaces whose plan has ended come down.
2036 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2037 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page2038 for (const workspace of workspaces) {
2039 const plan = await billing.hasFeature(workspace, "deployments");
2040 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 member2041 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 domains2042 // Custom domains cost g1t by the month: they go with the plan.
2043 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.page2044 }
2045 }
2046
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2047 // Apps whose project moved and are not up under the new name yet: a
2048 // move's rebuild that could not start is tried again here.
2049 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2050 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2051
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2052 await this.domains
2053 .sweep(async (projectId) => {
2054 const app = await this.db
2055 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2056 .bind(projectId)
2057 .first<{ script: string }>();
2058 return app?.script ?? null;
2059 })
2060 .catch((error) => console.error("could not check domains", error));
2061
Projects: what a workspace builds and runs, first on every page2062 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page2063 await this.count(apps).catch((error) => console.error("could not count usage", error));
2064 await this.takeDownIdle();
2065 await this.chargeMonths();
2066 }
2067
Usage limits: unpaid usage can only go so far2068 /**
2069 * Pauses the apps of workspaces that reached their limit for usage not
2070 * yet paid for, and rebuilds them from the same commit once they are
2071 * 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 quotas2072 *
2073 * The workspace is the project's now (see `ownerOf`), never the one an
2074 * app's row was written under, so an app left under its old name after
2075 * a transfer is paused only if its new workspace is over its limit. Such
2076 * an app is resumed by being built under its new name (`followMoves`),
2077 * not here.
Usage limits: unpaid usage can only go so far2078 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2079 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far2080 const cloudflare = this.cloudflare;
2081 if (!cloudflare) return;
2082 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2083 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2084 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2085 const limit = await billing.checkLimit(workspace);
2086 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2087 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2088 if (limit.value.state === "stopped") {
2089 for (const app of theirs.filter((a) => !a.paused_at)) {
2090 await cloudflare.pauseScript(app.script);
2091 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2092 }
2093 continue;
2094 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2095 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2096 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2097 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2098 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2099 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2100 const project = projects.get(app.project_id);
2101 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2102 // A failed or refused rebuild leaves it paused, to try again later:
2103 // longer after each failure, and not once its commit is gone or it
2104 // failed the same way a few times.
2105 const history = await this.recentBuilds(app.script);
2106 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2107 const backoff = history[0]?.status === "failed";
2108 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
Usage limits: unpaid usage can only go so far2109 const rebuilt =
2110 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2111 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2112 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2113 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2114 : null;
2115 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2116 }
2117 }
2118 }
2119
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2120 /**
2121 * Scripts in the namespace that no app holds, such as ones renamed. An
2122 * old address that redirects to its app's new one is held until its
2123 * redirect expires, then removed with the rest.
2124 */
Projects: what a workspace builds and runs, first on every page2125 private async removeOrphans(apps: AppRow[]): Promise<void> {
2126 const cloudflare = this.cloudflare;
2127 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2128 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2129 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2130 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2131 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 page2132 const building = await this.db
2133 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2134 .all<{ script: string }>();
2135 for (const row of building.results) held.add(row.script);
2136 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2137 for (const script of await cloudflare.listScripts()) {
2138 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2139 }
2140 }
2141
Deployments: a preview for every pull request, production on g1t.page2142 /** Counts this month's requests and CPU time per workspace, from analytics. */
2143 private async count(apps: AppRow[]): Promise<void> {
2144 const cloudflare = this.cloudflare;
2145 if (!cloudflare || apps.length === 0) return;
2146 const start = `${month()}-01T00:00:00Z`;
2147 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2148 // Analytics only counts apps that are up; the meter keeps what earlier
2149 // apps used by never going down.
2150 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2151 for (const app of apps) {
2152 const used = totals.get(app.script);
2153 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2154 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2155 sum.requests += used.requests;
2156 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2157 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2158 }
2159 const at = now();
Projects: what a workspace builds and runs, first on every page2160 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page2161 await this.db
2162 .prepare(
2163 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2164 ON CONFLICT (namespace, month) DO UPDATE SET
2165 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2166 )
Projects: what a workspace builds and runs, first on every page2167 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2168 .run();
2169 }
2170 // When each preview last answered anyone, for the idle sweep.
2171 const recent = await cloudflare.usage(
2172 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2173 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2174 at,
2175 );
2176 for (const [script, used] of recent) {
2177 if (used.requests > 0) {
2178 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2179 }
2180 }
Projects: what a workspace builds and runs, first on every page2181 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 quotas2182 // What this month's traffic and custom domains will cost, from the
2183 // first request and the first domain, so the workspace's limit counts
2184 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2185 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2186 const billing = billingClient(this.env.BILLING);
2187 const meters = await this.db
2188 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2189 .bind(month())
2190 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2191 for (const meter of meters.results) {
2192 const cost = monthCost(meter, costs);
2193 await billing
2194 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2195 .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 quotas2196 if ((meter.peak_domains ?? 0) > 0) {
2197 await billing
2198 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2199 .catch((error) => console.error("could not note pending usage", error));
2200 }
Prices keep themselves current with what g1t pays2201 }
Deployments: a preview for every pull request, production on g1t.page2202 }
2203
Projects: what a workspace builds and runs, first on every page2204 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2205 private async takeDownIdle(): Promise<void> {
2206 const idle = await this.db
2207 .prepare(
Projects: what a workspace builds and runs, first on every page2208 `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 member2209 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2210 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2211 )
2212 .all<{ script: string }>();
2213 for (const { script } of idle.results) await this.removeApp(script);
2214 }
2215
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2216 /** 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.page2217 private async chargeMonths(): Promise<void> {
2218 const due = await this.db
2219 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2220 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2221 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2222 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2223 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2224 const cost = monthCost(meter, costs);
2225 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2226 const charged = await billingClient(this.env.BILLING).chargeFeature({
2227 workspace: meter.namespace,
2228 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2229 costMicros: cost.micros,
2230 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2231 reference: `deployments/${meter.namespace}/${meter.month}`,
2232 });
2233 if (!charged.ok) continue;
2234 }
2235 await this.db
2236 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2237 .bind(now(), meter.namespace, meter.month)
2238 .run();
2239 }
2240 }
2241}
2242
2243/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2244async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2245 switch (method) {
2246 case "settings":
2247 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2248 case "is_enabled":
2249 return service.isEnabled(args);
Deployments: a preview for every pull request, production on g1t.page2250 case "update_settings":
2251 return service.updateSettings(args);
2252 case "list":
2253 return service.list(args);
2254 case "get":
2255 return service.get(args);
2256 case "redeploy":
2257 return service.redeploy(args);
2258 case "take_down":
2259 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2260 case "stack":
2261 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2262 case "overview":
2263 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2264 case "usage":
2265 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2266 case "domains":
2267 return service.listDomains(args);
2268 case "add_domain":
2269 return service.addDomain(args);
2270 case "remove_domain":
2271 return service.removeDomain(args);
2272 case "refresh_domain":
2273 return service.refreshDomain(args);
Deployments: a preview for every pull request, production on g1t.page2274 default:
2275 return undefined;
2276 }
2277}
2278
2279export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2280 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2281 const { pathname } = new URL(request.url);
2282 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2283 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2284 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2285 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2286 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2287 const opened = openD1(env.DB, request);
2288 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2289 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2290 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.page2291 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2292 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2293 // A build's reports, forwarded by the API.
2294 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2295 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2296 return new Response("Not found\n", { status: 404 });
2297 },
2298
2299 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2300 const service = new Deployments(env);
2301 for (const message of batch.messages) {
2302 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2303 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2304 message.ack();
2305 } catch (error) {
2306 console.error("deployments could not handle", message.body.type, error);
2307 message.retry();
2308 }
2309 }
2310 },
2311
2312 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2313 await new Deployments(env).sweep();
2314 },
2315} satisfies ExportedHandler<Env, G1tEvent>;

This file's history is long; its oldest lines are credited to the oldest commit read.