Skip to content
2,289 linesCodeBlameRaw

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,
Merge branch 'mirroring' into artifacts-mode47 mirrorWritable,
Deployments: a preview for every pull request, production on g1t.page48 newId,
49 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents50 openD1,
Projects: what a workspace builds and runs, first on every page51 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52 repoMove,
Deployments: a preview for every pull request, production on g1t.page53 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains55 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page56 workClient,
57 type DeployKind,
58 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look59 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page60 type DeployStatus,
61 type DeployUsage,
62 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains63 type Domain,
Deployments: a preview for every pull request, production on g1t.page64 type G1tEvent,
65 type LiveApp,
Projects: what a workspace builds and runs, first on every page66 type Project,
67 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look68 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains69 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page70 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights71 workOwner,
Deployments: a preview for every pull request, production on g1t.page72 type RepoPath,
73 type Result,
74 type ServiceBinding,
75 type User,
76 type Viewer,
77} from "@g1t/contracts";
78
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights79import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page80import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains81import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas82import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails83import { monthCost, movesMeter } from "./metering";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit84import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
85import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains86import { appHost, appUrl, label, uniqueLabel } from "./names";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9787import { RepoDeployments, sourceOf } from "./repo-deployments";
Deployments: a preview for every pull request, production on g1t.page88
89type Env = {
90 DB: D1Database;
91 REPOS: ServiceBinding;
92 WORK: ServiceBinding;
93 IDENTITY: ServiceBinding;
94 BILLING: ServiceBinding;
95 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page96 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments97 /** Secrets and variables: the actions service holds the one store. */
98 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published99 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
100 EVENTS?: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page101 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
102 CLOUDFLARE_API_TOKEN?: string;
103 CLOUDFLARE_ACCOUNT_ID: string;
104 DISPATCH_NAMESPACE: string;
105 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains106 /** Custom domains: hostname to app, read by the dispatcher. */
107 DOMAINS?: KVNamespace;
108 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
109 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look110 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
111 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page112};
113
114/** A build that has not reported in this long has died. */
115const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page116/** A script in the namespace that no app holds, older than this, is removed. */
117const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page118const LIST_LIMIT = 50;
119const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains120/**
121 * Deliveries of `workspace.renamed` that wait for the projects service to
122 * have seen it too, before going ahead with the new slug regardless.
123 */
124const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas125/** Why a build under way was dropped by a move; the move builds it again under the new name. */
126const MOVED_ERROR = "The repository moved: built again under its new name.";
127const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit128/**
129 * A move's rebuild that could not start, or failed, is tried again by the
130 * sweep after this long, doubled for each failure after the first, up to
131 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
132 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas133const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page134
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look135/** A build's report of what it found the project to be, if it is one g1t knows. */
136export function detectedKind(value: unknown): DetectedKind | null {
137 return value === "workers" || value === "static" || value === "html" ? value : null;
138}
139
Deployments: a preview for every pull request, production on g1t.page140const now = () => new Date().toISOString();
141const month = (at = new Date()) => at.toISOString().slice(0, 7);
142
143async function sha256(text: string): Promise<string> {
144 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
145 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
146}
147
148function randomToken(): string {
149 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
150}
151
152function isMember(viewer: Viewer, slug: string): boolean {
153 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
154}
155
Projects: what a workspace builds and runs, first on every page156/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look157/** One compute gate per isolate, so entitlements and prices are kept between calls. */
158let computeGate: ComputeGate | null = null;
159function gateFor(billing: ServiceBinding): ComputeGate {
160 computeGate ??= new ComputeGate(billing);
161 return computeGate;
162}
163
164/** The longest a build may run, in minutes: as long as its read token lasts. */
165const BUILD_MINUTES = 30;
166
167/**
168 * A build that was skipped before it started (its plan, its limit, or
169 * billing's refusal) as a failure, so whoever asked for it sees why.
170 */
171function notStarted(result: Result<Deployment>): Result<Deployment> {
172 if (result.ok && result.value.status === "skipped" && result.value.error) {
173 return fail("payment_required", result.value.error);
174 }
175 return result;
176}
177
Projects: what a workspace builds and runs, first on every page178function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
179 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
180 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
181}
182
Deployments: a preview for every pull request, production on g1t.page183type SettingsRow = {
Projects: what a workspace builds and runs, first on every page184 project_id: string;
185 workspace: string;
186 slug: string;
Deployments: a preview for every pull request, production on g1t.page187 repo_id: string;
188 enabled: number;
189 previews: number;
190 production: number;
191 build_command: string | null;
192 output_dir: string | null;
193 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look194 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
195 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member196 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
197 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page198};
199
200type DeploymentRow = {
201 id: string;
Projects: what a workspace builds and runs, first on every page202 project_id: string;
203 workspace: string;
204 slug: string;
Deployments: a preview for every pull request, production on g1t.page205 repo_id: string;
Projects: what a workspace builds and runs, first on every page206 repo: string;
Deployments: a preview for every pull request, production on g1t.page207 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page208 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page209 number: number | null;
210 commit_sha: string;
211 script: string;
212 status: DeployStatus;
213 error: string | null;
214 warnings: string;
215 log: string | null;
216 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments217 trusted: number;
Deployments: a preview for every pull request, production on g1t.page218 build_seconds: number | null;
219 created_by: string;
220 created_at: string;
221 finished_at: string | null;
222};
223
224type AppRow = {
225 script: string;
Projects: what a workspace builds and runs, first on every page226 project_id: string;
227 workspace: string;
228 slug: string;
Deployments: a preview for every pull request, production on g1t.page229 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page230 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page231 number: number | null;
232 commit_sha: string;
233 deployed_at: string;
234 created_at: string;
235 last_request_at: string | null;
Usage limits: unpaid usage can only go so far236 /** Set while its workspace is over its limit; see `holdToLimits`. */
237 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page238};
239
240function toDeployment(row: DeploymentRow): Deployment {
241 return {
242 id: row.id,
243 kind: row.kind,
Projects: what a workspace builds and runs, first on every page244 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page245 number: row.number,
246 commit: row.commit_sha,
247 status: row.status,
248 url: appUrl(row.script),
249 error: row.error,
250 warnings: JSON.parse(row.warnings || "[]") as string[],
251 buildSeconds: row.build_seconds,
252 createdBy: row.created_by,
253 createdAt: row.created_at,
254 finishedAt: row.finished_at,
255 };
256}
257
Projects: what a workspace builds and runs, first on every page258function toLive(app: AppRow): LiveApp {
259 return {
260 kind: app.kind,
261 branch: app.branch,
262 number: app.number,
263 url: appUrl(app.script),
264 commit: app.commit_sha,
265 deployedAt: app.deployed_at,
266 };
267}
268
Deployments: a preview for every pull request, production on g1t.page269class Deployments {
270 constructor(private readonly env: Env) {}
271
272 private get cloudflare(): Cloudflare | null {
273 const token = this.env.CLOUDFLARE_API_TOKEN;
274 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
275 }
276
277 private get db() {
278 return this.env.DB;
279 }
280
Agents and memory, checks and conflicts, profiles, slug renames, custom domains281 private get domains(): Domains {
282 const token = this.env.CLOUDFLARE_API_TOKEN;
283 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
284 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
285 }
286
Projects: what a workspace builds and runs, first on every page287 private get projects() {
288 return projectsClient(this.env.PROJECTS);
289 }
290
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97291 /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */
292 get repoDeployments(): RepoDeployments {
293 return new RepoDeployments(this.env);
294 }
295
296 /**
297 * Publishes a build's change in the repository-wide model
298 * (`deployment.created`, `deployment_status.created`), as a reported
299 * deployment's are. Never fails the build.
300 */
301 private async buildChanged(id: string, created = false): Promise<void> {
302 try {
303 const row = await this.deploymentRow(id);
304 if (!row) return;
305 const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id);
306 const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main";
307 await this.repoDeployments.buildChanged(row, defaultBranch, created);
308 } catch (error) {
309 console.error("build change not published", id, String(error));
310 }
311 }
312
Deployments: a preview for every pull request, production on g1t.page313 /** The workspace itself, as the service acts for it. */
314 private async workspaceActor(slug: string): Promise<User | null> {
315 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
316 if (!workspace) return null;
317 return {
318 id: workspace.id,
319 username: workspace.slug,
320 kind: "workspace",
321 verified: true,
322 workspaces: [{ slug: workspace.slug, role: "member" }],
323 };
324 }
325
Secrets and variables: one list, rows per environment, for workflows and deployments326 /**
Projects: what a workspace builds and runs, first on every page327 * What the project's secrets and variables available to deployments give
328 * production or a preview: its build's environment, and the same again as
329 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments330 */
331 private async resolve(
Projects: what a workspace builds and runs, first on every page332 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments333 environment: DeployKind,
334 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know335 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments336 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
337 method: "POST",
338 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page339 body: JSON.stringify({
340 repoId: project.repoId,
341 repo: project.repo,
342 projectId: project.id,
343 projectSlug: project.slug,
344 consumer: "deployments",
345 environment,
346 trusted,
347 }),
Secrets and variables: one list, rows per environment, for workflows and deployments348 });
349 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
350 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
351 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
352 }
353
354 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights355 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
356 * it, or its author) is trusted with the project's secrets: g1t itself,
357 * or someone who can push to the repository (Write or more, a member's or
358 * a collaborator's). Anyone else gets a preview built without them, as
359 * their workflows run. A change g1t made for someone is trusted as they
360 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments361 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights362 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
363 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look364 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 rights365 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look366 .catch(() => null);
367 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments368 }
369
Projects: what a workspace builds and runs, first on every page370 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
371 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page372 }
373
Projects: what a workspace builds and runs, first on every page374 /** The name an app gets, unique among apps: production, or a branch's preview. */
375 private async scriptFor(project: Project, branch: string | null): Promise<string> {
376 const base = await label(project.workspace, project.slug, branch);
377 const holder = await this.db
378 .prepare(
379 `SELECT project_id, branch FROM apps WHERE script = ?1
380 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
381 )
382 .bind(base)
383 .first<{ project_id: string; branch: string | null }>();
384 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
385 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
386 }
387
388 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page389 return {
390 enabled: !!row?.enabled,
391 previews: row ? !!row.previews : true,
392 production: row ? !!row.production : true,
393 buildCommand: row?.build_command ?? null,
394 outputDir: row?.output_dir ?? null,
395 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page396 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains397 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look398 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page399 };
400 }
401
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look402 /** What the project's last finished build found it to be; null before one has. */
403 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
404 const row = await this.db
405 .prepare(
406 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
407 )
408 .bind(projectId)
409 .first<{ detected: string }>()
410 .catch(() => null);
411 return detectedKind(row?.detected);
412 }
413
414 /**
415 * The project, if `viewer` may do what `method` needs on its repository
416 * (see `NEEDS`): not found when they cannot read it, refused when they can
417 * but their role is too low.
418 */
419 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
420 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
421 if (!found.ok) return found;
422 const repo = repoRef(found.value);
423 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
424 const capability = NEEDS[method];
425 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
426 return found;
Deployments: a preview for every pull request, production on g1t.page427 }
428
429 // ---- Methods for the site and the API ------------------------------
430
Projects: what a workspace builds and runs, first on every page431 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 look432 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page433 if (!project.ok) return project;
434 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page435 }
436
437 async updateSettings(a: {
438 actor: User;
Projects: what a workspace builds and runs, first on every page439 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page440 changes: Partial<DeploySettings>;
441 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look442 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page443 if (!found.ok) return found;
444 const project = found.value;
445 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page446 const next = { ...before, ...a.changes };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97447 // A library, a tool or other, as set in its settings, does not deploy.
448 if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") {
449 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 production450 }
Deployments: a preview for every pull request, production on g1t.page451 if (next.enabled && !before.enabled) {
452 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page453 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page454 if (!plan.ok) return plan;
455 }
456 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
457 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
458 await this.db
459 .prepare(
Projects: what a workspace builds and runs, first on every page460 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
461 output_dir, idle_days, updated_by, updated_at)
462 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
463 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
464 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.page465 )
466 .bind(
Projects: what a workspace builds and runs, first on every page467 project.id,
468 project.workspace,
469 project.slug,
470 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page471 next.enabled ? 1 : 0,
472 next.previews ? 1 : 0,
473 next.production ? 1 : 0,
474 clip(next.buildCommand),
475 clip(next.outputDir),
476 idleDays,
477 a.actor.username,
478 now(),
479 )
480 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production481 // Projects keeps whether it deploys, so a project with Deployments on is an app.
482 if (next.enabled !== before.enabled) {
483 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
484 }
Deployments: a preview for every pull request, production on g1t.page485 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page486 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page487 else {
Projects: what a workspace builds and runs, first on every page488 if (!next.previews) await this.takeDownWhere(project.id, "preview");
489 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page490 }
491 // Turned on: production goes up from the default branch at once.
492 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page493 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page494 }
Projects: what a workspace builds and runs, first on every page495 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page496 }
497
A project is an app or a library: libraries show their package and how to ship a release, not production498 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
499 async isEnabled(a: { projectId: string }): Promise<boolean> {
500 return !!(await this.settingsRow(a.projectId))?.enabled;
501 }
502
Projects: what a workspace builds and runs, first on every page503 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 look504 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page505 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page506 const [deployments, apps] = await Promise.all([
507 this.db
Projects: what a workspace builds and runs, first on every page508 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
509 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page510 .all<DeploymentRow>(),
511 this.db
Projects: what a workspace builds and runs, first on every page512 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
513 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page514 .all<AppRow>(),
515 ]);
Projects: what a workspace builds and runs, first on every page516 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page517 }
518
Projects: what a workspace builds and runs, first on every page519 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 look520 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page521 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page522 const row = await this.db
Projects: what a workspace builds and runs, first on every page523 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
524 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page525 .first<DeploymentRow>();
526 if (!row) return fail("not_found", "No such deployment.");
527 return ok({ ...toDeployment(row), log: row.log });
528 }
529
Projects: what a workspace builds and runs, first on every page530 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 look531 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page532 if (!found.ok) return found;
533 const project = found.value;
534 const settings = await this.settingsRow(project.id);
535 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
536 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look537 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 page538 }
539 // A branch's preview comes from its pull request.
540 const app = await this.db
541 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
542 .bind(project.id, a.branch)
543 .first<{ number: number }>();
544 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 look545 return notStarted((await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open."));
Project dependencies: addresses, preview stacks, Affects, and agents who know546 }
547
Projects: what a workspace builds and runs, first on every page548 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 look549 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page550 if (!project.ok) return project;
551 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page552 return ok(true);
553 }
554
Projects: what a workspace builds and runs, first on every page555 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
556 const workspace = a.workspace.toLowerCase();
557 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 look558 // Only the projects whose repositories the viewer can read: the
559 // projects service lists no others.
560 const listed = await this.projects.list(workspace, a.viewer);
561 if (!listed.ok) return listed;
562 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page563 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look564 // A deleted repository's projects are hidden until it is restored.
565 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 page566 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
567 this.db
568 .prepare(
569 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
570 )
571 .bind(workspace)
572 .all<DeploymentRow>(),
573 ]);
574 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look575 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page576 const own = apps.results.filter((app) => app.slug === row.slug);
577 const production = own.find((app) => app.kind === "production");
578 const newest = latest.results.find((d) => d.slug === row.slug);
579 return {
580 slug: row.slug,
581 enabled: !!row.enabled,
582 production: production ? toLive(production) : null,
583 previews: own.filter((app) => app.kind === "preview").length,
584 latest: newest ? toDeployment(newest) : null,
585 };
586 }),
587 );
588 }
589
Deployments: a preview for every pull request, production on g1t.page590 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
591 const slug = a.workspace.toLowerCase();
592 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
593 const [meter, apps] = await Promise.all([
594 this.db
595 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
596 .bind(slug, month())
597 .first<{
598 requests: number;
599 cpu_ms: number;
600 peak_apps: number;
601 build_seconds: number;
602 build_micros: number;
603 counted_at: string | null;
604 }>(),
Projects: what a workspace builds and runs, first on every page605 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.page606 ]);
607 return ok({
608 month: month(),
609 requests: meter?.requests ?? 0,
610 cpuMs: meter?.cpu_ms ?? 0,
611 apps: apps?.n ?? 0,
612 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
613 buildSeconds: meter?.build_seconds ?? 0,
614 buildMicros: meter?.build_micros ?? 0,
615 countedAt: meter?.counted_at ?? null,
616 });
617 }
618
Agents and memory, checks and conflicts, profiles, slug renames, custom domains619 // ---- Custom domains ------------------------------------------------
620
621 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 look622 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains623 if (!project.ok) return project;
624 const domains = this.domains;
625 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 quotas626 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains627 domains.forProject(project.value.id),
628 domains.available(),
629 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas630 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains631 ]);
632 return ok({
633 domains: rows.map(toDomain),
634 target: CUSTOM_DOMAIN_TARGET,
635 available,
636 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas637 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains638 used,
639 });
640 }
641
642 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 look643 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains644 if (!found.ok) return found;
645 const project = found.value;
646 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
647 if (!plan.ok) return plan;
648 const added = await this.domains.add({
649 project: { id: project.id, workspace: project.workspace, slug: project.slug },
650 script: await this.productionScript(project),
651 hostname: String(a.hostname ?? ""),
652 twin: !!a.twin,
653 by: a.actor.username,
654 });
655 if (!added.ok) return fail(added.code, added.message);
656 await this.notePeak(project.workspace);
657 return ok(added.rows.map(toDomain));
658 }
659
660 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 look661 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains662 if (!found.ok) return found;
663 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
664 if (!row) return fail("not_found", "No such domain.");
665 await this.domains.remove(row);
666 return ok(true);
667 }
668
669 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 look670 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains671 if (!found.ok) return found;
672 const domains = this.domains;
673 const row = await domains.byId(found.value.id, String(a.id ?? ""));
674 if (!row) return fail("not_found", "No such domain.");
675 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
676 await this.notePeak(found.value.workspace);
677 return ok(toDomain(after));
678 }
679
680 /** The script production is up under, or the name it will have. */
681 private async productionScript(project: Project): Promise<string> {
682 const app = await this.db
683 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
684 .bind(project.id)
685 .first<{ script: string }>();
686 return app?.script ?? (await this.scriptFor(project, null));
687 }
688
Deployments: a preview for every pull request, production on g1t.page689 // ---- Starting builds -----------------------------------------------
690
691 /**
692 * Opens a deployment and starts its build. Skipped, with the reason
693 * recorded, when the workspace's plan is off.
694 */
695 private async start(input: {
Projects: what a workspace builds and runs, first on every page696 project: Project;
Deployments: a preview for every pull request, production on g1t.page697 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page698 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page699 number: number | null;
700 commit: string;
701 source: RepoPath;
702 reader: User;
703 createdBy: string;
704 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page705 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments706 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page707 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page708 const { project } = input;
709 const repo = repoOf(project);
710 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page711 const id = newId("dpl");
712 const token = randomToken();
Projects: what a workspace builds and runs, first on every page713 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far714 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page715 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look716 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page717 ? plan.error.message
Usage limits: unpaid usage can only go so far718 : limit.ok && limit.value.state === "stopped"
719 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page720 : !cloudflare
721 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
722 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look723 // A build is compute: reserved with billing before it starts, and
724 // settled by its sandbox when it stops. A refusal is the deployment's
725 // status, saying what to do.
726 const compute = gateFor(this.env.BILLING);
727 let reservation: string | null = null;
728 let microsPerSecond = 0;
729 let maxRunMinutes: number | null = null;
730 if (!refused) {
731 const ent = await compute.entitlements(project.workspace);
732 microsPerSecond = await compute.microsPerSecond();
733 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
734 const isPrivate = await reposClient(this.env.REPOS)
735 .get(repo.path, null)
736 .then((found) => !found.ok || found.value.isPrivate)
737 .catch(() => true);
738 const admitted = await compute.admit(
739 {
740 workspace: project.workspace,
741 repo: repo.path,
742 public: !isPrivate,
743 kind: "deploy",
744 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
745 },
746 ent,
747 );
748 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
749 else refused = admitted.message;
750 }
Deployments: a preview for every pull request, production on g1t.page751 await this.db
752 .prepare(
Projects: what a workspace builds and runs, first on every page753 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
754 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
755 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page756 )
757 .bind(
758 id,
Projects: what a workspace builds and runs, first on every page759 project.id,
760 project.workspace,
761 project.slug,
762 repo.id,
763 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page764 input.kind,
Projects: what a workspace builds and runs, first on every page765 input.branch,
Deployments: a preview for every pull request, production on g1t.page766 input.number,
767 input.commit,
768 script,
769 refused ? "skipped" : "queued",
770 refused,
771 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments772 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page773 input.createdBy,
774 now(),
775 refused ? now() : null,
776 )
777 .run();
778 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
779 // Older builds of the same app are replaced by this one.
780 await this.db
781 .prepare(
782 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
783 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
784 )
785 .bind(now(), script, id)
786 .run();
Projects: what a workspace builds and runs, first on every page787 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-a69aeabc4b0deeb97788 await this.buildChanged(id, true);
Projects: what a workspace builds and runs, first on every page789 // What the project's secrets and variables give builds of this kind.
790 const build = await this.resolve(
791 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
792 input.kind,
793 input.trusted,
794 );
Deployments: a preview for every pull request, production on g1t.page795 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
796 method: "POST",
797 headers: { "content-type": "application/json" },
798 body: JSON.stringify({
799 deployId: id,
800 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look801 workspace: project.workspace,
802 reservation,
803 microsPerSecond,
804 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page805 actor: input.reader,
806 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas807 // The project, whose guardrails the build runs under: a preview's
808 // source is its pull request's working copy, not the project.
809 repo: repo.path,
810 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page811 commit: input.commit,
Projects: what a workspace builds and runs, first on every page812 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page813 buildCommand: input.settings.build_command,
814 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments815 buildEnv: build.variables,
816 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page817 }),
818 });
819 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 look820 if (!started.ok) {
821 // Never reached a sandbox: what was reserved is given back.
822 if (reservation) await compute.settle(reservation, 0);
823 await this.finishFailed(id, started.error.message, null, null);
824 }
Deployments: a preview for every pull request, production on g1t.page825 return ok(toDeployment((await this.deploymentRow(id))!));
826 }
827
Projects: what a workspace builds and runs, first on every page828 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
829 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 member830 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 page831 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page832 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page833 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page834 let head = commit;
835 if (!head) {
Projects: what a workspace builds and runs, first on every page836 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
837 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page838 }
839 if (!head) return null;
840 return this.start({
Projects: what a workspace builds and runs, first on every page841 project,
Deployments: a preview for every pull request, production on g1t.page842 kind: "production",
Projects: what a workspace builds and runs, first on every page843 branch: null,
Deployments: a preview for every pull request, production on g1t.page844 number: null,
845 commit: head,
Projects: what a workspace builds and runs, first on every page846 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page847 reader: actor,
848 createdBy,
849 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments850 // The default branch only moves by people and agents with access.
851 trusted: true,
Deployments: a preview for every pull request, production on g1t.page852 });
853 }
854
Projects: what a workspace builds and runs, first on every page855 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
856 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 member857 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 page858 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page859 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page860 const repo = repoOf(project);
861 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page862 if (!detail.ok) return null;
863 const { pull } = detail.value;
864 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page865 // A pull request from a fork (as g1t's agents work) has no branch here.
866 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page867 if (!force) {
868 // Already built, or being built, at this commit.
869 const same = await this.db
870 .prepare(
Projects: what a workspace builds and runs, first on every page871 `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.page872 AND status IN ('queued', 'building', 'ready')`,
873 )
Projects: what a workspace builds and runs, first on every page874 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page875 .first();
876 if (same) return null;
877 }
878 return this.start({
Projects: what a workspace builds and runs, first on every page879 project,
Deployments: a preview for every pull request, production on g1t.page880 kind: "preview",
Projects: what a workspace builds and runs, first on every page881 branch,
Deployments: a preview for every pull request, production on g1t.page882 number,
883 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page884 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 rights885 // The pull request's fork may be private: read it as whoever it is
886 // for (whoever asked g1t for it, or its author), who is also who is
887 // trusted or not with the project's secrets.
888 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page889 createdBy,
890 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights891 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page892 });
893 }
894
895 // ---- A build's reports ---------------------------------------------
896
897 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
898 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
899 }
900
901 /** The build, if `token` is its own and it is still under way. */
902 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
903 const row = await this.deploymentRow(id);
904 if (!row?.token_hash || typeof token !== "string") return null;
905 if (row.token_hash !== (await sha256(token))) return null;
906 return row.status === "queued" || row.status === "building" ? row : null;
907 }
908
909 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run910 // Each report, for the logs: a build's own failure says why.
911 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page912 const row = await this.building(id, body.token);
913 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
914 const cloudflare = this.cloudflare;
915 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
916 switch (step) {
917 case "started":
918 await this.db
919 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
920 .bind(now(), id)
921 .run();
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97922 await this.buildChanged(id);
Deployments: a preview for every pull request, production on g1t.page923 return Response.json(ok(true));
924 case "session": {
925 const manifest = body.manifest as Manifest | undefined;
926 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
927 const session = await cloudflare.openUpload(row.script, manifest);
928 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
929 }
930 case "finish": {
931 const worker = (body.worker ?? {}) as BuiltWorker;
932 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page933 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page934 try {
Secrets and variables: one list, rows per environment, for workflows and deployments935 // Running apps' secrets and variables are bound here, by g1t:
936 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page937 const runtime = await this.resolve(
938 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
939 row.kind,
940 !!row.trusted,
941 );
Deployments: a preview for every pull request, production on g1t.page942 await cloudflare.putScript(
943 row.script,
944 worker,
945 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page946 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments947 runtime,
Deployments: a preview for every pull request, production on g1t.page948 );
949 } catch (error) {
950 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
951 return Response.json(ok(false));
952 }
953 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas954 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.page955 await this.db.batch([
956 this.db
957 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look958 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page959 WHERE id = ?`,
960 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look961 .bind(
962 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
963 String(body.log ?? ""),
964 seconds,
965 at,
966 detectedKind(body.detected),
967 id,
968 ),
Builds say Replaced or Down once they are no longer live969 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page970 this.db
Builds say Replaced or Down once they are no longer live971 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
972 .bind(row.script, id),
973 this.db
Deployments: a preview for every pull request, production on g1t.page974 .prepare(
Projects: what a workspace builds and runs, first on every page975 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
976 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far977 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.page978 )
Projects: what a workspace builds and runs, first on every page979 .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 quotas980 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains981 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page982 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas983 if (wasRedirect) {
984 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
985 }
986 // The same app at an older name (its project moved) now redirects
987 // here; it stays up as it was if the redirect cannot be put.
988 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains989 // The project's own domains serve production wherever it is up.
990 if (row.kind === "production") {
991 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
992 }
Deployments: a preview for every pull request, production on g1t.page993 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page994 await this.notePeak(row.workspace);
995 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 published996 await this.announce(row, null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97997 await this.buildChanged(id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look998 // A screenshot of production as it now is, for the project's overview.
999 if (row.kind === "production") {
1000 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1001 console.error("could not ask for a screenshot", error),
1002 );
1003 }
Deployments: a preview for every pull request, production on g1t.page1004 return Response.json(ok(true));
1005 }
1006 case "fail":
1007 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1008 return Response.json(ok(true));
1009 default:
1010 return Response.json(fail("not_found", "No such step."), { status: 404 });
1011 }
1012 }
1013
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1014 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1015 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1016 * 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 quotas1017 * same is the app under a name it had before its project moved (its
1018 * workspace renamed, its repository renamed or transferred). Each is
1019 * replaced in the namespace by a redirect to the new name, its app row
1020 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1021 * the sweep to hold the old script) and in `DOMAINS` under the old
1022 * hostname, which the dispatcher follows before running anything, so the
1023 * old address redirects even if the old script is paused. A name that
1024 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1025 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1026 private async supersede(
1027 cloudflare: Cloudflare,
1028 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1029 script: string,
1030 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1031 const older = await this.db
1032 .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 quotas1033 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1034 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1035 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1036 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1037 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1038 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1039 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1040 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1041 console.error("could not redirect", old, "to", script, error);
1042 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1043 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1044 const at = now();
1045 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1046 await this.db.batch([
1047 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1048 this.db
1049 .prepare(
1050 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1051 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1052 )
1053 .bind(old, target, app.workspace, at, expires),
1054 // Its builds are no longer live under the old name.
1055 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1056 ]);
1057 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1058 expiration: Math.floor(Date.parse(expires) / 1000),
1059 }).catch((error) => console.error("could not record redirect", old, error));
1060 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1061 }
1062 return done;
1063 }
1064
Deployments: a preview for every pull request, production on g1t.page1065 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1066 const row = await this.deploymentRow(id);
1067 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1068 // A commit that is gone says so plainly; git's own words stay in the log.
1069 if (commitMissing(message)) {
1070 log = log ?? message;
1071 message = missingCommitMessage(row);
1072 }
Deployments: a preview for every pull request, production on g1t.page1073 await this.db
1074 .prepare(
1075 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1076 WHERE id = ?`,
1077 )
1078 .bind(message.slice(0, 2000), log, seconds, now(), id)
1079 .run();
1080 // A failed build still used its sandbox.
1081 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1082 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1083 await this.announce(row, message.slice(0, 300));
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971084 await this.buildChanged(id);
Events: review requests, assignments, stops and deployments are published1085 }
1086
1087 /**
1088 * Publishes a finished build: `deployment.failed` with what went wrong,
1089 * or `deployment.succeeded`, saying whether the build of the same app
1090 * before it failed. Whoever started it is the actor when it was a
1091 * person known by id; otherwise `triggeredBy` names them, or g1t.
1092 */
1093 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1094 if (!this.env.EVENTS) return;
1095 const previous = error
1096 ? null
1097 : await this.db
1098 .prepare(
1099 `SELECT status FROM deployments
1100 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1101 ORDER BY created_at DESC LIMIT 1`,
1102 )
1103 .bind(row.script, row.id, row.created_at)
1104 .first<{ status: string }>();
1105 const byId = row.created_by.startsWith("usr_");
1106 const event = {
1107 source: "deployments",
1108 repoId: row.repo_id,
1109 actor: byId ? row.created_by : null,
1110 data: {
1111 deploymentId: row.id,
1112 projectId: row.project_id,
1113 repoId: row.repo_id,
1114 workspace: row.workspace,
1115 project: row.slug,
1116 kind: row.kind,
1117 branch: row.branch,
1118 number: row.kind === "preview" ? row.number : null,
1119 commit: row.commit_sha,
1120 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1121 error,
1122 recovered: previous?.status === "failed",
1123 triggeredBy: byId ? "" : row.created_by,
1124 },
1125 };
1126 await eventsClient(this.env.EVENTS)
1127 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1128 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Deployments: a preview for every pull request, production on g1t.page1129 }
1130
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1131 /** 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.page1132 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1133 const costs = await this.costs();
1134 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1135 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1136 const what =
1137 row.kind === "preview"
1138 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1139 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1140 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1141 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1142 feature: "deployments",
1143 costMicros: cost,
1144 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1145 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1146 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1147 // Every second is metered; billing prices it from its price book and
1148 // 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 put1149 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1150 });
1151 await this.db
1152 .prepare(
1153 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1154 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1155 )
Projects: what a workspace builds and runs, first on every page1156 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1157 .run();
1158 }
1159
Prices keep themselves current with what g1t pays1160 /**
1161 * What each unit costs g1t now, from billing's price book, which follows
1162 * what Cloudflare bills. The plan's figures if billing cannot say.
1163 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1164 private async costs(): Promise<{
1165 buildSecond: number;
1166 millionRequests: number;
1167 millionCpuMs: number;
1168 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1169 /** What one custom domain is charged a month: the cost plus the margin. */
1170 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1171 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1172 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1173 const book = await billingClient(this.env.BILLING)
1174 .prices()
1175 .catch(() => null);
1176 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1177 return {
1178 buildSecond: cost("build_second", a.microsPerBuildSecond),
1179 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1180 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1181 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 quotas1182 domainMonthPrice:
1183 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1184 };
1185 }
1186
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1187 /** 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 page1188 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1189 await this.db
1190 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1191 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1192 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1193 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1194 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1195 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1196 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.page1197 )
Projects: what a workspace builds and runs, first on every page1198 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1199 .run();
1200 }
1201
Projects: what a workspace builds and runs, first on every page1202 /**
1203 * The check on the commit: `g1t / deploy`, or, for one of several
1204 * projects on a repository, `g1t / deploy (<project>)`.
1205 */
1206 private async status(
1207 repoId: string,
1208 sha: string,
1209 project: { slug: string; primary: boolean },
1210 state: string,
1211 description: string,
1212 targetUrl: string,
1213 ): Promise<void> {
1214 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1215 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1216 method: "POST",
1217 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1218 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
Deployments: a preview for every pull request, production on g1t.page1219 }).catch(() => undefined);
1220 }
1221
Projects: what a workspace builds and runs, first on every page1222 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1223 const projects = await this.projects.byRepo(row.repo_id);
1224 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1225 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1226 }
1227
Deployments: a preview for every pull request, production on g1t.page1228 // ---- Taking apps down ----------------------------------------------
1229
1230 private async removeApp(script: string): Promise<void> {
1231 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1232 await this.db.batch([
1233 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1234 // Its build is no longer live anywhere.
1235 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1236 ]);
Deployments: a preview for every pull request, production on g1t.page1237 }
1238
Projects: what a workspace builds and runs, first on every page1239 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1240 const apps = await this.db
1241 .prepare(
Projects: what a workspace builds and runs, first on every page1242 `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.page1243 )
Projects: what a workspace builds and runs, first on every page1244 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1245 .all<{ script: string }>();
1246 for (const app of apps.results) await this.removeApp(app.script);
1247 }
1248
1249 // ---- Events --------------------------------------------------------
1250
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1251 /** `attempts`: which delivery of the event this is, from 1. */
1252 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1253 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1254 case "workspace.renamed":
1255 await this.renamed(event.data, attempts);
1256 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1257 case "repo.transferred":
1258 case "repo.renamed":
1259 await this.moved(repoMove(event)!, attempts);
1260 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1261 // A repository that went or came back with its workspace is the
1262 // 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 look1263 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1264 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 look1265 break;
1266 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1267 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 look1268 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1269 case "workspace.deleting":
1270 await this.workspaceDeleting(event.data.slug);
1271 break;
1272 case "workspace.restored":
1273 await this.workspaceRestored(event.data.slug);
1274 break;
1275 case "workspace.deleted":
1276 await this.workspacePurged(event.data.slug);
1277 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1278 case "repo.purged":
1279 await this.repoPurged(event.data.repoId);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971280 await this.repoDeployments.purge(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1281 break;
1282 case "repo.default_branch_changed":
1283 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1284 break;
1285 case "branch.renamed":
1286 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1287 break;
1288
Deployments: a preview for every pull request, production on g1t.page1289 case "pull.opened":
1290 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1291 case "pull.updated":
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1292 case "pull.reopened":
Projects: what a workspace builds and runs, first on every page1293 for (const project of await this.projects.byRepo(event.data.repoId)) {
1294 await this.deployPreview(project, event.data.number, "g1t");
1295 }
Deployments: a preview for every pull request, production on g1t.page1296 break;
1297 case "pull.closed":
1298 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1299 for (const project of await this.projects.byRepo(event.data.repoId)) {
1300 const apps = await this.db
1301 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1302 .bind(project.id, event.data.number)
1303 .all<{ script: string }>();
1304 for (const app of apps.results) await this.removeApp(app.script);
1305 }
Deployments: a preview for every pull request, production on g1t.page1306 break;
Projects: what a workspace builds and runs, first on every page1307 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1308 if (!event.data.defaultBranch) break;
Merge branch 'mirroring' into artifacts-mode1309 // A mirror deploys only while g1t leads it: standing by, or in CI
1310 // failover, the remote's own deploys stand (see contracts mirrors).
1311 if (!mirrorWritable(event.data.mirror)) break;
Projects: what a workspace builds and runs, first on every page1312 for (const project of await this.projects.byRepo(event.data.repoId)) {
1313 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1314 }
Deployments: a preview for every pull request, production on g1t.page1315 break;
1316 }
1317 }
1318
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1319 /**
1320 * A workspace's slug changed, and with it every app's name: production
1321 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1322 *
1323 * Its rows move to the slug it has now (asked of identity, so a delivery
1324 * 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 quotas1325 * each app (paused or not) is built again from the same commit under its
1326 * new name; see `followMoves`. The old name keeps serving the app until
1327 * the new one is live; then it redirects to the new name (see
1328 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1329 * 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 quotas1330 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1331 */
1332 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1333 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1334 const stale = staleSlugs(renamed, current);
1335 if (stale.length === 0) return;
1336 const marks = stale.map(() => "?").join(", ");
1337
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1338 // The workspace's projects, under any of its names: a second delivery
1339 // finds them under the new one, with whatever is left to do.
1340 const rows = await this.db
1341 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1342 .bind(...stale, current)
1343 .all<{ project_id: string }>();
1344 const projectIds = rows.results.map((row) => row.project_id);
1345 const projects = await this.projectsById(projectIds);
1346 // The projects service hears of the rename on its own queue: wait for
1347 // it a few deliveries, so the builds read the repository by its new name.
1348 const behind = [...projects.values()].some((p) => p.workspace !== current);
1349 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 domains1350
1351 // Every row moves at once.
1352 const at = now();
1353 const statements: D1PreparedStatement[] = [];
1354 for (const slug of stale) {
1355 statements.push(
1356 this.db
1357 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1358 `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 domains1359 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1360 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1361 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1362 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1363 this.db
1364 .prepare(
1365 `UPDATE deployments SET workspace = ?1,
1366 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1367 WHERE workspace = ?2`,
1368 )
1369 .bind(current, slug),
1370 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1371 this.domains.rename(slug, current),
1372 // Counters add up; the peak is the higher; a month charged stays charged.
1373 this.db
1374 .prepare(
1375 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1376 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1377 FROM meters WHERE namespace = ?2
1378 ON CONFLICT (namespace, month) DO UPDATE SET
1379 requests = requests + excluded.requests,
1380 cpu_ms = cpu_ms + excluded.cpu_ms,
1381 peak_apps = MAX(peak_apps, excluded.peak_apps),
1382 peak_domains = MAX(peak_domains, excluded.peak_domains),
1383 build_seconds = build_seconds + excluded.build_seconds,
1384 build_micros = build_micros + excluded.build_micros,
1385 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1386 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1387 )
1388 .bind(current, slug),
1389 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1390 );
1391 }
1392 await this.db.batch(statements);
1393
Projects no longer depend on each other: the dependsOn relation, its settings page (old links redirect), the overview card, a pull request's Affects panel and preview stacks, reference variables and agents' notes on what a project uses are gone; the table is dropped in a later deploy1394 // Each app again, under its new name.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1395 const followed = await this.followMoves(projectIds, {
1396 projects,
1397 adjust: (project) => this.underSlug(project, current, stale),
1398 });
1399 if (followed.failed.length > 0) {
1400 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1401 }
1402 }
1403
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1404 /**
1405 * A repository moved: transferred to another workspace, and its projects
1406 * with it, or renamed within its own, when its own project's slug follows
1407 * its name (see the projects service). Each app's name is
1408 * `<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 quotas1409 * slug changed (production and every preview, paused or not) are built
1410 * again, from the same commit, under the new name; see `followMoves`. As
1411 * after a workspace rename, the old name keeps serving until the new one
1412 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1413 * The project's custom domains follow its production. Builds under way
1414 * are started again under the new name. What the apps used this month
1415 * stays on the old workspace's meter; from now on, the new workspace's
1416 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1417 *
1418 * Projects whose name did not change (a rename where the new name was
1419 * 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 quotas1420 * path. What is left to rebuild is read from the rows each time, so a
1421 * second or late delivery builds only what the first could not, and one
1422 * whose rebuild could not be queued is delivered again (and, past the
1423 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1424 */
1425 private async moved(move: RepoMove, attempts: number): Promise<void> {
1426 const current = await currentMovedPath(this.env.REPOS, move);
1427 if (staleMovedPaths(move, current).length === 0) return;
1428 const [workspace, name] = current.split("/") as [string, string];
1429 const settings = await this.db
1430 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1431 .bind(move.repoId)
1432 .all<{ project_id: string; workspace: string; slug: string }>();
1433 if (settings.results.length === 0) return;
1434
1435 // The projects service hears of the move on its own queue: wait for it
1436 // a few deliveries, so builds read the project where and as it is now.
1437 const projects = new Map<string, Project>();
1438 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1439 const behind = settings.results.some(({ project_id }) => {
1440 const project = projects.get(project_id);
1441 if (!project || project.source.kind !== "hosted") return false;
1442 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1443 });
1444 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1445
1446 // Its history goes with it, as the repository's issues do.
1447 const statements: D1PreparedStatement[] = [
1448 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1449 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1450 // The projects whose apps' names change, and have not been moved yet.
1451 const moving = settings.results
1452 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1453 .map((row) => row.project_id);
1454 if (moving.length > 0) {
1455 const ids = moving.map(() => "?").join(", ");
1456 statements.push(
1457 this.db
1458 .prepare(
1459 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1460 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1461 )
1462 .bind(MOVED_ERROR, now(), ...moving),
1463 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1464 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1465 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1466 const slug = projects.get(projectId)?.slug ?? null;
1467 statements.push(
1468 this.db
1469 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1470 .bind(workspace, slug, projectId),
1471 this.db
1472 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1473 .bind(workspace, slug, projectId),
1474 this.db
1475 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1476 .bind(workspace, slug, projectId),
1477 // Within the workspace its apps are listed under the project's name
1478 // now. One left behind in another workspace stays as it is until the
1479 // new name is live and redirects it.
1480 this.db
1481 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1482 .bind(workspace, slug, projectId),
1483 );
1484 }
1485 await this.db.batch(statements);
1486
1487 // Each app again, under its new name. App rows under the old name stay
1488 // 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 quotas1489 const followed = await this.followMoves(
1490 settings.results.map((row) => row.project_id),
1491 {
1492 projects,
1493 adjust: (found) =>
1494 found.source.kind === "hosted"
1495 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1496 : { ...found, workspace },
1497 },
1498 );
1499 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1500 }
1501
1502 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1503 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1504 const projects = new Map<string, Project>();
1505 if (projectIds.length === 0) return projects;
1506 const repoIds = new Set<string>();
1507 for (let i = 0; i < projectIds.length; i += 50) {
1508 const chunk = projectIds.slice(i, i + 50);
1509 const rows = await this.db
1510 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1511 .bind(...chunk)
1512 .all<{ repo_id: string }>();
1513 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1514 }
1515 const wanted = new Set(projectIds);
1516 for (const repoId of repoIds) {
1517 for (const project of await this.projects.byRepo(repoId)) {
1518 if (wanted.has(project.id)) projects.set(project.id, project);
1519 }
1520 }
1521 return projects;
1522 }
1523
1524 /** Whether `script` is the name an app of the project has where the project is now. */
1525 private async namedNow(
1526 script: string,
1527 settings: { project_id: string; workspace: string; slug: string },
1528 branch: string | null,
1529 ): Promise<boolean> {
1530 const base = await label(settings.workspace, settings.slug, branch);
1531 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1532 }
1533
1534 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1535 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1536 const stale: AppRow[] = [];
1537 for (const app of apps) {
1538 const row = settings.get(app.project_id);
1539 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1540 }
1541 return stale;
1542 }
1543
1544 /**
1545 * Brings the apps of the projects `projectIds` (every project when null)
1546 * under the names they have where the projects are now: each app still
1547 * under an older name, paused or not, and each build a move dropped, is
1548 * built again under its new name from the same commit, and once that is
1549 * live the old name redirects to it (`supersede`).
1550 *
1551 * Idempotent, and safe to run again at any time: an app already up under
1552 * its new name only has its old names redirected; one whose rebuild is
1553 * queued or under way is left to it; one whose workspace has no
1554 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1555 * each time; one whose commit is gone, or whose rebuild failed the same
1556 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1557 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1558 * 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 quotas1559 * event's delivery to be retried.
1560 */
1561 private async followMoves(
1562 projectIds: string[] | null,
1563 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1564 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1565 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1566 if (projectIds && projectIds.length === 0) return result;
1567 const cloudflare = this.cloudflare;
1568 if (!cloudflare) return result;
1569
1570 const settingsRows =
1571 projectIds == null
1572 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1573 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1574 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1575 if (settings.size === 0) return result;
1576 const ids = [...settings.keys()];
1577
1578 const apps: AppRow[] = [];
1579 const dropped: DroppedBuild[] = [];
1580 for (let i = 0; i < ids.length; i += 50) {
1581 const chunk = ids.slice(i, i + 50);
1582 const marks = chunk.map(() => "?").join(", ");
1583 const [appRows, droppedRows] = await Promise.all([
1584 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1585 // Builds a move dropped, that nothing has been built in place of since.
1586 this.db
1587 .prepare(
1588 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1589 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1590 AND NOT EXISTS (
1591 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1592 AND n.created_at > d.created_at AND n.status != 'skipped'
1593 )`,
1594 )
1595 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1596 .all<DeploymentRow>(),
1597 ]);
1598 apps.push(...appRows.results);
1599 dropped.push(...droppedRows.results);
1600 }
1601 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1602 if (targets.length === 0) return result;
1603
1604 const projects = new Map(options.projects ?? []);
1605 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1606 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1607 const billing = billingClient(this.env.BILLING);
1608 const open = new Map<string, boolean>();
1609 const actors = new Map<string, User | null>();
1610
1611 for (const target of targets) {
1612 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1613 const found = projects.get(target.projectId);
1614 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1615 const project = options.adjust ? options.adjust(found) : found;
1616 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1617 // Built only where deployments has the project now; the projects
1618 // service catches up on its own queue, and a later run builds then.
1619 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1620 result.waiting++;
1621 continue;
1622 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1623 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1624 const script = await this.scriptFor(project, target.branch);
1625 // Already up under its new name: only its old names are left to redirect.
1626 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1627 if (up) {
1628 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1629 continue;
1630 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1631 const history = await this.recentBuilds(script);
1632 const last = history[0];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1633 if (last && (last.status === "queued" || last.status === "building")) {
1634 result.queued++;
1635 continue;
1636 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1637 // A commit that is gone, or a build that failed the same way a few
1638 // times, is not tried again; a push or a redeploy builds it.
1639 // Otherwise the sweep waits longer after each failure.
1640 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 quotas1641 result.waiting++;
1642 continue;
1643 }
1644 // A workspace without Deployments, or over its limit, waits for it
1645 // rather than gathering refused deployments.
1646 if (!open.has(project.workspace)) {
1647 const [plan, limit] = await Promise.all([
1648 billing.hasFeature(project.workspace, "deployments"),
1649 billing.checkLimit(project.workspace),
1650 ]);
1651 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1652 }
1653 if (!open.get(project.workspace)) {
1654 result.waiting++;
1655 continue;
1656 }
1657 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 look1658 const started =
1659 target.kind === "production"
1660 ? await this.deployProduction(project, target.commit, "g1t")
1661 : target.number != null
1662 ? 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 quotas1663 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1664 const outcome = rebuildOutcome(started);
1665 if (outcome === "queued") result.queued++;
1666 else if (outcome === "failed") {
1667 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1668 result.failed.push(`${named}: ${why}`);
1669 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1670 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1671 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1672 }
1673 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1674 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1675 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1676 }
1677
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1678 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1679 private async recentBuilds(script: string): Promise<PastBuild[]> {
1680 const rows = await this.db
1681 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1682 .bind(script, MAX_IDENTICAL_FAILURES)
1683 .all<PastBuild>();
1684 return rows.results;
1685 }
1686
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1687 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1688 private async projectIdsFor(repoId: string): Promise<string[]> {
1689 const rows = await this.db
1690 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1691 .bind(repoId)
1692 .all<{ project_id: string }>();
1693 return rows.results.map((row) => row.project_id);
1694 }
1695
1696 /**
1697 * A repository was deleted, restorable for a while: every app of its
1698 * projects (production and previews) comes down, builds under way are
1699 * dropped, and nothing builds for it until it is restored. Its settings
1700 * and custom domains are kept for the restore; until then a domain has
1701 * nothing up to serve, as when production is turned off.
1702 */
1703 private async repoDeleted(repoId: string): Promise<void> {
1704 const projectIds = await this.projectIdsFor(repoId);
1705 if (projectIds.length === 0) return;
1706 const at = now();
1707 await this.db.batch([
1708 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1709 this.db
1710 .prepare(
1711 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1712 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1713 )
1714 .bind(at, repoId),
1715 ]);
1716 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1717 }
1718
1719 /**
1720 * A deleted repository is back: production goes up again from its
1721 * default branch, for each project that has it on. Previews come back
1722 * with the next push to their pull requests.
1723 */
1724 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1725 const deleted = await this.db
1726 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1727 .bind(repoId)
1728 .all<{ project_id: string }>();
1729 const ids = deleted.results.map((row) => row.project_id);
1730 if (ids.length === 0) return;
1731 // The projects service hears of the restore on its own queue, and hides
1732 // the projects until then: wait for it a few deliveries.
1733 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1734 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1735 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1736 for (const project of projects) {
1737 try {
1738 const started = await this.deployProduction(project, null, "g1t");
1739 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1740 } catch (error) {
1741 console.error("could not deploy after restore", project.slug, error);
1742 }
1743 }
1744 }
1745
1746 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1747 * A workspace was deleted, restorable by g1t's staff for a while: every
1748 * app of its projects (production and previews) is paused, answering with
1749 * a notice and running nothing, builds under way are dropped, and nothing
1750 * builds for it until it is restored. Nothing is taken down: scripts,
1751 * settings and custom domains are kept for the restore. Never for a
1752 * protected workspace, whatever was published.
1753 */
1754 private async workspaceDeleting(slug: string): Promise<void> {
1755 const workspace = slug.toLowerCase();
1756 if (isProtectedWorkspace(workspace)) {
1757 console.error("workspace.deleting ignored for protected", workspace);
1758 return;
1759 }
1760 const at = now();
1761 await this.db.batch([
1762 this.db
1763 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1764 .bind(at, workspace),
1765 this.db
1766 .prepare(
1767 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1768 WHERE workspace = ? AND status IN ('queued', 'building')`,
1769 )
1770 .bind(at, workspace),
1771 ]);
1772 const cloudflare = this.cloudflare;
1773 for (const app of await this.appsOfWorkspace(workspace)) {
1774 if (app.paused_at) continue;
1775 await cloudflare?.pauseScript(app.script);
1776 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1777 }
1778 }
1779
1780 /**
1781 * A deleted workspace is back: it builds again, and its paused apps are
1782 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1783 * (the sweep tries again any it could not).
1784 */
1785 private async workspaceRestored(slug: string): Promise<void> {
1786 const workspace = slug.toLowerCase();
1787 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1788 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1789 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1790 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1791 }
1792
1793 /**
1794 * A deleted workspace is purged: whatever its projects still have up
1795 * comes down and their custom domains go, as for a purged repository.
1796 * Its own repositories' projects are purged with them (`repo.purged`);
1797 * this catches any building from a repository it had transferred away.
1798 */
1799 private async workspacePurged(slug: string): Promise<void> {
1800 const workspace = slug.toLowerCase();
1801 if (isProtectedWorkspace(workspace)) return;
1802 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1803 for (const { project_id } of rows.results) {
1804 await this.takeDownWhere(project_id, null);
1805 await this.domains.removeWhere("project_id", project_id);
1806 }
1807 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1808 await this.db.batch([
1809 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1810 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1811 ]);
1812 }
1813
1814 /** The apps of a workspace's projects, and any still under its name. */
1815 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1816 const rows = await this.db
1817 .prepare(
1818 `SELECT * FROM apps WHERE workspace = ?1
1819 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1820 )
1821 .bind(workspace)
1822 .all<AppRow>();
1823 return rows.results;
1824 }
1825
1826 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1827 * A deleted repository is gone for good: its projects' custom domains are
1828 * removed (from the dispatcher and from Cloudflare), any app or redirect
1829 * still up comes down, and every row kept for them goes. What they used
1830 * stays on their workspace's meter.
1831 */
1832 private async repoPurged(repoId: string): Promise<void> {
1833 const projectIds = await this.projectIdsFor(repoId);
1834 const domains = this.domains;
1835 for (const projectId of projectIds) {
1836 await this.takeDownWhere(projectId, null);
1837 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1838 await domains.removeWhere("project_id", projectId);
1839 }
1840 // The redirects left at names its apps had before.
1841 const scripts = await this.db
1842 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1843 .bind(repoId)
1844 .all<{ script: string }>();
1845 const hosts = scripts.results.map(({ script }) => appHost(script));
1846 for (let i = 0; i < hosts.length; i += 50) {
1847 const chunk = hosts.slice(i, i + 50);
1848 const redirects = await this.db
1849 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1850 .bind(...chunk)
1851 .all<{ script: string }>();
1852 for (const { script } of redirects.results) {
1853 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1854 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 look1855 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1856 }
1857 }
1858 await this.db.batch([
1859 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1860 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1861 ]);
1862 }
1863
1864 /**
1865 * The default branch is another one now: production is built from it, as
1866 * from a push to it, unless production already serves (or is building)
1867 * its commit, as when the default branch was only renamed.
1868 */
1869 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1870 for (const found of await this.projects.byRepo(repoId)) {
1871 if (found.source.kind !== "hosted") continue;
1872 // Projects may not have heard yet: the event names the branch.
1873 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1874 const actor = await this.workspaceActor(project.workspace);
1875 if (!actor) continue;
1876 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1877 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1878 if (!head) continue;
1879 const same = await this.db
1880 .prepare(
1881 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1882 AND status IN ('queued', 'building', 'ready')`,
1883 )
1884 .bind(project.id, head)
1885 .first();
1886 if (same) continue;
1887 await this.deployProduction(project, head, createdBy);
1888 }
1889 }
1890
1891 /**
1892 * A branch was renamed: its preview is the same app, so its rows follow.
1893 * The app keeps its name until it is next built; then it goes up under
1894 * the new branch's name, and the old one redirects there (see `supersede`).
1895 */
1896 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1897 const projectIds = await this.projectIdsFor(repoId);
1898 if (projectIds.length === 0) return;
1899 const ids = projectIds.map(() => "?").join(", ");
1900 await this.db.batch([
1901 this.db
1902 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1903 .bind(to, from, ...projectIds),
1904 this.db
1905 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1906 .bind(to, from, ...projectIds),
1907 ]);
1908 }
1909
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1910 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1911 private underSlug(project: Project, current: string, stale: string[]): Project {
1912 const source =
1913 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1914 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1915 : project.source;
1916 return { ...project, workspace: current, source };
1917 }
1918
1919 /** A stack's preview (no pull request of its own) built again at `commit`. */
1920 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1921 if (!actor || branch == null) return null;
1922 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1923 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1924 return this.start({
1925 project,
1926 kind: "preview",
1927 branch,
1928 number: null,
1929 commit,
1930 source: repoOf(project).path,
1931 reader: actor,
1932 createdBy: "g1t",
1933 settings,
1934 // As `stack` built it: the project's own default branch.
1935 trusted: true,
1936 });
1937 }
1938
Deployments: a preview for every pull request, production on g1t.page1939 // ---- The sweep -----------------------------------------------------
1940
1941 /**
1942 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1943 * previews, the apps of workspaces whose plan ended, and scripts no app
1944 * holds come down; and a month that is over is charged past its
1945 * allowance.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1946 *
1947 * Each step stands alone: one that fails is logged and the rest still
1948 * run. Taking idle previews down and charging months, which only read
1949 * g1t's own tables, go before the steps that wait on Cloudflare's API,
1950 * so a slow or failing API never holds them up.
Deployments: a preview for every pull request, production on g1t.page1951 */
1952 async sweep(): Promise<void> {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1953 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
1954 await step("failing stuck builds", async () => {
1955 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1956 const stuck = await this.db
1957 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1958 .bind(cutoff)
1959 .all<{ id: string }>();
1960 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1961 });
1962 await step("taking down idle previews", () => this.takeDownIdle());
1963 await step("charging months", () => this.chargeMonths());
Deployments: a preview for every pull request, production on g1t.page1964
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1965 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1966 // Each app is its project's workspace's, as deployments has it now: an
1967 // app still under the name it had before its project moved is the new
1968 // workspace's, and plans and limits are checked there.
1969 const settings = new Map(
1970 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
1971 );
1972 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 member1973 // A deleted workspace's apps stay paused as they are, for a restore:
1974 // its plan ended with the deletion, and that must not take them down.
1975 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
1976 const live = apps.filter((app) => !held(app));
1977 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page1978
1979 // Apps of workspaces whose plan has ended come down.
1980 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1981 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1982 for (const workspace of workspaces) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1983 await step(`ending ${workspace}'s apps`, async () => {
1984 const plan = await billing.hasFeature(workspace, "deployments");
1985 if (!plan.ok && plan.error.code === "payment_required") {
1986 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
1987 // Custom domains cost g1t by the month: they go with the plan.
1988 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
1989 }
1990 });
Deployments: a preview for every pull request, production on g1t.page1991 }
1992
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1993 // Apps whose project moved and are not up under the new name yet: a
1994 // move's rebuild that could not start is tried again here.
1995 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
1996 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1997
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1998 await this.domains
1999 .sweep(async (projectId) => {
2000 const app = await this.db
2001 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2002 .bind(projectId)
2003 .first<{ script: string }>();
2004 return app?.script ?? null;
2005 })
2006 .catch((error) => console.error("could not check domains", error));
2007
Projects: what a workspace builds and runs, first on every page2008 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page2009 await this.count(apps).catch((error) => console.error("could not count usage", error));
2010 }
2011
Usage limits: unpaid usage can only go so far2012 /**
2013 * Pauses the apps of workspaces that reached their limit for usage not
2014 * yet paid for, and rebuilds them from the same commit once they are
2015 * 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 quotas2016 *
2017 * The workspace is the project's now (see `ownerOf`), never the one an
2018 * app's row was written under, so an app left under its old name after
2019 * a transfer is paused only if its new workspace is over its limit. Such
2020 * an app is resumed by being built under its new name (`followMoves`),
2021 * not here.
Usage limits: unpaid usage can only go so far2022 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2023 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far2024 const cloudflare = this.cloudflare;
2025 if (!cloudflare) return;
2026 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2027 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2028 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2029 const limit = await billing.checkLimit(workspace);
2030 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2031 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2032 if (limit.value.state === "stopped") {
2033 for (const app of theirs.filter((a) => !a.paused_at)) {
2034 await cloudflare.pauseScript(app.script);
2035 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2036 }
2037 continue;
2038 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2039 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2040 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2041 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2042 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2043 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2044 const project = projects.get(app.project_id);
2045 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2046 // A failed or refused rebuild leaves it paused, to try again later:
2047 // longer after each failure, and not once its commit is gone or it
2048 // failed the same way a few times.
2049 const history = await this.recentBuilds(app.script);
2050 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2051 const backoff = history[0]?.status === "failed";
2052 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
Usage limits: unpaid usage can only go so far2053 const rebuilt =
2054 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2055 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2056 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2057 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2058 : null;
2059 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2060 }
2061 }
2062 }
2063
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2064 /**
2065 * Scripts in the namespace that no app holds, such as ones renamed. An
2066 * old address that redirects to its app's new one is held until its
2067 * redirect expires, then removed with the rest.
2068 */
Projects: what a workspace builds and runs, first on every page2069 private async removeOrphans(apps: AppRow[]): Promise<void> {
2070 const cloudflare = this.cloudflare;
2071 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2072 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2073 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2074 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2075 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 page2076 const building = await this.db
2077 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2078 .all<{ script: string }>();
2079 for (const row of building.results) held.add(row.script);
2080 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2081 for (const script of await cloudflare.listScripts()) {
2082 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2083 }
2084 }
2085
Deployments: a preview for every pull request, production on g1t.page2086 /** Counts this month's requests and CPU time per workspace, from analytics. */
2087 private async count(apps: AppRow[]): Promise<void> {
2088 const cloudflare = this.cloudflare;
2089 if (!cloudflare || apps.length === 0) return;
2090 const start = `${month()}-01T00:00:00Z`;
2091 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2092 // Analytics only counts apps that are up; the meter keeps what earlier
2093 // apps used by never going down.
2094 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2095 for (const app of apps) {
2096 const used = totals.get(app.script);
2097 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2098 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2099 sum.requests += used.requests;
2100 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2101 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2102 }
2103 const at = now();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2104 // Written only when the count moves the meter: most sweeps it does not.
2105 const stored = new Map(
2106 (
2107 await this.db
2108 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2109 .bind(month())
2110 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2111 ).results.map((row) => [row.namespace, row]),
2112 );
Projects: what a workspace builds and runs, first on every page2113 for (const [workspace, used] of perWorkspace) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2114 if (!movesMeter(stored.get(workspace), used)) continue;
Deployments: a preview for every pull request, production on g1t.page2115 await this.db
2116 .prepare(
2117 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2118 ON CONFLICT (namespace, month) DO UPDATE SET
2119 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2120 )
Projects: what a workspace builds and runs, first on every page2121 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2122 .run();
2123 }
2124 // When each preview last answered anyone, for the idle sweep.
2125 const recent = await cloudflare.usage(
2126 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2127 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2128 at,
2129 );
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2130 // At an hour's resolution: previews idle for days are what it finds.
2131 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2132 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
Deployments: a preview for every pull request, production on g1t.page2133 for (const [script, used] of recent) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2134 const last = lastRequest.get(script);
2135 if (used.requests > 0 && (!last || last < hourAgo)) {
Deployments: a preview for every pull request, production on g1t.page2136 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2137 }
2138 }
Projects: what a workspace builds and runs, first on every page2139 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 quotas2140 // What this month's traffic and custom domains will cost, from the
2141 // first request and the first domain, so the workspace's limit counts
2142 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2143 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2144 const billing = billingClient(this.env.BILLING);
2145 const meters = await this.db
2146 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2147 .bind(month())
2148 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2149 for (const meter of meters.results) {
2150 const cost = monthCost(meter, costs);
2151 await billing
2152 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2153 .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 quotas2154 if ((meter.peak_domains ?? 0) > 0) {
2155 await billing
2156 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2157 .catch((error) => console.error("could not note pending usage", error));
2158 }
Prices keep themselves current with what g1t pays2159 }
Deployments: a preview for every pull request, production on g1t.page2160 }
2161
Projects: what a workspace builds and runs, first on every page2162 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2163 private async takeDownIdle(): Promise<void> {
2164 const idle = await this.db
2165 .prepare(
Projects: what a workspace builds and runs, first on every page2166 `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 member2167 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2168 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2169 )
2170 .all<{ script: string }>();
2171 for (const { script } of idle.results) await this.removeApp(script);
2172 }
2173
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2174 /** 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.page2175 private async chargeMonths(): Promise<void> {
2176 const due = await this.db
2177 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2178 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2179 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2180 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2181 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2182 const cost = monthCost(meter, costs);
2183 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2184 const charged = await billingClient(this.env.BILLING).chargeFeature({
2185 workspace: meter.namespace,
2186 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2187 costMicros: cost.micros,
2188 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2189 reference: `deployments/${meter.namespace}/${meter.month}`,
2190 });
2191 if (!charged.ok) continue;
2192 }
2193 await this.db
2194 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2195 .bind(now(), meter.namespace, meter.month)
2196 .run();
2197 }
2198 }
2199}
2200
2201/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2202async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2203 switch (method) {
2204 case "settings":
2205 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2206 case "is_enabled":
2207 return service.isEnabled(args);
Deployments: a preview for every pull request, production on g1t.page2208 case "update_settings":
2209 return service.updateSettings(args);
2210 case "list":
2211 return service.list(args);
2212 case "get":
2213 return service.get(args);
2214 case "redeploy":
2215 return service.redeploy(args);
2216 case "take_down":
2217 return service.takeDown(args);
Projects: what a workspace builds and runs, first on every page2218 case "overview":
2219 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2220 case "usage":
2221 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2222 case "domains":
2223 return service.listDomains(args);
2224 case "add_domain":
2225 return service.addDomain(args);
2226 case "remove_domain":
2227 return service.removeDomain(args);
2228 case "refresh_domain":
2229 return service.refreshDomain(args);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972230 // A repository's deployments wherever they run (repo-deployments.ts).
2231 case "list_deployments":
2232 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2233 case "get_deployment":
2234 return service.repoDeployments.get(args);
2235 case "list_deployment_statuses":
2236 return service.repoDeployments.statuses(args);
2237 case "list_environments":
2238 return service.repoDeployments.environments(args);
2239 case "get_environment":
2240 return service.repoDeployments.environment(args);
2241 case "create_deployment":
2242 return service.repoDeployments.create(args);
2243 case "create_deployment_status":
2244 return service.repoDeployments.createStatus(args);
2245 // For the actions service: a run's deployment to one environment.
2246 case "actions_deployment":
2247 return service.repoDeployments.fromActions(args);
Deployments: a preview for every pull request, production on g1t.page2248 default:
2249 return undefined;
2250 }
2251}
2252
2253export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2254 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2255 const { pathname } = new URL(request.url);
2256 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2257 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2258 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2259 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2260 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2261 const opened = openD1(env.DB, request);
2262 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2263 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2264 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.page2265 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2266 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2267 // A build's reports, forwarded by the API.
2268 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2269 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2270 return new Response("Not found\n", { status: 404 });
2271 },
2272
2273 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2274 const service = new Deployments(env);
2275 for (const message of batch.messages) {
2276 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2277 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2278 message.ack();
2279 } catch (error) {
2280 console.error("deployments could not handle", message.body.type, error);
2281 message.retry();
2282 }
2283 }
2284 },
2285
2286 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2287 await new Deployments(env).sweep();
2288 },
2289} satisfies ExportedHandler<Env, G1tEvent>;

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