Skip to content
2,387 lines107,331 bytesCodeBlameRaw

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";
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails82import { monthCost, movesMeter } 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";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9786import { RepoDeployments, sourceOf } from "./repo-deployments";
Deployments: a preview for every pull request, production on g1t.page87
88type Env = {
89 DB: D1Database;
90 REPOS: ServiceBinding;
91 WORK: ServiceBinding;
92 IDENTITY: ServiceBinding;
93 BILLING: ServiceBinding;
94 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page95 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments96 /** Secrets and variables: the actions service holds the one store. */
97 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published98 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
99 EVENTS?: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page100 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
101 CLOUDFLARE_API_TOKEN?: string;
102 CLOUDFLARE_ACCOUNT_ID: string;
103 DISPATCH_NAMESPACE: string;
104 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains105 /** Custom domains: hostname to app, read by the dispatcher. */
106 DOMAINS?: KVNamespace;
107 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
108 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look109 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
110 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page111};
112
113/** A build that has not reported in this long has died. */
114const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page115/** A script in the namespace that no app holds, older than this, is removed. */
116const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page117const LIST_LIMIT = 50;
118const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains119/**
120 * Deliveries of `workspace.renamed` that wait for the projects service to
121 * have seen it too, before going ahead with the new slug regardless.
122 */
123const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas124/** Why a build under way was dropped by a move; the move builds it again under the new name. */
125const MOVED_ERROR = "The repository moved: built again under its new name.";
126const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit127/**
128 * A move's rebuild that could not start, or failed, is tried again by the
129 * sweep after this long, doubled for each failure after the first, up to
130 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
131 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas132const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page133
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look134/** A build's report of what it found the project to be, if it is one g1t knows. */
135export function detectedKind(value: unknown): DetectedKind | null {
136 return value === "workers" || value === "static" || value === "html" ? value : null;
137}
138
Deployments: a preview for every pull request, production on g1t.page139const now = () => new Date().toISOString();
140const month = (at = new Date()) => at.toISOString().slice(0, 7);
141
142async function sha256(text: string): Promise<string> {
143 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
144 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
145}
146
147function randomToken(): string {
148 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
149}
150
151function isMember(viewer: Viewer, slug: string): boolean {
152 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
153}
154
Projects: what a workspace builds and runs, first on every page155/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look156/** One compute gate per isolate, so entitlements and prices are kept between calls. */
157let computeGate: ComputeGate | null = null;
158function gateFor(billing: ServiceBinding): ComputeGate {
159 computeGate ??= new ComputeGate(billing);
160 return computeGate;
161}
162
163/** The longest a build may run, in minutes: as long as its read token lasts. */
164const BUILD_MINUTES = 30;
165
166/**
167 * A build that was skipped before it started (its plan, its limit, or
168 * billing's refusal) as a failure, so whoever asked for it sees why.
169 */
170function notStarted(result: Result<Deployment>): Result<Deployment> {
171 if (result.ok && result.value.status === "skipped" && result.value.error) {
172 return fail("payment_required", result.value.error);
173 }
174 return result;
175}
176
Projects: what a workspace builds and runs, first on every page177function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
178 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
179 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
180}
181
Deployments: a preview for every pull request, production on g1t.page182type SettingsRow = {
Projects: what a workspace builds and runs, first on every page183 project_id: string;
184 workspace: string;
185 slug: string;
Deployments: a preview for every pull request, production on g1t.page186 repo_id: string;
187 enabled: number;
188 previews: number;
189 production: number;
190 build_command: string | null;
191 output_dir: string | null;
192 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look193 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
194 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member195 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
196 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page197};
198
199type DeploymentRow = {
200 id: string;
Projects: what a workspace builds and runs, first on every page201 project_id: string;
202 workspace: string;
203 slug: string;
Deployments: a preview for every pull request, production on g1t.page204 repo_id: string;
Projects: what a workspace builds and runs, first on every page205 repo: string;
Deployments: a preview for every pull request, production on g1t.page206 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page207 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page208 number: number | null;
209 commit_sha: string;
210 script: string;
211 status: DeployStatus;
212 error: string | null;
213 warnings: string;
214 log: string | null;
215 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments216 trusted: number;
Deployments: a preview for every pull request, production on g1t.page217 build_seconds: number | null;
218 created_by: string;
219 created_at: string;
220 finished_at: string | null;
221};
222
223type AppRow = {
224 script: string;
Projects: what a workspace builds and runs, first on every page225 project_id: string;
226 workspace: string;
227 slug: string;
Deployments: a preview for every pull request, production on g1t.page228 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page229 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page230 number: number | null;
231 commit_sha: string;
232 deployed_at: string;
233 created_at: string;
234 last_request_at: string | null;
Usage limits: unpaid usage can only go so far235 /** Set while its workspace is over its limit; see `holdToLimits`. */
236 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page237};
238
239function toDeployment(row: DeploymentRow): Deployment {
240 return {
241 id: row.id,
242 kind: row.kind,
Projects: what a workspace builds and runs, first on every page243 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page244 number: row.number,
245 commit: row.commit_sha,
246 status: row.status,
247 url: appUrl(row.script),
248 error: row.error,
249 warnings: JSON.parse(row.warnings || "[]") as string[],
250 buildSeconds: row.build_seconds,
251 createdBy: row.created_by,
252 createdAt: row.created_at,
253 finishedAt: row.finished_at,
254 };
255}
256
Projects: what a workspace builds and runs, first on every page257function toLive(app: AppRow): LiveApp {
258 return {
259 kind: app.kind,
260 branch: app.branch,
261 number: app.number,
262 url: appUrl(app.script),
263 commit: app.commit_sha,
264 deployedAt: app.deployed_at,
265 };
266}
267
Deployments: a preview for every pull request, production on g1t.page268class Deployments {
269 constructor(private readonly env: Env) {}
270
271 private get cloudflare(): Cloudflare | null {
272 const token = this.env.CLOUDFLARE_API_TOKEN;
273 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
274 }
275
276 private get db() {
277 return this.env.DB;
278 }
279
Agents and memory, checks and conflicts, profiles, slug renames, custom domains280 private get domains(): Domains {
281 const token = this.env.CLOUDFLARE_API_TOKEN;
282 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
283 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
284 }
285
Projects: what a workspace builds and runs, first on every page286 private get projects() {
287 return projectsClient(this.env.PROJECTS);
288 }
289
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97290 /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */
291 get repoDeployments(): RepoDeployments {
292 return new RepoDeployments(this.env);
293 }
294
295 /**
296 * Publishes a build's change in the repository-wide model
297 * (`deployment.created`, `deployment_status.created`), as a reported
298 * deployment's are. Never fails the build.
299 */
300 private async buildChanged(id: string, created = false): Promise<void> {
301 try {
302 const row = await this.deploymentRow(id);
303 if (!row) return;
304 const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id);
305 const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main";
306 await this.repoDeployments.buildChanged(row, defaultBranch, created);
307 } catch (error) {
308 console.error("build change not published", id, String(error));
309 }
310 }
311
Deployments: a preview for every pull request, production on g1t.page312 /** The workspace itself, as the service acts for it. */
313 private async workspaceActor(slug: string): Promise<User | null> {
314 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
315 if (!workspace) return null;
316 return {
317 id: workspace.id,
318 username: workspace.slug,
319 kind: "workspace",
320 verified: true,
321 workspaces: [{ slug: workspace.slug, role: "member" }],
322 };
323 }
324
Secrets and variables: one list, rows per environment, for workflows and deployments325 /**
Projects: what a workspace builds and runs, first on every page326 * What the project's secrets and variables available to deployments give
327 * production or a preview: its build's environment, and the same again as
328 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments329 */
330 private async resolve(
Projects: what a workspace builds and runs, first on every page331 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments332 environment: DeployKind,
333 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know334 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments335 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know336 const [rows, references] = await Promise.all([
337 this.rows(project, environment, trusted),
338 this.references(project.id, environment === "preview" ? branch : null),
339 ]);
340 // The project's own rows win over a dependency's address of the same name.
341 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
342 }
343
344 /**
345 * Each dependency's address, under the name the dependency gives it:
346 * for a preview, the same branch's preview of it if one is up, else its
347 * production; for production, its production.
348 */
349 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
350 const graph = await this.projects.graph(projectId).catch(() => null);
351 const out: Record<string, string> = {};
352 for (const dependency of graph?.dependsOn ?? []) {
353 if (!dependency.as) continue;
354 const app =
355 (branch
356 ? await this.db
357 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
358 .bind(dependency.id, branch)
359 .first<{ script: string }>()
360 : null) ??
361 (await this.db
362 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
363 .bind(dependency.id)
364 .first<{ script: string }>());
365 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
366 }
367 return out;
368 }
369
370 private async rows(
371 project: { id: string; slug: string; repoId: string; repo: RepoPath },
372 environment: DeployKind,
373 trusted: boolean,
374 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments375 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
376 method: "POST",
377 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page378 body: JSON.stringify({
379 repoId: project.repoId,
380 repo: project.repo,
381 projectId: project.id,
382 projectSlug: project.slug,
383 consumer: "deployments",
384 environment,
385 trusted,
386 }),
Secrets and variables: one list, rows per environment, for workflows and deployments387 });
388 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
389 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
390 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
391 }
392
393 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights394 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
395 * it, or its author) is trusted with the project's secrets: g1t itself,
396 * or someone who can push to the repository (Write or more, a member's or
397 * a collaborator's). Anyone else gets a preview built without them, as
398 * their workflows run. A change g1t made for someone is trusted as they
399 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments400 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights401 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
402 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look403 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 rights404 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look405 .catch(() => null);
406 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments407 }
408
Projects: what a workspace builds and runs, first on every page409 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
410 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page411 }
412
Projects: what a workspace builds and runs, first on every page413 /** The name an app gets, unique among apps: production, or a branch's preview. */
414 private async scriptFor(project: Project, branch: string | null): Promise<string> {
415 const base = await label(project.workspace, project.slug, branch);
416 const holder = await this.db
417 .prepare(
418 `SELECT project_id, branch FROM apps WHERE script = ?1
419 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
420 )
421 .bind(base)
422 .first<{ project_id: string; branch: string | null }>();
423 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
424 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
425 }
426
427 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page428 return {
429 enabled: !!row?.enabled,
430 previews: row ? !!row.previews : true,
431 production: row ? !!row.production : true,
432 buildCommand: row?.build_command ?? null,
433 outputDir: row?.output_dir ?? null,
434 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page435 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains436 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look437 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page438 };
439 }
440
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 /** What the project's last finished build found it to be; null before one has. */
442 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
443 const row = await this.db
444 .prepare(
445 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
446 )
447 .bind(projectId)
448 .first<{ detected: string }>()
449 .catch(() => null);
450 return detectedKind(row?.detected);
451 }
452
453 /**
454 * The project, if `viewer` may do what `method` needs on its repository
455 * (see `NEEDS`): not found when they cannot read it, refused when they can
456 * but their role is too low.
457 */
458 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
459 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
460 if (!found.ok) return found;
461 const repo = repoRef(found.value);
462 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
463 const capability = NEEDS[method];
464 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
465 return found;
Deployments: a preview for every pull request, production on g1t.page466 }
467
468 // ---- Methods for the site and the API ------------------------------
469
Projects: what a workspace builds and runs, first on every page470 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 look471 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page472 if (!project.ok) return project;
473 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page474 }
475
476 async updateSettings(a: {
477 actor: User;
Projects: what a workspace builds and runs, first on every page478 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page479 changes: Partial<DeploySettings>;
480 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look481 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page482 if (!found.ok) return found;
483 const project = found.value;
484 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page485 const next = { ...before, ...a.changes };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97486 // A library, a tool or other, as set in its settings, does not deploy.
487 if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") {
488 return fail("conflict", "This project is set to be something that doesn't deploy. Change what it is in its General settings first.");
A project is an app or a library: libraries show their package and how to ship a release, not production489 }
Deployments: a preview for every pull request, production on g1t.page490 if (next.enabled && !before.enabled) {
491 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page492 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page493 if (!plan.ok) return plan;
494 }
495 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
496 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
497 await this.db
498 .prepare(
Projects: what a workspace builds and runs, first on every page499 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
500 output_dir, idle_days, updated_by, updated_at)
501 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
502 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
503 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.page504 )
505 .bind(
Projects: what a workspace builds and runs, first on every page506 project.id,
507 project.workspace,
508 project.slug,
509 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page510 next.enabled ? 1 : 0,
511 next.previews ? 1 : 0,
512 next.production ? 1 : 0,
513 clip(next.buildCommand),
514 clip(next.outputDir),
515 idleDays,
516 a.actor.username,
517 now(),
518 )
519 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production520 // Projects keeps whether it deploys, so a project with Deployments on is an app.
521 if (next.enabled !== before.enabled) {
522 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
523 }
Deployments: a preview for every pull request, production on g1t.page524 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page525 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page526 else {
Projects: what a workspace builds and runs, first on every page527 if (!next.previews) await this.takeDownWhere(project.id, "preview");
528 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page529 }
530 // Turned on: production goes up from the default branch at once.
531 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page532 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page533 }
Projects: what a workspace builds and runs, first on every page534 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page535 }
536
A project is an app or a library: libraries show their package and how to ship a release, not production537 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
538 async isEnabled(a: { projectId: string }): Promise<boolean> {
539 return !!(await this.settingsRow(a.projectId))?.enabled;
540 }
541
Projects: what a workspace builds and runs, first on every page542 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 look543 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page544 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page545 const [deployments, apps] = await Promise.all([
546 this.db
Projects: what a workspace builds and runs, first on every page547 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
548 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page549 .all<DeploymentRow>(),
550 this.db
Projects: what a workspace builds and runs, first on every page551 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
552 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page553 .all<AppRow>(),
554 ]);
Projects: what a workspace builds and runs, first on every page555 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page556 }
557
Projects: what a workspace builds and runs, first on every page558 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 look559 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page560 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page561 const row = await this.db
Projects: what a workspace builds and runs, first on every page562 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
563 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page564 .first<DeploymentRow>();
565 if (!row) return fail("not_found", "No such deployment.");
566 return ok({ ...toDeployment(row), log: row.log });
567 }
568
Projects: what a workspace builds and runs, first on every page569 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 look570 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page571 if (!found.ok) return found;
572 const project = found.value;
573 const settings = await this.settingsRow(project.id);
574 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
575 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look576 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 page577 }
578 // A branch's preview comes from its pull request.
579 const app = await this.db
580 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
581 .bind(project.id, a.branch)
582 .first<{ number: number }>();
583 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 look584 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.page585 }
586
Project dependencies: addresses, preview stacks, Affects, and agents who know587 /**
588 * A preview stack: the projects that use this one get previews of their
589 * own default branch, under the same branch name, so each reaches this
590 * branch's preview through its dependency's variable. A change to an API
591 * can then be clicked through in the apps that call it.
592 */
593 async stack(
594 a: { actor: User; project: ProjectRef; branch: string },
595 background: (work: Promise<unknown>) => void,
596 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look597 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know598 if (!found.ok) return found;
599 const upstream = await this.db
600 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
601 .bind(found.value.id, a.branch)
602 .first();
603 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
604 const graph = await this.projects.graph(found.value.id);
605 const ready: { project: Project; settings: SettingsRow }[] = [];
606 for (const dependent of graph.usedBy) {
607 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 look608 // Each build spends compute on its own repository: only those the actor can run.
609 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know610 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look611 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know612 }
613 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
614 // The builds start after the answer: a person moving on from the page
615 // does not stop them.
616 background(
617 (async () => {
618 for (const { project, settings } of ready) {
619 const actor = await this.workspaceActor(project.workspace);
620 if (!actor) continue;
621 const repo = repoOf(project);
622 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
623 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
624 if (!head) continue;
625 await this.start({
626 project,
627 kind: "preview",
628 branch: a.branch,
629 number: null,
630 commit: head,
631 source: repo.path,
632 reader: actor,
633 createdBy: a.actor.username,
634 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look635 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know636 trusted: true,
637 });
638 }
639 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
640 );
641 return ok(ready.map(({ project }) => project.name));
642 }
643
Projects: what a workspace builds and runs, first on every page644 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 look645 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page646 if (!project.ok) return project;
647 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page648 return ok(true);
649 }
650
Projects: what a workspace builds and runs, first on every page651 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
652 const workspace = a.workspace.toLowerCase();
653 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 look654 // Only the projects whose repositories the viewer can read: the
655 // projects service lists no others.
656 const listed = await this.projects.list(workspace, a.viewer);
657 if (!listed.ok) return listed;
658 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page659 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look660 // A deleted repository's projects are hidden until it is restored.
661 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 page662 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
663 this.db
664 .prepare(
665 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
666 )
667 .bind(workspace)
668 .all<DeploymentRow>(),
669 ]);
670 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look671 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page672 const own = apps.results.filter((app) => app.slug === row.slug);
673 const production = own.find((app) => app.kind === "production");
674 const newest = latest.results.find((d) => d.slug === row.slug);
675 return {
676 slug: row.slug,
677 enabled: !!row.enabled,
678 production: production ? toLive(production) : null,
679 previews: own.filter((app) => app.kind === "preview").length,
680 latest: newest ? toDeployment(newest) : null,
681 };
682 }),
683 );
684 }
685
Deployments: a preview for every pull request, production on g1t.page686 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
687 const slug = a.workspace.toLowerCase();
688 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
689 const [meter, apps] = await Promise.all([
690 this.db
691 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
692 .bind(slug, month())
693 .first<{
694 requests: number;
695 cpu_ms: number;
696 peak_apps: number;
697 build_seconds: number;
698 build_micros: number;
699 counted_at: string | null;
700 }>(),
Projects: what a workspace builds and runs, first on every page701 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.page702 ]);
703 return ok({
704 month: month(),
705 requests: meter?.requests ?? 0,
706 cpuMs: meter?.cpu_ms ?? 0,
707 apps: apps?.n ?? 0,
708 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
709 buildSeconds: meter?.build_seconds ?? 0,
710 buildMicros: meter?.build_micros ?? 0,
711 countedAt: meter?.counted_at ?? null,
712 });
713 }
714
Agents and memory, checks and conflicts, profiles, slug renames, custom domains715 // ---- Custom domains ------------------------------------------------
716
717 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 look718 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains719 if (!project.ok) return project;
720 const domains = this.domains;
721 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 quotas722 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains723 domains.forProject(project.value.id),
724 domains.available(),
725 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas726 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains727 ]);
728 return ok({
729 domains: rows.map(toDomain),
730 target: CUSTOM_DOMAIN_TARGET,
731 available,
732 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas733 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains734 used,
735 });
736 }
737
738 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 look739 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains740 if (!found.ok) return found;
741 const project = found.value;
742 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
743 if (!plan.ok) return plan;
744 const added = await this.domains.add({
745 project: { id: project.id, workspace: project.workspace, slug: project.slug },
746 script: await this.productionScript(project),
747 hostname: String(a.hostname ?? ""),
748 twin: !!a.twin,
749 by: a.actor.username,
750 });
751 if (!added.ok) return fail(added.code, added.message);
752 await this.notePeak(project.workspace);
753 return ok(added.rows.map(toDomain));
754 }
755
756 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 look757 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains758 if (!found.ok) return found;
759 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
760 if (!row) return fail("not_found", "No such domain.");
761 await this.domains.remove(row);
762 return ok(true);
763 }
764
765 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 look766 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains767 if (!found.ok) return found;
768 const domains = this.domains;
769 const row = await domains.byId(found.value.id, String(a.id ?? ""));
770 if (!row) return fail("not_found", "No such domain.");
771 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
772 await this.notePeak(found.value.workspace);
773 return ok(toDomain(after));
774 }
775
776 /** The script production is up under, or the name it will have. */
777 private async productionScript(project: Project): Promise<string> {
778 const app = await this.db
779 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
780 .bind(project.id)
781 .first<{ script: string }>();
782 return app?.script ?? (await this.scriptFor(project, null));
783 }
784
Deployments: a preview for every pull request, production on g1t.page785 // ---- Starting builds -----------------------------------------------
786
787 /**
788 * Opens a deployment and starts its build. Skipped, with the reason
789 * recorded, when the workspace's plan is off.
790 */
791 private async start(input: {
Projects: what a workspace builds and runs, first on every page792 project: Project;
Deployments: a preview for every pull request, production on g1t.page793 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page794 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page795 number: number | null;
796 commit: string;
797 source: RepoPath;
798 reader: User;
799 createdBy: string;
800 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page801 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments802 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page803 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page804 const { project } = input;
805 const repo = repoOf(project);
806 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page807 const id = newId("dpl");
808 const token = randomToken();
Projects: what a workspace builds and runs, first on every page809 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far810 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page811 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look812 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page813 ? plan.error.message
Usage limits: unpaid usage can only go so far814 : limit.ok && limit.value.state === "stopped"
815 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page816 : !cloudflare
817 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
818 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look819 // A build is compute: reserved with billing before it starts, and
820 // settled by its sandbox when it stops. A refusal is the deployment's
821 // status, saying what to do.
822 const compute = gateFor(this.env.BILLING);
823 let reservation: string | null = null;
824 let microsPerSecond = 0;
825 let maxRunMinutes: number | null = null;
826 if (!refused) {
827 const ent = await compute.entitlements(project.workspace);
828 microsPerSecond = await compute.microsPerSecond();
829 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
830 const isPrivate = await reposClient(this.env.REPOS)
831 .get(repo.path, null)
832 .then((found) => !found.ok || found.value.isPrivate)
833 .catch(() => true);
834 const admitted = await compute.admit(
835 {
836 workspace: project.workspace,
837 repo: repo.path,
838 public: !isPrivate,
839 kind: "deploy",
840 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
841 },
842 ent,
843 );
844 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
845 else refused = admitted.message;
846 }
Deployments: a preview for every pull request, production on g1t.page847 await this.db
848 .prepare(
Projects: what a workspace builds and runs, first on every page849 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
850 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
851 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page852 )
853 .bind(
854 id,
Projects: what a workspace builds and runs, first on every page855 project.id,
856 project.workspace,
857 project.slug,
858 repo.id,
859 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page860 input.kind,
Projects: what a workspace builds and runs, first on every page861 input.branch,
Deployments: a preview for every pull request, production on g1t.page862 input.number,
863 input.commit,
864 script,
865 refused ? "skipped" : "queued",
866 refused,
867 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments868 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page869 input.createdBy,
870 now(),
871 refused ? now() : null,
872 )
873 .run();
874 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
875 // Older builds of the same app are replaced by this one.
876 await this.db
877 .prepare(
878 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
879 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
880 )
881 .bind(now(), script, id)
882 .run();
Projects: what a workspace builds and runs, first on every page883 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97884 await this.buildChanged(id, true);
Projects: what a workspace builds and runs, first on every page885 // What the project's secrets and variables give builds of this kind.
886 const build = await this.resolve(
887 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
888 input.kind,
889 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know890 input.branch,
Projects: what a workspace builds and runs, first on every page891 );
Deployments: a preview for every pull request, production on g1t.page892 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
893 method: "POST",
894 headers: { "content-type": "application/json" },
895 body: JSON.stringify({
896 deployId: id,
897 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look898 workspace: project.workspace,
899 reservation,
900 microsPerSecond,
901 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page902 actor: input.reader,
903 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas904 // The project, whose guardrails the build runs under: a preview's
905 // source is its pull request's working copy, not the project.
906 repo: repo.path,
907 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page908 commit: input.commit,
Projects: what a workspace builds and runs, first on every page909 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page910 buildCommand: input.settings.build_command,
911 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments912 buildEnv: build.variables,
913 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page914 }),
915 });
916 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 look917 if (!started.ok) {
918 // Never reached a sandbox: what was reserved is given back.
919 if (reservation) await compute.settle(reservation, 0);
920 await this.finishFailed(id, started.error.message, null, null);
921 }
Deployments: a preview for every pull request, production on g1t.page922 return ok(toDeployment((await this.deploymentRow(id))!));
923 }
924
Projects: what a workspace builds and runs, first on every page925 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
926 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 member927 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 page928 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page929 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page930 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page931 let head = commit;
932 if (!head) {
Projects: what a workspace builds and runs, first on every page933 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
934 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page935 }
936 if (!head) return null;
937 return this.start({
Projects: what a workspace builds and runs, first on every page938 project,
Deployments: a preview for every pull request, production on g1t.page939 kind: "production",
Projects: what a workspace builds and runs, first on every page940 branch: null,
Deployments: a preview for every pull request, production on g1t.page941 number: null,
942 commit: head,
Projects: what a workspace builds and runs, first on every page943 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page944 reader: actor,
945 createdBy,
946 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments947 // The default branch only moves by people and agents with access.
948 trusted: true,
Deployments: a preview for every pull request, production on g1t.page949 });
950 }
951
Projects: what a workspace builds and runs, first on every page952 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
953 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 member954 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 page955 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page956 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page957 const repo = repoOf(project);
958 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page959 if (!detail.ok) return null;
960 const { pull } = detail.value;
961 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page962 // A pull request from a fork (as g1t's agents work) has no branch here.
963 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page964 if (!force) {
965 // Already built, or being built, at this commit.
966 const same = await this.db
967 .prepare(
Projects: what a workspace builds and runs, first on every page968 `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.page969 AND status IN ('queued', 'building', 'ready')`,
970 )
Projects: what a workspace builds and runs, first on every page971 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page972 .first();
973 if (same) return null;
974 }
975 return this.start({
Projects: what a workspace builds and runs, first on every page976 project,
Deployments: a preview for every pull request, production on g1t.page977 kind: "preview",
Projects: what a workspace builds and runs, first on every page978 branch,
Deployments: a preview for every pull request, production on g1t.page979 number,
980 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page981 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 rights982 // The pull request's fork may be private: read it as whoever it is
983 // for (whoever asked g1t for it, or its author), who is also who is
984 // trusted or not with the project's secrets.
985 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page986 createdBy,
987 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights988 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page989 });
990 }
991
992 // ---- A build's reports ---------------------------------------------
993
994 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
995 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
996 }
997
998 /** The build, if `token` is its own and it is still under way. */
999 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
1000 const row = await this.deploymentRow(id);
1001 if (!row?.token_hash || typeof token !== "string") return null;
1002 if (row.token_hash !== (await sha256(token))) return null;
1003 return row.status === "queued" || row.status === "building" ? row : null;
1004 }
1005
1006 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run1007 // Each report, for the logs: a build's own failure says why.
1008 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page1009 const row = await this.building(id, body.token);
1010 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
1011 const cloudflare = this.cloudflare;
1012 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
1013 switch (step) {
1014 case "started":
1015 await this.db
1016 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
1017 .bind(now(), id)
1018 .run();
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971019 await this.buildChanged(id);
Deployments: a preview for every pull request, production on g1t.page1020 return Response.json(ok(true));
1021 case "session": {
1022 const manifest = body.manifest as Manifest | undefined;
1023 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
1024 const session = await cloudflare.openUpload(row.script, manifest);
1025 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
1026 }
1027 case "finish": {
1028 const worker = (body.worker ?? {}) as BuiltWorker;
1029 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page1030 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page1031 try {
Secrets and variables: one list, rows per environment, for workflows and deployments1032 // Running apps' secrets and variables are bound here, by g1t:
1033 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page1034 const runtime = await this.resolve(
1035 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1036 row.kind,
1037 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know1038 row.branch,
Projects: what a workspace builds and runs, first on every page1039 );
Deployments: a preview for every pull request, production on g1t.page1040 await cloudflare.putScript(
1041 row.script,
1042 worker,
1043 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page1044 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments1045 runtime,
Deployments: a preview for every pull request, production on g1t.page1046 );
1047 } catch (error) {
1048 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1049 return Response.json(ok(false));
1050 }
1051 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1052 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.page1053 await this.db.batch([
1054 this.db
1055 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1056 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1057 WHERE id = ?`,
1058 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1059 .bind(
1060 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1061 String(body.log ?? ""),
1062 seconds,
1063 at,
1064 detectedKind(body.detected),
1065 id,
1066 ),
Builds say Replaced or Down once they are no longer live1067 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1068 this.db
Builds say Replaced or Down once they are no longer live1069 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1070 .bind(row.script, id),
1071 this.db
Deployments: a preview for every pull request, production on g1t.page1072 .prepare(
Projects: what a workspace builds and runs, first on every page1073 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1074 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1075 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.page1076 )
Projects: what a workspace builds and runs, first on every page1077 .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 quotas1078 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1079 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1080 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1081 if (wasRedirect) {
1082 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1083 }
1084 // The same app at an older name (its project moved) now redirects
1085 // here; it stays up as it was if the redirect cannot be put.
1086 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1087 // The project's own domains serve production wherever it is up.
1088 if (row.kind === "production") {
1089 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1090 }
Deployments: a preview for every pull request, production on g1t.page1091 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1092 await this.notePeak(row.workspace);
1093 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 published1094 await this.announce(row, null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971095 await this.buildChanged(id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1096 // A screenshot of production as it now is, for the project's overview.
1097 if (row.kind === "production") {
1098 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1099 console.error("could not ask for a screenshot", error),
1100 );
1101 }
Deployments: a preview for every pull request, production on g1t.page1102 return Response.json(ok(true));
1103 }
1104 case "fail":
1105 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1106 return Response.json(ok(true));
1107 default:
1108 return Response.json(fail("not_found", "No such step."), { status: 404 });
1109 }
1110 }
1111
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1112 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1113 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1114 * 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 quotas1115 * same is the app under a name it had before its project moved (its
1116 * workspace renamed, its repository renamed or transferred). Each is
1117 * replaced in the namespace by a redirect to the new name, its app row
1118 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1119 * the sweep to hold the old script) and in `DOMAINS` under the old
1120 * hostname, which the dispatcher follows before running anything, so the
1121 * old address redirects even if the old script is paused. A name that
1122 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1123 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1124 private async supersede(
1125 cloudflare: Cloudflare,
1126 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1127 script: string,
1128 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1129 const older = await this.db
1130 .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 quotas1131 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1132 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1133 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1134 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1135 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1136 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1137 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1138 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1139 console.error("could not redirect", old, "to", script, error);
1140 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1141 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1142 const at = now();
1143 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1144 await this.db.batch([
1145 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1146 this.db
1147 .prepare(
1148 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1149 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1150 )
1151 .bind(old, target, app.workspace, at, expires),
1152 // Its builds are no longer live under the old name.
1153 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1154 ]);
1155 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1156 expiration: Math.floor(Date.parse(expires) / 1000),
1157 }).catch((error) => console.error("could not record redirect", old, error));
1158 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1159 }
1160 return done;
1161 }
1162
Deployments: a preview for every pull request, production on g1t.page1163 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1164 const row = await this.deploymentRow(id);
1165 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1166 // A commit that is gone says so plainly; git's own words stay in the log.
1167 if (commitMissing(message)) {
1168 log = log ?? message;
1169 message = missingCommitMessage(row);
1170 }
Deployments: a preview for every pull request, production on g1t.page1171 await this.db
1172 .prepare(
1173 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1174 WHERE id = ?`,
1175 )
1176 .bind(message.slice(0, 2000), log, seconds, now(), id)
1177 .run();
1178 // A failed build still used its sandbox.
1179 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1180 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1181 await this.announce(row, message.slice(0, 300));
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971182 await this.buildChanged(id);
Events: review requests, assignments, stops and deployments are published1183 }
1184
1185 /**
1186 * Publishes a finished build: `deployment.failed` with what went wrong,
1187 * or `deployment.succeeded`, saying whether the build of the same app
1188 * before it failed. Whoever started it is the actor when it was a
1189 * person known by id; otherwise `triggeredBy` names them, or g1t.
1190 */
1191 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1192 if (!this.env.EVENTS) return;
1193 const previous = error
1194 ? null
1195 : await this.db
1196 .prepare(
1197 `SELECT status FROM deployments
1198 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1199 ORDER BY created_at DESC LIMIT 1`,
1200 )
1201 .bind(row.script, row.id, row.created_at)
1202 .first<{ status: string }>();
1203 const byId = row.created_by.startsWith("usr_");
1204 const event = {
1205 source: "deployments",
1206 repoId: row.repo_id,
1207 actor: byId ? row.created_by : null,
1208 data: {
1209 deploymentId: row.id,
1210 projectId: row.project_id,
1211 repoId: row.repo_id,
1212 workspace: row.workspace,
1213 project: row.slug,
1214 kind: row.kind,
1215 branch: row.branch,
1216 number: row.kind === "preview" ? row.number : null,
1217 commit: row.commit_sha,
1218 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1219 error,
1220 recovered: previous?.status === "failed",
1221 triggeredBy: byId ? "" : row.created_by,
1222 },
1223 };
1224 await eventsClient(this.env.EVENTS)
1225 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1226 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Deployments: a preview for every pull request, production on g1t.page1227 }
1228
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1229 /** 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.page1230 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1231 const costs = await this.costs();
1232 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1233 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1234 const what =
1235 row.kind === "preview"
1236 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1237 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1238 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1239 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1240 feature: "deployments",
1241 costMicros: cost,
1242 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1243 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1244 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1245 // Every second is metered; billing prices it from its price book and
1246 // 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 put1247 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1248 });
1249 await this.db
1250 .prepare(
1251 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1252 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1253 )
Projects: what a workspace builds and runs, first on every page1254 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1255 .run();
1256 }
1257
Prices keep themselves current with what g1t pays1258 /**
1259 * What each unit costs g1t now, from billing's price book, which follows
1260 * what Cloudflare bills. The plan's figures if billing cannot say.
1261 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1262 private async costs(): Promise<{
1263 buildSecond: number;
1264 millionRequests: number;
1265 millionCpuMs: number;
1266 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1267 /** What one custom domain is charged a month: the cost plus the margin. */
1268 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1269 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1270 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1271 const book = await billingClient(this.env.BILLING)
1272 .prices()
1273 .catch(() => null);
1274 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1275 return {
1276 buildSecond: cost("build_second", a.microsPerBuildSecond),
1277 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1278 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1279 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 quotas1280 domainMonthPrice:
1281 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1282 };
1283 }
1284
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1285 /** 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 page1286 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1287 await this.db
1288 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1289 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1290 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1291 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1292 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1293 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1294 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.page1295 )
Projects: what a workspace builds and runs, first on every page1296 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1297 .run();
1298 }
1299
Projects: what a workspace builds and runs, first on every page1300 /**
1301 * The check on the commit: `g1t / deploy`, or, for one of several
1302 * projects on a repository, `g1t / deploy (<project>)`.
1303 */
1304 private async status(
1305 repoId: string,
1306 sha: string,
1307 project: { slug: string; primary: boolean },
1308 state: string,
1309 description: string,
1310 targetUrl: string,
1311 ): Promise<void> {
1312 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1313 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1314 method: "POST",
1315 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1316 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
Deployments: a preview for every pull request, production on g1t.page1317 }).catch(() => undefined);
1318 }
1319
Projects: what a workspace builds and runs, first on every page1320 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1321 const projects = await this.projects.byRepo(row.repo_id);
1322 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1323 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1324 }
1325
Deployments: a preview for every pull request, production on g1t.page1326 // ---- Taking apps down ----------------------------------------------
1327
1328 private async removeApp(script: string): Promise<void> {
1329 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1330 await this.db.batch([
1331 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1332 // Its build is no longer live anywhere.
1333 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1334 ]);
Deployments: a preview for every pull request, production on g1t.page1335 }
1336
Projects: what a workspace builds and runs, first on every page1337 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1338 const apps = await this.db
1339 .prepare(
Projects: what a workspace builds and runs, first on every page1340 `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.page1341 )
Projects: what a workspace builds and runs, first on every page1342 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1343 .all<{ script: string }>();
1344 for (const app of apps.results) await this.removeApp(app.script);
1345 }
1346
1347 // ---- Events --------------------------------------------------------
1348
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1349 /** `attempts`: which delivery of the event this is, from 1. */
1350 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1351 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1352 case "workspace.renamed":
1353 await this.renamed(event.data, attempts);
1354 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1355 case "repo.transferred":
1356 case "repo.renamed":
1357 await this.moved(repoMove(event)!, attempts);
1358 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1359 // A repository that went or came back with its workspace is the
1360 // 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 look1361 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1362 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 look1363 break;
1364 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1365 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 look1366 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1367 case "workspace.deleting":
1368 await this.workspaceDeleting(event.data.slug);
1369 break;
1370 case "workspace.restored":
1371 await this.workspaceRestored(event.data.slug);
1372 break;
1373 case "workspace.deleted":
1374 await this.workspacePurged(event.data.slug);
1375 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1376 case "repo.purged":
1377 await this.repoPurged(event.data.repoId);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971378 await this.repoDeployments.purge(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1379 break;
1380 case "repo.default_branch_changed":
1381 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1382 break;
1383 case "branch.renamed":
1384 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1385 break;
1386
Deployments: a preview for every pull request, production on g1t.page1387 case "pull.opened":
1388 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1389 case "pull.updated":
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1390 case "pull.reopened":
Projects: what a workspace builds and runs, first on every page1391 for (const project of await this.projects.byRepo(event.data.repoId)) {
1392 await this.deployPreview(project, event.data.number, "g1t");
1393 }
Deployments: a preview for every pull request, production on g1t.page1394 break;
1395 case "pull.closed":
1396 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1397 for (const project of await this.projects.byRepo(event.data.repoId)) {
1398 const apps = await this.db
1399 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1400 .bind(project.id, event.data.number)
1401 .all<{ script: string }>();
1402 for (const app of apps.results) await this.removeApp(app.script);
1403 }
Deployments: a preview for every pull request, production on g1t.page1404 break;
Projects: what a workspace builds and runs, first on every page1405 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1406 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1407 for (const project of await this.projects.byRepo(event.data.repoId)) {
1408 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1409 }
Deployments: a preview for every pull request, production on g1t.page1410 break;
1411 }
1412 }
1413
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1414 /**
1415 * A workspace's slug changed, and with it every app's name: production
1416 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1417 *
1418 * Its rows move to the slug it has now (asked of identity, so a delivery
1419 * 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 quotas1420 * each app (paused or not) is built again from the same commit under its
1421 * new name; see `followMoves`. The old name keeps serving the app until
1422 * the new one is live; then it redirects to the new name (see
1423 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1424 * 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 quotas1425 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1426 */
1427 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1428 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1429 const stale = staleSlugs(renamed, current);
1430 if (stale.length === 0) return;
1431 const marks = stale.map(() => "?").join(", ");
1432
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1433 // The workspace's projects, under any of its names: a second delivery
1434 // finds them under the new one, with whatever is left to do.
1435 const rows = await this.db
1436 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1437 .bind(...stale, current)
1438 .all<{ project_id: string }>();
1439 const projectIds = rows.results.map((row) => row.project_id);
1440 const projects = await this.projectsById(projectIds);
1441 // The projects service hears of the rename on its own queue: wait for
1442 // it a few deliveries, so the builds read the repository by its new name.
1443 const behind = [...projects.values()].some((p) => p.workspace !== current);
1444 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 domains1445
1446 // Every row moves at once.
1447 const at = now();
1448 const statements: D1PreparedStatement[] = [];
1449 for (const slug of stale) {
1450 statements.push(
1451 this.db
1452 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1453 `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 domains1454 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1455 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1456 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1457 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1458 this.db
1459 .prepare(
1460 `UPDATE deployments SET workspace = ?1,
1461 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1462 WHERE workspace = ?2`,
1463 )
1464 .bind(current, slug),
1465 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1466 this.domains.rename(slug, current),
1467 // Counters add up; the peak is the higher; a month charged stays charged.
1468 this.db
1469 .prepare(
1470 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1471 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1472 FROM meters WHERE namespace = ?2
1473 ON CONFLICT (namespace, month) DO UPDATE SET
1474 requests = requests + excluded.requests,
1475 cpu_ms = cpu_ms + excluded.cpu_ms,
1476 peak_apps = MAX(peak_apps, excluded.peak_apps),
1477 peak_domains = MAX(peak_domains, excluded.peak_domains),
1478 build_seconds = build_seconds + excluded.build_seconds,
1479 build_micros = build_micros + excluded.build_micros,
1480 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1481 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1482 )
1483 .bind(current, slug),
1484 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1485 );
1486 }
1487 await this.db.batch(statements);
1488
1489 // Each app again, under its new name. Its dependencies' addresses are
1490 // 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 quotas1491 const followed = await this.followMoves(projectIds, {
1492 projects,
1493 adjust: (project) => this.underSlug(project, current, stale),
1494 });
1495 if (followed.failed.length > 0) {
1496 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1497 }
1498 }
1499
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1500 /**
1501 * A repository moved: transferred to another workspace, and its projects
1502 * with it, or renamed within its own, when its own project's slug follows
1503 * its name (see the projects service). Each app's name is
1504 * `<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 quotas1505 * slug changed (production and every preview, paused or not) are built
1506 * again, from the same commit, under the new name; see `followMoves`. As
1507 * after a workspace rename, the old name keeps serving until the new one
1508 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1509 * The project's custom domains follow its production. Builds under way
1510 * are started again under the new name. What the apps used this month
1511 * stays on the old workspace's meter; from now on, the new workspace's
1512 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1513 *
1514 * Projects whose name did not change (a rename where the new name was
1515 * 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 quotas1516 * path. What is left to rebuild is read from the rows each time, so a
1517 * second or late delivery builds only what the first could not, and one
1518 * whose rebuild could not be queued is delivered again (and, past the
1519 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1520 */
1521 private async moved(move: RepoMove, attempts: number): Promise<void> {
1522 const current = await currentMovedPath(this.env.REPOS, move);
1523 if (staleMovedPaths(move, current).length === 0) return;
1524 const [workspace, name] = current.split("/") as [string, string];
1525 const settings = await this.db
1526 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1527 .bind(move.repoId)
1528 .all<{ project_id: string; workspace: string; slug: string }>();
1529 if (settings.results.length === 0) return;
1530
1531 // The projects service hears of the move on its own queue: wait for it
1532 // a few deliveries, so builds read the project where and as it is now.
1533 const projects = new Map<string, Project>();
1534 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1535 const behind = settings.results.some(({ project_id }) => {
1536 const project = projects.get(project_id);
1537 if (!project || project.source.kind !== "hosted") return false;
1538 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1539 });
1540 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1541
1542 // Its history goes with it, as the repository's issues do.
1543 const statements: D1PreparedStatement[] = [
1544 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1545 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1546 // The projects whose apps' names change, and have not been moved yet.
1547 const moving = settings.results
1548 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1549 .map((row) => row.project_id);
1550 if (moving.length > 0) {
1551 const ids = moving.map(() => "?").join(", ");
1552 statements.push(
1553 this.db
1554 .prepare(
1555 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1556 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1557 )
1558 .bind(MOVED_ERROR, now(), ...moving),
1559 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1560 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1561 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1562 const slug = projects.get(projectId)?.slug ?? null;
1563 statements.push(
1564 this.db
1565 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1566 .bind(workspace, slug, projectId),
1567 this.db
1568 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1569 .bind(workspace, slug, projectId),
1570 this.db
1571 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1572 .bind(workspace, slug, projectId),
1573 // Within the workspace its apps are listed under the project's name
1574 // now. One left behind in another workspace stays as it is until the
1575 // new name is live and redirects it.
1576 this.db
1577 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1578 .bind(workspace, slug, projectId),
1579 );
1580 }
1581 await this.db.batch(statements);
1582
1583 // Each app again, under its new name. App rows under the old name stay
1584 // 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 quotas1585 const followed = await this.followMoves(
1586 settings.results.map((row) => row.project_id),
1587 {
1588 projects,
1589 adjust: (found) =>
1590 found.source.kind === "hosted"
1591 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1592 : { ...found, workspace },
1593 },
1594 );
1595 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1596 }
1597
1598 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1599 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1600 const projects = new Map<string, Project>();
1601 if (projectIds.length === 0) return projects;
1602 const repoIds = new Set<string>();
1603 for (let i = 0; i < projectIds.length; i += 50) {
1604 const chunk = projectIds.slice(i, i + 50);
1605 const rows = await this.db
1606 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1607 .bind(...chunk)
1608 .all<{ repo_id: string }>();
1609 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1610 }
1611 const wanted = new Set(projectIds);
1612 for (const repoId of repoIds) {
1613 for (const project of await this.projects.byRepo(repoId)) {
1614 if (wanted.has(project.id)) projects.set(project.id, project);
1615 }
1616 }
1617 return projects;
1618 }
1619
1620 /** Whether `script` is the name an app of the project has where the project is now. */
1621 private async namedNow(
1622 script: string,
1623 settings: { project_id: string; workspace: string; slug: string },
1624 branch: string | null,
1625 ): Promise<boolean> {
1626 const base = await label(settings.workspace, settings.slug, branch);
1627 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1628 }
1629
1630 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1631 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1632 const stale: AppRow[] = [];
1633 for (const app of apps) {
1634 const row = settings.get(app.project_id);
1635 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1636 }
1637 return stale;
1638 }
1639
1640 /**
1641 * Brings the apps of the projects `projectIds` (every project when null)
1642 * under the names they have where the projects are now: each app still
1643 * under an older name, paused or not, and each build a move dropped, is
1644 * built again under its new name from the same commit, and once that is
1645 * live the old name redirects to it (`supersede`).
1646 *
1647 * Idempotent, and safe to run again at any time: an app already up under
1648 * its new name only has its old names redirected; one whose rebuild is
1649 * queued or under way is left to it; one whose workspace has no
1650 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1651 * each time; one whose commit is gone, or whose rebuild failed the same
1652 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1653 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1654 * 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 quotas1655 * event's delivery to be retried.
1656 */
1657 private async followMoves(
1658 projectIds: string[] | null,
1659 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1660 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1661 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1662 if (projectIds && projectIds.length === 0) return result;
1663 const cloudflare = this.cloudflare;
1664 if (!cloudflare) return result;
1665
1666 const settingsRows =
1667 projectIds == null
1668 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1669 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1670 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1671 if (settings.size === 0) return result;
1672 const ids = [...settings.keys()];
1673
1674 const apps: AppRow[] = [];
1675 const dropped: DroppedBuild[] = [];
1676 for (let i = 0; i < ids.length; i += 50) {
1677 const chunk = ids.slice(i, i + 50);
1678 const marks = chunk.map(() => "?").join(", ");
1679 const [appRows, droppedRows] = await Promise.all([
1680 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1681 // Builds a move dropped, that nothing has been built in place of since.
1682 this.db
1683 .prepare(
1684 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1685 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1686 AND NOT EXISTS (
1687 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1688 AND n.created_at > d.created_at AND n.status != 'skipped'
1689 )`,
1690 )
1691 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1692 .all<DeploymentRow>(),
1693 ]);
1694 apps.push(...appRows.results);
1695 dropped.push(...droppedRows.results);
1696 }
1697 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1698 if (targets.length === 0) return result;
1699
1700 const projects = new Map(options.projects ?? []);
1701 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1702 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1703 const billing = billingClient(this.env.BILLING);
1704 const open = new Map<string, boolean>();
1705 const actors = new Map<string, User | null>();
1706
1707 for (const target of targets) {
1708 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1709 const found = projects.get(target.projectId);
1710 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1711 const project = options.adjust ? options.adjust(found) : found;
1712 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1713 // Built only where deployments has the project now; the projects
1714 // service catches up on its own queue, and a later run builds then.
1715 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1716 result.waiting++;
1717 continue;
1718 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1719 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1720 const script = await this.scriptFor(project, target.branch);
1721 // Already up under its new name: only its old names are left to redirect.
1722 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1723 if (up) {
1724 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1725 continue;
1726 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1727 const history = await this.recentBuilds(script);
1728 const last = history[0];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1729 if (last && (last.status === "queued" || last.status === "building")) {
1730 result.queued++;
1731 continue;
1732 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1733 // A commit that is gone, or a build that failed the same way a few
1734 // times, is not tried again; a push or a redeploy builds it.
1735 // Otherwise the sweep waits longer after each failure.
1736 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 quotas1737 result.waiting++;
1738 continue;
1739 }
1740 // A workspace without Deployments, or over its limit, waits for it
1741 // rather than gathering refused deployments.
1742 if (!open.has(project.workspace)) {
1743 const [plan, limit] = await Promise.all([
1744 billing.hasFeature(project.workspace, "deployments"),
1745 billing.checkLimit(project.workspace),
1746 ]);
1747 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1748 }
1749 if (!open.get(project.workspace)) {
1750 result.waiting++;
1751 continue;
1752 }
1753 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 look1754 const started =
1755 target.kind === "production"
1756 ? await this.deployProduction(project, target.commit, "g1t")
1757 : target.number != null
1758 ? 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 quotas1759 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1760 const outcome = rebuildOutcome(started);
1761 if (outcome === "queued") result.queued++;
1762 else if (outcome === "failed") {
1763 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1764 result.failed.push(`${named}: ${why}`);
1765 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1766 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1767 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1768 }
1769 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1770 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1771 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1772 }
1773
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1774 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1775 private async recentBuilds(script: string): Promise<PastBuild[]> {
1776 const rows = await this.db
1777 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1778 .bind(script, MAX_IDENTICAL_FAILURES)
1779 .all<PastBuild>();
1780 return rows.results;
1781 }
1782
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1783 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1784 private async projectIdsFor(repoId: string): Promise<string[]> {
1785 const rows = await this.db
1786 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1787 .bind(repoId)
1788 .all<{ project_id: string }>();
1789 return rows.results.map((row) => row.project_id);
1790 }
1791
1792 /**
1793 * A repository was deleted, restorable for a while: every app of its
1794 * projects (production and previews) comes down, builds under way are
1795 * dropped, and nothing builds for it until it is restored. Its settings
1796 * and custom domains are kept for the restore; until then a domain has
1797 * nothing up to serve, as when production is turned off.
1798 */
1799 private async repoDeleted(repoId: string): Promise<void> {
1800 const projectIds = await this.projectIdsFor(repoId);
1801 if (projectIds.length === 0) return;
1802 const at = now();
1803 await this.db.batch([
1804 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1805 this.db
1806 .prepare(
1807 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1808 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1809 )
1810 .bind(at, repoId),
1811 ]);
1812 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1813 }
1814
1815 /**
1816 * A deleted repository is back: production goes up again from its
1817 * default branch, for each project that has it on. Previews come back
1818 * with the next push to their pull requests.
1819 */
1820 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1821 const deleted = await this.db
1822 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1823 .bind(repoId)
1824 .all<{ project_id: string }>();
1825 const ids = deleted.results.map((row) => row.project_id);
1826 if (ids.length === 0) return;
1827 // The projects service hears of the restore on its own queue, and hides
1828 // the projects until then: wait for it a few deliveries.
1829 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1830 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1831 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1832 for (const project of projects) {
1833 try {
1834 const started = await this.deployProduction(project, null, "g1t");
1835 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1836 } catch (error) {
1837 console.error("could not deploy after restore", project.slug, error);
1838 }
1839 }
1840 }
1841
1842 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1843 * A workspace was deleted, restorable by g1t's staff for a while: every
1844 * app of its projects (production and previews) is paused, answering with
1845 * a notice and running nothing, builds under way are dropped, and nothing
1846 * builds for it until it is restored. Nothing is taken down: scripts,
1847 * settings and custom domains are kept for the restore. Never for a
1848 * protected workspace, whatever was published.
1849 */
1850 private async workspaceDeleting(slug: string): Promise<void> {
1851 const workspace = slug.toLowerCase();
1852 if (isProtectedWorkspace(workspace)) {
1853 console.error("workspace.deleting ignored for protected", workspace);
1854 return;
1855 }
1856 const at = now();
1857 await this.db.batch([
1858 this.db
1859 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1860 .bind(at, workspace),
1861 this.db
1862 .prepare(
1863 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1864 WHERE workspace = ? AND status IN ('queued', 'building')`,
1865 )
1866 .bind(at, workspace),
1867 ]);
1868 const cloudflare = this.cloudflare;
1869 for (const app of await this.appsOfWorkspace(workspace)) {
1870 if (app.paused_at) continue;
1871 await cloudflare?.pauseScript(app.script);
1872 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1873 }
1874 }
1875
1876 /**
1877 * A deleted workspace is back: it builds again, and its paused apps are
1878 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1879 * (the sweep tries again any it could not).
1880 */
1881 private async workspaceRestored(slug: string): Promise<void> {
1882 const workspace = slug.toLowerCase();
1883 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1884 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1885 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1886 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1887 }
1888
1889 /**
1890 * A deleted workspace is purged: whatever its projects still have up
1891 * comes down and their custom domains go, as for a purged repository.
1892 * Its own repositories' projects are purged with them (`repo.purged`);
1893 * this catches any building from a repository it had transferred away.
1894 */
1895 private async workspacePurged(slug: string): Promise<void> {
1896 const workspace = slug.toLowerCase();
1897 if (isProtectedWorkspace(workspace)) return;
1898 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1899 for (const { project_id } of rows.results) {
1900 await this.takeDownWhere(project_id, null);
1901 await this.domains.removeWhere("project_id", project_id);
1902 }
1903 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1904 await this.db.batch([
1905 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1906 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1907 ]);
1908 }
1909
1910 /** The apps of a workspace's projects, and any still under its name. */
1911 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1912 const rows = await this.db
1913 .prepare(
1914 `SELECT * FROM apps WHERE workspace = ?1
1915 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1916 )
1917 .bind(workspace)
1918 .all<AppRow>();
1919 return rows.results;
1920 }
1921
1922 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1923 * A deleted repository is gone for good: its projects' custom domains are
1924 * removed (from the dispatcher and from Cloudflare), any app or redirect
1925 * still up comes down, and every row kept for them goes. What they used
1926 * stays on their workspace's meter.
1927 */
1928 private async repoPurged(repoId: string): Promise<void> {
1929 const projectIds = await this.projectIdsFor(repoId);
1930 const domains = this.domains;
1931 for (const projectId of projectIds) {
1932 await this.takeDownWhere(projectId, null);
1933 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1934 await domains.removeWhere("project_id", projectId);
1935 }
1936 // The redirects left at names its apps had before.
1937 const scripts = await this.db
1938 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1939 .bind(repoId)
1940 .all<{ script: string }>();
1941 const hosts = scripts.results.map(({ script }) => appHost(script));
1942 for (let i = 0; i < hosts.length; i += 50) {
1943 const chunk = hosts.slice(i, i + 50);
1944 const redirects = await this.db
1945 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1946 .bind(...chunk)
1947 .all<{ script: string }>();
1948 for (const { script } of redirects.results) {
1949 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1950 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 look1951 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1952 }
1953 }
1954 await this.db.batch([
1955 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1956 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1957 ]);
1958 }
1959
1960 /**
1961 * The default branch is another one now: production is built from it, as
1962 * from a push to it, unless production already serves (or is building)
1963 * its commit, as when the default branch was only renamed.
1964 */
1965 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1966 for (const found of await this.projects.byRepo(repoId)) {
1967 if (found.source.kind !== "hosted") continue;
1968 // Projects may not have heard yet: the event names the branch.
1969 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1970 const actor = await this.workspaceActor(project.workspace);
1971 if (!actor) continue;
1972 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1973 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1974 if (!head) continue;
1975 const same = await this.db
1976 .prepare(
1977 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1978 AND status IN ('queued', 'building', 'ready')`,
1979 )
1980 .bind(project.id, head)
1981 .first();
1982 if (same) continue;
1983 await this.deployProduction(project, head, createdBy);
1984 }
1985 }
1986
1987 /**
1988 * A branch was renamed: its preview is the same app, so its rows follow.
1989 * The app keeps its name until it is next built; then it goes up under
1990 * the new branch's name, and the old one redirects there (see `supersede`).
1991 */
1992 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1993 const projectIds = await this.projectIdsFor(repoId);
1994 if (projectIds.length === 0) return;
1995 const ids = projectIds.map(() => "?").join(", ");
1996 await this.db.batch([
1997 this.db
1998 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1999 .bind(to, from, ...projectIds),
2000 this.db
2001 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2002 .bind(to, from, ...projectIds),
2003 ]);
2004 }
2005
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2006 /** `project` under the workspace's slug now, whether or not projects has caught up. */
2007 private underSlug(project: Project, current: string, stale: string[]): Project {
2008 const source =
2009 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
2010 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
2011 : project.source;
2012 return { ...project, workspace: current, source };
2013 }
2014
2015 /** A stack's preview (no pull request of its own) built again at `commit`. */
2016 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
2017 if (!actor || branch == null) return null;
2018 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2019 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2020 return this.start({
2021 project,
2022 kind: "preview",
2023 branch,
2024 number: null,
2025 commit,
2026 source: repoOf(project).path,
2027 reader: actor,
2028 createdBy: "g1t",
2029 settings,
2030 // As `stack` built it: the project's own default branch.
2031 trusted: true,
2032 });
2033 }
2034
Deployments: a preview for every pull request, production on g1t.page2035 // ---- The sweep -----------------------------------------------------
2036
2037 /**
2038 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page2039 * previews, the apps of workspaces whose plan ended, and scripts no app
2040 * holds come down; and a month that is over is charged past its
2041 * allowance.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2042 *
2043 * Each step stands alone: one that fails is logged and the rest still
2044 * run. Taking idle previews down and charging months, which only read
2045 * g1t's own tables, go before the steps that wait on Cloudflare's API,
2046 * so a slow or failing API never holds them up.
Deployments: a preview for every pull request, production on g1t.page2047 */
2048 async sweep(): Promise<void> {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2049 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
2050 await step("failing stuck builds", async () => {
2051 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2052 const stuck = await this.db
2053 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2054 .bind(cutoff)
2055 .all<{ id: string }>();
2056 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2057 });
2058 await step("taking down idle previews", () => this.takeDownIdle());
2059 await step("charging months", () => this.chargeMonths());
Deployments: a preview for every pull request, production on g1t.page2060
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2061 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2062 // Each app is its project's workspace's, as deployments has it now: an
2063 // app still under the name it had before its project moved is the new
2064 // workspace's, and plans and limits are checked there.
2065 const settings = new Map(
2066 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2067 );
2068 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 member2069 // A deleted workspace's apps stay paused as they are, for a restore:
2070 // its plan ended with the deletion, and that must not take them down.
2071 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2072 const live = apps.filter((app) => !held(app));
2073 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page2074
2075 // Apps of workspaces whose plan has ended come down.
2076 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2077 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page2078 for (const workspace of workspaces) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2079 await step(`ending ${workspace}'s apps`, async () => {
2080 const plan = await billing.hasFeature(workspace, "deployments");
2081 if (!plan.ok && plan.error.code === "payment_required") {
2082 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2083 // Custom domains cost g1t by the month: they go with the plan.
2084 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2085 }
2086 });
Deployments: a preview for every pull request, production on g1t.page2087 }
2088
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2089 // Apps whose project moved and are not up under the new name yet: a
2090 // move's rebuild that could not start is tried again here.
2091 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2092 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2093
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2094 await this.domains
2095 .sweep(async (projectId) => {
2096 const app = await this.db
2097 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2098 .bind(projectId)
2099 .first<{ script: string }>();
2100 return app?.script ?? null;
2101 })
2102 .catch((error) => console.error("could not check domains", error));
2103
Projects: what a workspace builds and runs, first on every page2104 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page2105 await this.count(apps).catch((error) => console.error("could not count usage", error));
2106 }
2107
Usage limits: unpaid usage can only go so far2108 /**
2109 * Pauses the apps of workspaces that reached their limit for usage not
2110 * yet paid for, and rebuilds them from the same commit once they are
2111 * 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 quotas2112 *
2113 * The workspace is the project's now (see `ownerOf`), never the one an
2114 * app's row was written under, so an app left under its old name after
2115 * a transfer is paused only if its new workspace is over its limit. Such
2116 * an app is resumed by being built under its new name (`followMoves`),
2117 * not here.
Usage limits: unpaid usage can only go so far2118 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2119 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far2120 const cloudflare = this.cloudflare;
2121 if (!cloudflare) return;
2122 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2123 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2124 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2125 const limit = await billing.checkLimit(workspace);
2126 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2127 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2128 if (limit.value.state === "stopped") {
2129 for (const app of theirs.filter((a) => !a.paused_at)) {
2130 await cloudflare.pauseScript(app.script);
2131 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2132 }
2133 continue;
2134 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2135 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2136 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2137 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2138 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2139 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2140 const project = projects.get(app.project_id);
2141 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2142 // A failed or refused rebuild leaves it paused, to try again later:
2143 // longer after each failure, and not once its commit is gone or it
2144 // failed the same way a few times.
2145 const history = await this.recentBuilds(app.script);
2146 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2147 const backoff = history[0]?.status === "failed";
2148 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
Usage limits: unpaid usage can only go so far2149 const rebuilt =
2150 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2151 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2152 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2153 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2154 : null;
2155 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2156 }
2157 }
2158 }
2159
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2160 /**
2161 * Scripts in the namespace that no app holds, such as ones renamed. An
2162 * old address that redirects to its app's new one is held until its
2163 * redirect expires, then removed with the rest.
2164 */
Projects: what a workspace builds and runs, first on every page2165 private async removeOrphans(apps: AppRow[]): Promise<void> {
2166 const cloudflare = this.cloudflare;
2167 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2168 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2169 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2170 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2171 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 page2172 const building = await this.db
2173 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2174 .all<{ script: string }>();
2175 for (const row of building.results) held.add(row.script);
2176 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2177 for (const script of await cloudflare.listScripts()) {
2178 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2179 }
2180 }
2181
Deployments: a preview for every pull request, production on g1t.page2182 /** Counts this month's requests and CPU time per workspace, from analytics. */
2183 private async count(apps: AppRow[]): Promise<void> {
2184 const cloudflare = this.cloudflare;
2185 if (!cloudflare || apps.length === 0) return;
2186 const start = `${month()}-01T00:00:00Z`;
2187 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2188 // Analytics only counts apps that are up; the meter keeps what earlier
2189 // apps used by never going down.
2190 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2191 for (const app of apps) {
2192 const used = totals.get(app.script);
2193 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2194 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2195 sum.requests += used.requests;
2196 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2197 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2198 }
2199 const at = now();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2200 // Written only when the count moves the meter: most sweeps it does not.
2201 const stored = new Map(
2202 (
2203 await this.db
2204 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2205 .bind(month())
2206 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2207 ).results.map((row) => [row.namespace, row]),
2208 );
Projects: what a workspace builds and runs, first on every page2209 for (const [workspace, used] of perWorkspace) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2210 if (!movesMeter(stored.get(workspace), used)) continue;
Deployments: a preview for every pull request, production on g1t.page2211 await this.db
2212 .prepare(
2213 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2214 ON CONFLICT (namespace, month) DO UPDATE SET
2215 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2216 )
Projects: what a workspace builds and runs, first on every page2217 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2218 .run();
2219 }
2220 // When each preview last answered anyone, for the idle sweep.
2221 const recent = await cloudflare.usage(
2222 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2223 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2224 at,
2225 );
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2226 // At an hour's resolution: previews idle for days are what it finds.
2227 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2228 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
Deployments: a preview for every pull request, production on g1t.page2229 for (const [script, used] of recent) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2230 const last = lastRequest.get(script);
2231 if (used.requests > 0 && (!last || last < hourAgo)) {
Deployments: a preview for every pull request, production on g1t.page2232 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2233 }
2234 }
Projects: what a workspace builds and runs, first on every page2235 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 quotas2236 // What this month's traffic and custom domains will cost, from the
2237 // first request and the first domain, so the workspace's limit counts
2238 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2239 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2240 const billing = billingClient(this.env.BILLING);
2241 const meters = await this.db
2242 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2243 .bind(month())
2244 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2245 for (const meter of meters.results) {
2246 const cost = monthCost(meter, costs);
2247 await billing
2248 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2249 .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 quotas2250 if ((meter.peak_domains ?? 0) > 0) {
2251 await billing
2252 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2253 .catch((error) => console.error("could not note pending usage", error));
2254 }
Prices keep themselves current with what g1t pays2255 }
Deployments: a preview for every pull request, production on g1t.page2256 }
2257
Projects: what a workspace builds and runs, first on every page2258 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2259 private async takeDownIdle(): Promise<void> {
2260 const idle = await this.db
2261 .prepare(
Projects: what a workspace builds and runs, first on every page2262 `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 member2263 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2264 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2265 )
2266 .all<{ script: string }>();
2267 for (const { script } of idle.results) await this.removeApp(script);
2268 }
2269
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2270 /** 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.page2271 private async chargeMonths(): Promise<void> {
2272 const due = await this.db
2273 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2274 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2275 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2276 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2277 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2278 const cost = monthCost(meter, costs);
2279 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2280 const charged = await billingClient(this.env.BILLING).chargeFeature({
2281 workspace: meter.namespace,
2282 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2283 costMicros: cost.micros,
2284 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2285 reference: `deployments/${meter.namespace}/${meter.month}`,
2286 });
2287 if (!charged.ok) continue;
2288 }
2289 await this.db
2290 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2291 .bind(now(), meter.namespace, meter.month)
2292 .run();
2293 }
2294 }
2295}
2296
2297/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2298async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2299 switch (method) {
2300 case "settings":
2301 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2302 case "is_enabled":
2303 return service.isEnabled(args);
Deployments: a preview for every pull request, production on g1t.page2304 case "update_settings":
2305 return service.updateSettings(args);
2306 case "list":
2307 return service.list(args);
2308 case "get":
2309 return service.get(args);
2310 case "redeploy":
2311 return service.redeploy(args);
2312 case "take_down":
2313 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2314 case "stack":
2315 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2316 case "overview":
2317 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2318 case "usage":
2319 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2320 case "domains":
2321 return service.listDomains(args);
2322 case "add_domain":
2323 return service.addDomain(args);
2324 case "remove_domain":
2325 return service.removeDomain(args);
2326 case "refresh_domain":
2327 return service.refreshDomain(args);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972328 // A repository's deployments wherever they run (repo-deployments.ts).
2329 case "list_deployments":
2330 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2331 case "get_deployment":
2332 return service.repoDeployments.get(args);
2333 case "list_deployment_statuses":
2334 return service.repoDeployments.statuses(args);
2335 case "list_environments":
2336 return service.repoDeployments.environments(args);
2337 case "get_environment":
2338 return service.repoDeployments.environment(args);
2339 case "create_deployment":
2340 return service.repoDeployments.create(args);
2341 case "create_deployment_status":
2342 return service.repoDeployments.createStatus(args);
2343 // For the actions service: a run's deployment to one environment.
2344 case "actions_deployment":
2345 return service.repoDeployments.fromActions(args);
Deployments: a preview for every pull request, production on g1t.page2346 default:
2347 return undefined;
2348 }
2349}
2350
2351export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2352 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2353 const { pathname } = new URL(request.url);
2354 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2355 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2356 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2357 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2358 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2359 const opened = openD1(env.DB, request);
2360 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2361 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2362 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.page2363 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2364 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2365 // A build's reports, forwarded by the API.
2366 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2367 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2368 return new Response("Not found\n", { status: 404 });
2369 },
2370
2371 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2372 const service = new Deployments(env);
2373 for (const message of batch.messages) {
2374 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2375 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2376 message.ack();
2377 } catch (error) {
2378 console.error("deployments could not handle", message.body.type, error);
2379 message.retry();
2380 }
2381 }
2382 },
2383
2384 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2385 await new Deployments(env).sweep();
2386 },
2387} satisfies ExportedHandler<Env, G1tEvent>;

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