Skip to content
2,285 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,
Deployments: a preview for every pull request, production on g1t.page47 newId,
48 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents49 openD1,
Projects: what a workspace builds and runs, first on every page50 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look51 repoMove,
Deployments: a preview for every pull request, production on g1t.page52 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains54 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page55 workClient,
56 type DeployKind,
57 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page59 type DeployStatus,
60 type DeployUsage,
61 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains62 type Domain,
Deployments: a preview for every pull request, production on g1t.page63 type G1tEvent,
64 type LiveApp,
Projects: what a workspace builds and runs, first on every page65 type Project,
66 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains68 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page69 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights70 workOwner,
Deployments: a preview for every pull request, production on g1t.page71 type RepoPath,
72 type Result,
73 type ServiceBinding,
74 type User,
75 type Viewer,
76} from "@g1t/contracts";
77
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights78import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page79import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains80import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas81import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails82import { monthCost, movesMeter } from "./metering";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit83import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
84import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains85import { appHost, appUrl, label, uniqueLabel } from "./names";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9786import { RepoDeployments, sourceOf } from "./repo-deployments";
Deployments: a preview for every pull request, production on g1t.page87
88type Env = {
89 DB: D1Database;
90 REPOS: ServiceBinding;
91 WORK: ServiceBinding;
92 IDENTITY: ServiceBinding;
93 BILLING: ServiceBinding;
94 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page95 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments96 /** Secrets and variables: the actions service holds the one store. */
97 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published98 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
99 EVENTS?: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page100 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
101 CLOUDFLARE_API_TOKEN?: string;
102 CLOUDFLARE_ACCOUNT_ID: string;
103 DISPATCH_NAMESPACE: string;
104 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains105 /** Custom domains: hostname to app, read by the dispatcher. */
106 DOMAINS?: KVNamespace;
107 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
108 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look109 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
110 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page111};
112
113/** A build that has not reported in this long has died. */
114const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page115/** A script in the namespace that no app holds, older than this, is removed. */
116const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page117const LIST_LIMIT = 50;
118const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains119/**
120 * Deliveries of `workspace.renamed` that wait for the projects service to
121 * have seen it too, before going ahead with the new slug regardless.
122 */
123const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas124/** Why a build under way was dropped by a move; the move builds it again under the new name. */
125const MOVED_ERROR = "The repository moved: built again under its new name.";
126const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit127/**
128 * A move's rebuild that could not start, or failed, is tried again by the
129 * sweep after this long, doubled for each failure after the first, up to
130 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
131 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas132const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page133
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look134/** A build's report of what it found the project to be, if it is one g1t knows. */
135export function detectedKind(value: unknown): DetectedKind | null {
136 return value === "workers" || value === "static" || value === "html" ? value : null;
137}
138
Deployments: a preview for every pull request, production on g1t.page139const now = () => new Date().toISOString();
140const month = (at = new Date()) => at.toISOString().slice(0, 7);
141
142async function sha256(text: string): Promise<string> {
143 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
144 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
145}
146
147function randomToken(): string {
148 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
149}
150
151function isMember(viewer: Viewer, slug: string): boolean {
152 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
153}
154
Projects: what a workspace builds and runs, first on every page155/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look156/** One compute gate per isolate, so entitlements and prices are kept between calls. */
157let computeGate: ComputeGate | null = null;
158function gateFor(billing: ServiceBinding): ComputeGate {
159 computeGate ??= new ComputeGate(billing);
160 return computeGate;
161}
162
163/** The longest a build may run, in minutes: as long as its read token lasts. */
164const BUILD_MINUTES = 30;
165
166/**
167 * A build that was skipped before it started (its plan, its limit, or
168 * billing's refusal) as a failure, so whoever asked for it sees why.
169 */
170function notStarted(result: Result<Deployment>): Result<Deployment> {
171 if (result.ok && result.value.status === "skipped" && result.value.error) {
172 return fail("payment_required", result.value.error);
173 }
174 return result;
175}
176
Projects: what a workspace builds and runs, first on every page177function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
178 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
179 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
180}
181
Deployments: a preview for every pull request, production on g1t.page182type SettingsRow = {
Projects: what a workspace builds and runs, first on every page183 project_id: string;
184 workspace: string;
185 slug: string;
Deployments: a preview for every pull request, production on g1t.page186 repo_id: string;
187 enabled: number;
188 previews: number;
189 production: number;
190 build_command: string | null;
191 output_dir: string | null;
192 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look193 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
194 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member195 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
196 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page197};
198
199type DeploymentRow = {
200 id: string;
Projects: what a workspace builds and runs, first on every page201 project_id: string;
202 workspace: string;
203 slug: string;
Deployments: a preview for every pull request, production on g1t.page204 repo_id: string;
Projects: what a workspace builds and runs, first on every page205 repo: string;
Deployments: a preview for every pull request, production on g1t.page206 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page207 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page208 number: number | null;
209 commit_sha: string;
210 script: string;
211 status: DeployStatus;
212 error: string | null;
213 warnings: string;
214 log: string | null;
215 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments216 trusted: number;
Deployments: a preview for every pull request, production on g1t.page217 build_seconds: number | null;
218 created_by: string;
219 created_at: string;
220 finished_at: string | null;
221};
222
223type AppRow = {
224 script: string;
Projects: what a workspace builds and runs, first on every page225 project_id: string;
226 workspace: string;
227 slug: string;
Deployments: a preview for every pull request, production on g1t.page228 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page229 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page230 number: number | null;
231 commit_sha: string;
232 deployed_at: string;
233 created_at: string;
234 last_request_at: string | null;
Usage limits: unpaid usage can only go so far235 /** Set while its workspace is over its limit; see `holdToLimits`. */
236 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page237};
238
239function toDeployment(row: DeploymentRow): Deployment {
240 return {
241 id: row.id,
242 kind: row.kind,
Projects: what a workspace builds and runs, first on every page243 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page244 number: row.number,
245 commit: row.commit_sha,
246 status: row.status,
247 url: appUrl(row.script),
248 error: row.error,
249 warnings: JSON.parse(row.warnings || "[]") as string[],
250 buildSeconds: row.build_seconds,
251 createdBy: row.created_by,
252 createdAt: row.created_at,
253 finishedAt: row.finished_at,
254 };
255}
256
Projects: what a workspace builds and runs, first on every page257function toLive(app: AppRow): LiveApp {
258 return {
259 kind: app.kind,
260 branch: app.branch,
261 number: app.number,
262 url: appUrl(app.script),
263 commit: app.commit_sha,
264 deployedAt: app.deployed_at,
265 };
266}
267
Deployments: a preview for every pull request, production on g1t.page268class Deployments {
269 constructor(private readonly env: Env) {}
270
271 private get cloudflare(): Cloudflare | null {
272 const token = this.env.CLOUDFLARE_API_TOKEN;
273 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
274 }
275
276 private get db() {
277 return this.env.DB;
278 }
279
Agents and memory, checks and conflicts, profiles, slug renames, custom domains280 private get domains(): Domains {
281 const token = this.env.CLOUDFLARE_API_TOKEN;
282 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
283 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
284 }
285
Projects: what a workspace builds and runs, first on every page286 private get projects() {
287 return projectsClient(this.env.PROJECTS);
288 }
289
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97290 /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */
291 get repoDeployments(): RepoDeployments {
292 return new RepoDeployments(this.env);
293 }
294
295 /**
296 * Publishes a build's change in the repository-wide model
297 * (`deployment.created`, `deployment_status.created`), as a reported
298 * deployment's are. Never fails the build.
299 */
300 private async buildChanged(id: string, created = false): Promise<void> {
301 try {
302 const row = await this.deploymentRow(id);
303 if (!row) return;
304 const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id);
305 const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main";
306 await this.repoDeployments.buildChanged(row, defaultBranch, created);
307 } catch (error) {
308 console.error("build change not published", id, String(error));
309 }
310 }
311
Deployments: a preview for every pull request, production on g1t.page312 /** The workspace itself, as the service acts for it. */
313 private async workspaceActor(slug: string): Promise<User | null> {
314 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
315 if (!workspace) return null;
316 return {
317 id: workspace.id,
318 username: workspace.slug,
319 kind: "workspace",
320 verified: true,
321 workspaces: [{ slug: workspace.slug, role: "member" }],
322 };
323 }
324
Secrets and variables: one list, rows per environment, for workflows and deployments325 /**
Projects: what a workspace builds and runs, first on every page326 * What the project's secrets and variables available to deployments give
327 * production or a preview: its build's environment, and the same again as
328 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments329 */
330 private async resolve(
Projects: what a workspace builds and runs, first on every page331 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments332 environment: DeployKind,
333 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know334 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments335 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
336 method: "POST",
337 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page338 body: JSON.stringify({
339 repoId: project.repoId,
340 repo: project.repo,
341 projectId: project.id,
342 projectSlug: project.slug,
343 consumer: "deployments",
344 environment,
345 trusted,
346 }),
Secrets and variables: one list, rows per environment, for workflows and deployments347 });
348 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
349 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
350 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
351 }
352
353 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights354 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
355 * it, or its author) is trusted with the project's secrets: g1t itself,
356 * or someone who can push to the repository (Write or more, a member's or
357 * a collaborator's). Anyone else gets a preview built without them, as
358 * their workflows run. A change g1t made for someone is trusted as they
359 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments360 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights361 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
362 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look363 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 rights364 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look365 .catch(() => null);
366 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments367 }
368
Projects: what a workspace builds and runs, first on every page369 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
370 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page371 }
372
Projects: what a workspace builds and runs, first on every page373 /** The name an app gets, unique among apps: production, or a branch's preview. */
374 private async scriptFor(project: Project, branch: string | null): Promise<string> {
375 const base = await label(project.workspace, project.slug, branch);
376 const holder = await this.db
377 .prepare(
378 `SELECT project_id, branch FROM apps WHERE script = ?1
379 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
380 )
381 .bind(base)
382 .first<{ project_id: string; branch: string | null }>();
383 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
384 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
385 }
386
387 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page388 return {
389 enabled: !!row?.enabled,
390 previews: row ? !!row.previews : true,
391 production: row ? !!row.production : true,
392 buildCommand: row?.build_command ?? null,
393 outputDir: row?.output_dir ?? null,
394 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page395 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains396 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look397 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page398 };
399 }
400
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look401 /** What the project's last finished build found it to be; null before one has. */
402 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
403 const row = await this.db
404 .prepare(
405 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
406 )
407 .bind(projectId)
408 .first<{ detected: string }>()
409 .catch(() => null);
410 return detectedKind(row?.detected);
411 }
412
413 /**
414 * The project, if `viewer` may do what `method` needs on its repository
415 * (see `NEEDS`): not found when they cannot read it, refused when they can
416 * but their role is too low.
417 */
418 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
419 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
420 if (!found.ok) return found;
421 const repo = repoRef(found.value);
422 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
423 const capability = NEEDS[method];
424 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
425 return found;
Deployments: a preview for every pull request, production on g1t.page426 }
427
428 // ---- Methods for the site and the API ------------------------------
429
Projects: what a workspace builds and runs, first on every page430 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 look431 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page432 if (!project.ok) return project;
433 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page434 }
435
436 async updateSettings(a: {
437 actor: User;
Projects: what a workspace builds and runs, first on every page438 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page439 changes: Partial<DeploySettings>;
440 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page442 if (!found.ok) return found;
443 const project = found.value;
444 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page445 const next = { ...before, ...a.changes };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97446 // A library, a tool or other, as set in its settings, does not deploy.
447 if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") {
448 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 production449 }
Deployments: a preview for every pull request, production on g1t.page450 if (next.enabled && !before.enabled) {
451 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page452 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page453 if (!plan.ok) return plan;
454 }
455 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
456 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
457 await this.db
458 .prepare(
Projects: what a workspace builds and runs, first on every page459 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
460 output_dir, idle_days, updated_by, updated_at)
461 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
462 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
463 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.page464 )
465 .bind(
Projects: what a workspace builds and runs, first on every page466 project.id,
467 project.workspace,
468 project.slug,
469 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page470 next.enabled ? 1 : 0,
471 next.previews ? 1 : 0,
472 next.production ? 1 : 0,
473 clip(next.buildCommand),
474 clip(next.outputDir),
475 idleDays,
476 a.actor.username,
477 now(),
478 )
479 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production480 // Projects keeps whether it deploys, so a project with Deployments on is an app.
481 if (next.enabled !== before.enabled) {
482 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
483 }
Deployments: a preview for every pull request, production on g1t.page484 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page485 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page486 else {
Projects: what a workspace builds and runs, first on every page487 if (!next.previews) await this.takeDownWhere(project.id, "preview");
488 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page489 }
490 // Turned on: production goes up from the default branch at once.
491 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page492 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page493 }
Projects: what a workspace builds and runs, first on every page494 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page495 }
496
A project is an app or a library: libraries show their package and how to ship a release, not production497 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
498 async isEnabled(a: { projectId: string }): Promise<boolean> {
499 return !!(await this.settingsRow(a.projectId))?.enabled;
500 }
501
Projects: what a workspace builds and runs, first on every page502 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 look503 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page504 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page505 const [deployments, apps] = await Promise.all([
506 this.db
Projects: what a workspace builds and runs, first on every page507 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
508 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page509 .all<DeploymentRow>(),
510 this.db
Projects: what a workspace builds and runs, first on every page511 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
512 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page513 .all<AppRow>(),
514 ]);
Projects: what a workspace builds and runs, first on every page515 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page516 }
517
Projects: what a workspace builds and runs, first on every page518 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 look519 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page520 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page521 const row = await this.db
Projects: what a workspace builds and runs, first on every page522 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
523 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page524 .first<DeploymentRow>();
525 if (!row) return fail("not_found", "No such deployment.");
526 return ok({ ...toDeployment(row), log: row.log });
527 }
528
Projects: what a workspace builds and runs, first on every page529 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 look530 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page531 if (!found.ok) return found;
532 const project = found.value;
533 const settings = await this.settingsRow(project.id);
534 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
535 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look536 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 page537 }
538 // A branch's preview comes from its pull request.
539 const app = await this.db
540 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
541 .bind(project.id, a.branch)
542 .first<{ number: number }>();
543 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 look544 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 know545 }
546
Projects: what a workspace builds and runs, first on every page547 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 look548 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page549 if (!project.ok) return project;
550 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page551 return ok(true);
552 }
553
Projects: what a workspace builds and runs, first on every page554 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
555 const workspace = a.workspace.toLowerCase();
556 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 look557 // Only the projects whose repositories the viewer can read: the
558 // projects service lists no others.
559 const listed = await this.projects.list(workspace, a.viewer);
560 if (!listed.ok) return listed;
561 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page562 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look563 // A deleted repository's projects are hidden until it is restored.
564 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 page565 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
566 this.db
567 .prepare(
568 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
569 )
570 .bind(workspace)
571 .all<DeploymentRow>(),
572 ]);
573 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look574 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page575 const own = apps.results.filter((app) => app.slug === row.slug);
576 const production = own.find((app) => app.kind === "production");
577 const newest = latest.results.find((d) => d.slug === row.slug);
578 return {
579 slug: row.slug,
580 enabled: !!row.enabled,
581 production: production ? toLive(production) : null,
582 previews: own.filter((app) => app.kind === "preview").length,
583 latest: newest ? toDeployment(newest) : null,
584 };
585 }),
586 );
587 }
588
Deployments: a preview for every pull request, production on g1t.page589 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
590 const slug = a.workspace.toLowerCase();
591 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
592 const [meter, apps] = await Promise.all([
593 this.db
594 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
595 .bind(slug, month())
596 .first<{
597 requests: number;
598 cpu_ms: number;
599 peak_apps: number;
600 build_seconds: number;
601 build_micros: number;
602 counted_at: string | null;
603 }>(),
Projects: what a workspace builds and runs, first on every page604 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.page605 ]);
606 return ok({
607 month: month(),
608 requests: meter?.requests ?? 0,
609 cpuMs: meter?.cpu_ms ?? 0,
610 apps: apps?.n ?? 0,
611 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
612 buildSeconds: meter?.build_seconds ?? 0,
613 buildMicros: meter?.build_micros ?? 0,
614 countedAt: meter?.counted_at ?? null,
615 });
616 }
617
Agents and memory, checks and conflicts, profiles, slug renames, custom domains618 // ---- Custom domains ------------------------------------------------
619
620 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 look621 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains622 if (!project.ok) return project;
623 const domains = this.domains;
624 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 quotas625 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains626 domains.forProject(project.value.id),
627 domains.available(),
628 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas629 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains630 ]);
631 return ok({
632 domains: rows.map(toDomain),
633 target: CUSTOM_DOMAIN_TARGET,
634 available,
635 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas636 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains637 used,
638 });
639 }
640
641 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 look642 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains643 if (!found.ok) return found;
644 const project = found.value;
645 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
646 if (!plan.ok) return plan;
647 const added = await this.domains.add({
648 project: { id: project.id, workspace: project.workspace, slug: project.slug },
649 script: await this.productionScript(project),
650 hostname: String(a.hostname ?? ""),
651 twin: !!a.twin,
652 by: a.actor.username,
653 });
654 if (!added.ok) return fail(added.code, added.message);
655 await this.notePeak(project.workspace);
656 return ok(added.rows.map(toDomain));
657 }
658
659 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 look660 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains661 if (!found.ok) return found;
662 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
663 if (!row) return fail("not_found", "No such domain.");
664 await this.domains.remove(row);
665 return ok(true);
666 }
667
668 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 look669 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains670 if (!found.ok) return found;
671 const domains = this.domains;
672 const row = await domains.byId(found.value.id, String(a.id ?? ""));
673 if (!row) return fail("not_found", "No such domain.");
674 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
675 await this.notePeak(found.value.workspace);
676 return ok(toDomain(after));
677 }
678
679 /** The script production is up under, or the name it will have. */
680 private async productionScript(project: Project): Promise<string> {
681 const app = await this.db
682 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
683 .bind(project.id)
684 .first<{ script: string }>();
685 return app?.script ?? (await this.scriptFor(project, null));
686 }
687
Deployments: a preview for every pull request, production on g1t.page688 // ---- Starting builds -----------------------------------------------
689
690 /**
691 * Opens a deployment and starts its build. Skipped, with the reason
692 * recorded, when the workspace's plan is off.
693 */
694 private async start(input: {
Projects: what a workspace builds and runs, first on every page695 project: Project;
Deployments: a preview for every pull request, production on g1t.page696 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page697 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page698 number: number | null;
699 commit: string;
700 source: RepoPath;
701 reader: User;
702 createdBy: string;
703 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page704 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments705 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page706 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page707 const { project } = input;
708 const repo = repoOf(project);
709 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page710 const id = newId("dpl");
711 const token = randomToken();
Projects: what a workspace builds and runs, first on every page712 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far713 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page714 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look715 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page716 ? plan.error.message
Usage limits: unpaid usage can only go so far717 : limit.ok && limit.value.state === "stopped"
718 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page719 : !cloudflare
720 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
721 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look722 // A build is compute: reserved with billing before it starts, and
723 // settled by its sandbox when it stops. A refusal is the deployment's
724 // status, saying what to do.
725 const compute = gateFor(this.env.BILLING);
726 let reservation: string | null = null;
727 let microsPerSecond = 0;
728 let maxRunMinutes: number | null = null;
729 if (!refused) {
730 const ent = await compute.entitlements(project.workspace);
731 microsPerSecond = await compute.microsPerSecond();
732 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
733 const isPrivate = await reposClient(this.env.REPOS)
734 .get(repo.path, null)
735 .then((found) => !found.ok || found.value.isPrivate)
736 .catch(() => true);
737 const admitted = await compute.admit(
738 {
739 workspace: project.workspace,
740 repo: repo.path,
741 public: !isPrivate,
742 kind: "deploy",
743 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
744 },
745 ent,
746 );
747 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
748 else refused = admitted.message;
749 }
Deployments: a preview for every pull request, production on g1t.page750 await this.db
751 .prepare(
Projects: what a workspace builds and runs, first on every page752 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
753 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
754 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page755 )
756 .bind(
757 id,
Projects: what a workspace builds and runs, first on every page758 project.id,
759 project.workspace,
760 project.slug,
761 repo.id,
762 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page763 input.kind,
Projects: what a workspace builds and runs, first on every page764 input.branch,
Deployments: a preview for every pull request, production on g1t.page765 input.number,
766 input.commit,
767 script,
768 refused ? "skipped" : "queued",
769 refused,
770 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments771 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page772 input.createdBy,
773 now(),
774 refused ? now() : null,
775 )
776 .run();
777 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
778 // Older builds of the same app are replaced by this one.
779 await this.db
780 .prepare(
781 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
782 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
783 )
784 .bind(now(), script, id)
785 .run();
Projects: what a workspace builds and runs, first on every page786 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-a69aeabc4b0deeb97787 await this.buildChanged(id, true);
Projects: what a workspace builds and runs, first on every page788 // What the project's secrets and variables give builds of this kind.
789 const build = await this.resolve(
790 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
791 input.kind,
792 input.trusted,
793 );
Deployments: a preview for every pull request, production on g1t.page794 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
795 method: "POST",
796 headers: { "content-type": "application/json" },
797 body: JSON.stringify({
798 deployId: id,
799 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look800 workspace: project.workspace,
801 reservation,
802 microsPerSecond,
803 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page804 actor: input.reader,
805 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas806 // The project, whose guardrails the build runs under: a preview's
807 // source is its pull request's working copy, not the project.
808 repo: repo.path,
809 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page810 commit: input.commit,
Projects: what a workspace builds and runs, first on every page811 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page812 buildCommand: input.settings.build_command,
813 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments814 buildEnv: build.variables,
815 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page816 }),
817 });
818 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 look819 if (!started.ok) {
820 // Never reached a sandbox: what was reserved is given back.
821 if (reservation) await compute.settle(reservation, 0);
822 await this.finishFailed(id, started.error.message, null, null);
823 }
Deployments: a preview for every pull request, production on g1t.page824 return ok(toDeployment((await this.deploymentRow(id))!));
825 }
826
Projects: what a workspace builds and runs, first on every page827 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
828 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 member829 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 page830 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page831 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page832 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page833 let head = commit;
834 if (!head) {
Projects: what a workspace builds and runs, first on every page835 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
836 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page837 }
838 if (!head) return null;
839 return this.start({
Projects: what a workspace builds and runs, first on every page840 project,
Deployments: a preview for every pull request, production on g1t.page841 kind: "production",
Projects: what a workspace builds and runs, first on every page842 branch: null,
Deployments: a preview for every pull request, production on g1t.page843 number: null,
844 commit: head,
Projects: what a workspace builds and runs, first on every page845 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page846 reader: actor,
847 createdBy,
848 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments849 // The default branch only moves by people and agents with access.
850 trusted: true,
Deployments: a preview for every pull request, production on g1t.page851 });
852 }
853
Projects: what a workspace builds and runs, first on every page854 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
855 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 member856 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 page857 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page858 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page859 const repo = repoOf(project);
860 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page861 if (!detail.ok) return null;
862 const { pull } = detail.value;
863 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page864 // A pull request from a fork (as g1t's agents work) has no branch here.
865 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page866 if (!force) {
867 // Already built, or being built, at this commit.
868 const same = await this.db
869 .prepare(
Projects: what a workspace builds and runs, first on every page870 `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.page871 AND status IN ('queued', 'building', 'ready')`,
872 )
Projects: what a workspace builds and runs, first on every page873 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page874 .first();
875 if (same) return null;
876 }
877 return this.start({
Projects: what a workspace builds and runs, first on every page878 project,
Deployments: a preview for every pull request, production on g1t.page879 kind: "preview",
Projects: what a workspace builds and runs, first on every page880 branch,
Deployments: a preview for every pull request, production on g1t.page881 number,
882 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page883 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 rights884 // The pull request's fork may be private: read it as whoever it is
885 // for (whoever asked g1t for it, or its author), who is also who is
886 // trusted or not with the project's secrets.
887 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page888 createdBy,
889 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights890 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page891 });
892 }
893
894 // ---- A build's reports ---------------------------------------------
895
896 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
897 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
898 }
899
900 /** The build, if `token` is its own and it is still under way. */
901 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
902 const row = await this.deploymentRow(id);
903 if (!row?.token_hash || typeof token !== "string") return null;
904 if (row.token_hash !== (await sha256(token))) return null;
905 return row.status === "queued" || row.status === "building" ? row : null;
906 }
907
908 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run909 // Each report, for the logs: a build's own failure says why.
910 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page911 const row = await this.building(id, body.token);
912 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
913 const cloudflare = this.cloudflare;
914 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
915 switch (step) {
916 case "started":
917 await this.db
918 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
919 .bind(now(), id)
920 .run();
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97921 await this.buildChanged(id);
Deployments: a preview for every pull request, production on g1t.page922 return Response.json(ok(true));
923 case "session": {
924 const manifest = body.manifest as Manifest | undefined;
925 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
926 const session = await cloudflare.openUpload(row.script, manifest);
927 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
928 }
929 case "finish": {
930 const worker = (body.worker ?? {}) as BuiltWorker;
931 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page932 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page933 try {
Secrets and variables: one list, rows per environment, for workflows and deployments934 // Running apps' secrets and variables are bound here, by g1t:
935 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page936 const runtime = await this.resolve(
937 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
938 row.kind,
939 !!row.trusted,
940 );
Deployments: a preview for every pull request, production on g1t.page941 await cloudflare.putScript(
942 row.script,
943 worker,
944 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page945 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments946 runtime,
Deployments: a preview for every pull request, production on g1t.page947 );
948 } catch (error) {
949 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
950 return Response.json(ok(false));
951 }
952 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas953 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.page954 await this.db.batch([
955 this.db
956 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look957 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page958 WHERE id = ?`,
959 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look960 .bind(
961 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
962 String(body.log ?? ""),
963 seconds,
964 at,
965 detectedKind(body.detected),
966 id,
967 ),
Builds say Replaced or Down once they are no longer live968 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page969 this.db
Builds say Replaced or Down once they are no longer live970 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
971 .bind(row.script, id),
972 this.db
Deployments: a preview for every pull request, production on g1t.page973 .prepare(
Projects: what a workspace builds and runs, first on every page974 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
975 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far976 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.page977 )
Projects: what a workspace builds and runs, first on every page978 .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 quotas979 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains980 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page981 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas982 if (wasRedirect) {
983 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
984 }
985 // The same app at an older name (its project moved) now redirects
986 // here; it stays up as it was if the redirect cannot be put.
987 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains988 // The project's own domains serve production wherever it is up.
989 if (row.kind === "production") {
990 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
991 }
Deployments: a preview for every pull request, production on g1t.page992 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page993 await this.notePeak(row.workspace);
994 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 published995 await this.announce(row, null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97996 await this.buildChanged(id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look997 // A screenshot of production as it now is, for the project's overview.
998 if (row.kind === "production") {
999 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1000 console.error("could not ask for a screenshot", error),
1001 );
1002 }
Deployments: a preview for every pull request, production on g1t.page1003 return Response.json(ok(true));
1004 }
1005 case "fail":
1006 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1007 return Response.json(ok(true));
1008 default:
1009 return Response.json(fail("not_found", "No such step."), { status: 404 });
1010 }
1011 }
1012
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1013 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1014 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1015 * 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 quotas1016 * same is the app under a name it had before its project moved (its
1017 * workspace renamed, its repository renamed or transferred). Each is
1018 * replaced in the namespace by a redirect to the new name, its app row
1019 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1020 * the sweep to hold the old script) and in `DOMAINS` under the old
1021 * hostname, which the dispatcher follows before running anything, so the
1022 * old address redirects even if the old script is paused. A name that
1023 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1024 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1025 private async supersede(
1026 cloudflare: Cloudflare,
1027 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1028 script: string,
1029 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1030 const older = await this.db
1031 .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 quotas1032 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1033 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1034 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1035 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1036 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1037 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1038 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1039 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1040 console.error("could not redirect", old, "to", script, error);
1041 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1042 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1043 const at = now();
1044 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1045 await this.db.batch([
1046 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1047 this.db
1048 .prepare(
1049 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1050 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1051 )
1052 .bind(old, target, app.workspace, at, expires),
1053 // Its builds are no longer live under the old name.
1054 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1055 ]);
1056 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1057 expiration: Math.floor(Date.parse(expires) / 1000),
1058 }).catch((error) => console.error("could not record redirect", old, error));
1059 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1060 }
1061 return done;
1062 }
1063
Deployments: a preview for every pull request, production on g1t.page1064 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1065 const row = await this.deploymentRow(id);
1066 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1067 // A commit that is gone says so plainly; git's own words stay in the log.
1068 if (commitMissing(message)) {
1069 log = log ?? message;
1070 message = missingCommitMessage(row);
1071 }
Deployments: a preview for every pull request, production on g1t.page1072 await this.db
1073 .prepare(
1074 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1075 WHERE id = ?`,
1076 )
1077 .bind(message.slice(0, 2000), log, seconds, now(), id)
1078 .run();
1079 // A failed build still used its sandbox.
1080 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1081 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1082 await this.announce(row, message.slice(0, 300));
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971083 await this.buildChanged(id);
Events: review requests, assignments, stops and deployments are published1084 }
1085
1086 /**
1087 * Publishes a finished build: `deployment.failed` with what went wrong,
1088 * or `deployment.succeeded`, saying whether the build of the same app
1089 * before it failed. Whoever started it is the actor when it was a
1090 * person known by id; otherwise `triggeredBy` names them, or g1t.
1091 */
1092 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1093 if (!this.env.EVENTS) return;
1094 const previous = error
1095 ? null
1096 : await this.db
1097 .prepare(
1098 `SELECT status FROM deployments
1099 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1100 ORDER BY created_at DESC LIMIT 1`,
1101 )
1102 .bind(row.script, row.id, row.created_at)
1103 .first<{ status: string }>();
1104 const byId = row.created_by.startsWith("usr_");
1105 const event = {
1106 source: "deployments",
1107 repoId: row.repo_id,
1108 actor: byId ? row.created_by : null,
1109 data: {
1110 deploymentId: row.id,
1111 projectId: row.project_id,
1112 repoId: row.repo_id,
1113 workspace: row.workspace,
1114 project: row.slug,
1115 kind: row.kind,
1116 branch: row.branch,
1117 number: row.kind === "preview" ? row.number : null,
1118 commit: row.commit_sha,
1119 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1120 error,
1121 recovered: previous?.status === "failed",
1122 triggeredBy: byId ? "" : row.created_by,
1123 },
1124 };
1125 await eventsClient(this.env.EVENTS)
1126 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1127 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Deployments: a preview for every pull request, production on g1t.page1128 }
1129
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1130 /** 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.page1131 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1132 const costs = await this.costs();
1133 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1134 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1135 const what =
1136 row.kind === "preview"
1137 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1138 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1139 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1140 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1141 feature: "deployments",
1142 costMicros: cost,
1143 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1144 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1145 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1146 // Every second is metered; billing prices it from its price book and
1147 // 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 put1148 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1149 });
1150 await this.db
1151 .prepare(
1152 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1153 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1154 )
Projects: what a workspace builds and runs, first on every page1155 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1156 .run();
1157 }
1158
Prices keep themselves current with what g1t pays1159 /**
1160 * What each unit costs g1t now, from billing's price book, which follows
1161 * what Cloudflare bills. The plan's figures if billing cannot say.
1162 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1163 private async costs(): Promise<{
1164 buildSecond: number;
1165 millionRequests: number;
1166 millionCpuMs: number;
1167 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1168 /** What one custom domain is charged a month: the cost plus the margin. */
1169 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1170 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1171 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1172 const book = await billingClient(this.env.BILLING)
1173 .prices()
1174 .catch(() => null);
1175 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1176 return {
1177 buildSecond: cost("build_second", a.microsPerBuildSecond),
1178 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1179 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1180 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 quotas1181 domainMonthPrice:
1182 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1183 };
1184 }
1185
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1186 /** 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 page1187 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1188 await this.db
1189 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1190 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1191 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1192 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1193 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1194 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1195 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.page1196 )
Projects: what a workspace builds and runs, first on every page1197 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1198 .run();
1199 }
1200
Projects: what a workspace builds and runs, first on every page1201 /**
1202 * The check on the commit: `g1t / deploy`, or, for one of several
1203 * projects on a repository, `g1t / deploy (<project>)`.
1204 */
1205 private async status(
1206 repoId: string,
1207 sha: string,
1208 project: { slug: string; primary: boolean },
1209 state: string,
1210 description: string,
1211 targetUrl: string,
1212 ): Promise<void> {
1213 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1214 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1215 method: "POST",
1216 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1217 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
Deployments: a preview for every pull request, production on g1t.page1218 }).catch(() => undefined);
1219 }
1220
Projects: what a workspace builds and runs, first on every page1221 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1222 const projects = await this.projects.byRepo(row.repo_id);
1223 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1224 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1225 }
1226
Deployments: a preview for every pull request, production on g1t.page1227 // ---- Taking apps down ----------------------------------------------
1228
1229 private async removeApp(script: string): Promise<void> {
1230 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1231 await this.db.batch([
1232 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1233 // Its build is no longer live anywhere.
1234 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1235 ]);
Deployments: a preview for every pull request, production on g1t.page1236 }
1237
Projects: what a workspace builds and runs, first on every page1238 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1239 const apps = await this.db
1240 .prepare(
Projects: what a workspace builds and runs, first on every page1241 `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.page1242 )
Projects: what a workspace builds and runs, first on every page1243 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1244 .all<{ script: string }>();
1245 for (const app of apps.results) await this.removeApp(app.script);
1246 }
1247
1248 // ---- Events --------------------------------------------------------
1249
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1250 /** `attempts`: which delivery of the event this is, from 1. */
1251 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1252 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1253 case "workspace.renamed":
1254 await this.renamed(event.data, attempts);
1255 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1256 case "repo.transferred":
1257 case "repo.renamed":
1258 await this.moved(repoMove(event)!, attempts);
1259 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1260 // A repository that went or came back with its workspace is the
1261 // 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 look1262 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1263 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 look1264 break;
1265 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1266 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 look1267 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1268 case "workspace.deleting":
1269 await this.workspaceDeleting(event.data.slug);
1270 break;
1271 case "workspace.restored":
1272 await this.workspaceRestored(event.data.slug);
1273 break;
1274 case "workspace.deleted":
1275 await this.workspacePurged(event.data.slug);
1276 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1277 case "repo.purged":
1278 await this.repoPurged(event.data.repoId);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971279 await this.repoDeployments.purge(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1280 break;
1281 case "repo.default_branch_changed":
1282 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1283 break;
1284 case "branch.renamed":
1285 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1286 break;
1287
Deployments: a preview for every pull request, production on g1t.page1288 case "pull.opened":
1289 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1290 case "pull.updated":
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1291 case "pull.reopened":
Projects: what a workspace builds and runs, first on every page1292 for (const project of await this.projects.byRepo(event.data.repoId)) {
1293 await this.deployPreview(project, event.data.number, "g1t");
1294 }
Deployments: a preview for every pull request, production on g1t.page1295 break;
1296 case "pull.closed":
1297 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1298 for (const project of await this.projects.byRepo(event.data.repoId)) {
1299 const apps = await this.db
1300 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1301 .bind(project.id, event.data.number)
1302 .all<{ script: string }>();
1303 for (const app of apps.results) await this.removeApp(app.script);
1304 }
Deployments: a preview for every pull request, production on g1t.page1305 break;
Projects: what a workspace builds and runs, first on every page1306 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1307 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page1308 for (const project of await this.projects.byRepo(event.data.repoId)) {
1309 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1310 }
Deployments: a preview for every pull request, production on g1t.page1311 break;
1312 }
1313 }
1314
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1315 /**
1316 * A workspace's slug changed, and with it every app's name: production
1317 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1318 *
1319 * Its rows move to the slug it has now (asked of identity, so a delivery
1320 * 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 quotas1321 * each app (paused or not) is built again from the same commit under its
1322 * new name; see `followMoves`. The old name keeps serving the app until
1323 * the new one is live; then it redirects to the new name (see
1324 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1325 * 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 quotas1326 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1327 */
1328 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1329 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1330 const stale = staleSlugs(renamed, current);
1331 if (stale.length === 0) return;
1332 const marks = stale.map(() => "?").join(", ");
1333
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1334 // The workspace's projects, under any of its names: a second delivery
1335 // finds them under the new one, with whatever is left to do.
1336 const rows = await this.db
1337 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1338 .bind(...stale, current)
1339 .all<{ project_id: string }>();
1340 const projectIds = rows.results.map((row) => row.project_id);
1341 const projects = await this.projectsById(projectIds);
1342 // The projects service hears of the rename on its own queue: wait for
1343 // it a few deliveries, so the builds read the repository by its new name.
1344 const behind = [...projects.values()].some((p) => p.workspace !== current);
1345 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 domains1346
1347 // Every row moves at once.
1348 const at = now();
1349 const statements: D1PreparedStatement[] = [];
1350 for (const slug of stale) {
1351 statements.push(
1352 this.db
1353 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1354 `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 domains1355 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1356 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1357 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1358 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1359 this.db
1360 .prepare(
1361 `UPDATE deployments SET workspace = ?1,
1362 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1363 WHERE workspace = ?2`,
1364 )
1365 .bind(current, slug),
1366 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1367 this.domains.rename(slug, current),
1368 // Counters add up; the peak is the higher; a month charged stays charged.
1369 this.db
1370 .prepare(
1371 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1372 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1373 FROM meters WHERE namespace = ?2
1374 ON CONFLICT (namespace, month) DO UPDATE SET
1375 requests = requests + excluded.requests,
1376 cpu_ms = cpu_ms + excluded.cpu_ms,
1377 peak_apps = MAX(peak_apps, excluded.peak_apps),
1378 peak_domains = MAX(peak_domains, excluded.peak_domains),
1379 build_seconds = build_seconds + excluded.build_seconds,
1380 build_micros = build_micros + excluded.build_micros,
1381 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1382 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1383 )
1384 .bind(current, slug),
1385 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1386 );
1387 }
1388 await this.db.batch(statements);
1389
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 deploy1390 // 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 quotas1391 const followed = await this.followMoves(projectIds, {
1392 projects,
1393 adjust: (project) => this.underSlug(project, current, stale),
1394 });
1395 if (followed.failed.length > 0) {
1396 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1397 }
1398 }
1399
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1400 /**
1401 * A repository moved: transferred to another workspace, and its projects
1402 * with it, or renamed within its own, when its own project's slug follows
1403 * its name (see the projects service). Each app's name is
1404 * `<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 quotas1405 * slug changed (production and every preview, paused or not) are built
1406 * again, from the same commit, under the new name; see `followMoves`. As
1407 * after a workspace rename, the old name keeps serving until the new one
1408 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1409 * The project's custom domains follow its production. Builds under way
1410 * are started again under the new name. What the apps used this month
1411 * stays on the old workspace's meter; from now on, the new workspace's
1412 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1413 *
1414 * Projects whose name did not change (a rename where the new name was
1415 * 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 quotas1416 * path. What is left to rebuild is read from the rows each time, so a
1417 * second or late delivery builds only what the first could not, and one
1418 * whose rebuild could not be queued is delivered again (and, past the
1419 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1420 */
1421 private async moved(move: RepoMove, attempts: number): Promise<void> {
1422 const current = await currentMovedPath(this.env.REPOS, move);
1423 if (staleMovedPaths(move, current).length === 0) return;
1424 const [workspace, name] = current.split("/") as [string, string];
1425 const settings = await this.db
1426 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1427 .bind(move.repoId)
1428 .all<{ project_id: string; workspace: string; slug: string }>();
1429 if (settings.results.length === 0) return;
1430
1431 // The projects service hears of the move on its own queue: wait for it
1432 // a few deliveries, so builds read the project where and as it is now.
1433 const projects = new Map<string, Project>();
1434 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1435 const behind = settings.results.some(({ project_id }) => {
1436 const project = projects.get(project_id);
1437 if (!project || project.source.kind !== "hosted") return false;
1438 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1439 });
1440 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1441
1442 // Its history goes with it, as the repository's issues do.
1443 const statements: D1PreparedStatement[] = [
1444 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1445 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1446 // The projects whose apps' names change, and have not been moved yet.
1447 const moving = settings.results
1448 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1449 .map((row) => row.project_id);
1450 if (moving.length > 0) {
1451 const ids = moving.map(() => "?").join(", ");
1452 statements.push(
1453 this.db
1454 .prepare(
1455 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1456 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1457 )
1458 .bind(MOVED_ERROR, now(), ...moving),
1459 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1460 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1461 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1462 const slug = projects.get(projectId)?.slug ?? null;
1463 statements.push(
1464 this.db
1465 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1466 .bind(workspace, slug, projectId),
1467 this.db
1468 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1469 .bind(workspace, slug, projectId),
1470 this.db
1471 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1472 .bind(workspace, slug, projectId),
1473 // Within the workspace its apps are listed under the project's name
1474 // now. One left behind in another workspace stays as it is until the
1475 // new name is live and redirects it.
1476 this.db
1477 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1478 .bind(workspace, slug, projectId),
1479 );
1480 }
1481 await this.db.batch(statements);
1482
1483 // Each app again, under its new name. App rows under the old name stay
1484 // 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 quotas1485 const followed = await this.followMoves(
1486 settings.results.map((row) => row.project_id),
1487 {
1488 projects,
1489 adjust: (found) =>
1490 found.source.kind === "hosted"
1491 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1492 : { ...found, workspace },
1493 },
1494 );
1495 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1496 }
1497
1498 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1499 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1500 const projects = new Map<string, Project>();
1501 if (projectIds.length === 0) return projects;
1502 const repoIds = new Set<string>();
1503 for (let i = 0; i < projectIds.length; i += 50) {
1504 const chunk = projectIds.slice(i, i + 50);
1505 const rows = await this.db
1506 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1507 .bind(...chunk)
1508 .all<{ repo_id: string }>();
1509 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1510 }
1511 const wanted = new Set(projectIds);
1512 for (const repoId of repoIds) {
1513 for (const project of await this.projects.byRepo(repoId)) {
1514 if (wanted.has(project.id)) projects.set(project.id, project);
1515 }
1516 }
1517 return projects;
1518 }
1519
1520 /** Whether `script` is the name an app of the project has where the project is now. */
1521 private async namedNow(
1522 script: string,
1523 settings: { project_id: string; workspace: string; slug: string },
1524 branch: string | null,
1525 ): Promise<boolean> {
1526 const base = await label(settings.workspace, settings.slug, branch);
1527 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1528 }
1529
1530 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1531 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1532 const stale: AppRow[] = [];
1533 for (const app of apps) {
1534 const row = settings.get(app.project_id);
1535 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1536 }
1537 return stale;
1538 }
1539
1540 /**
1541 * Brings the apps of the projects `projectIds` (every project when null)
1542 * under the names they have where the projects are now: each app still
1543 * under an older name, paused or not, and each build a move dropped, is
1544 * built again under its new name from the same commit, and once that is
1545 * live the old name redirects to it (`supersede`).
1546 *
1547 * Idempotent, and safe to run again at any time: an app already up under
1548 * its new name only has its old names redirected; one whose rebuild is
1549 * queued or under way is left to it; one whose workspace has no
1550 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1551 * each time; one whose commit is gone, or whose rebuild failed the same
1552 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1553 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1554 * 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 quotas1555 * event's delivery to be retried.
1556 */
1557 private async followMoves(
1558 projectIds: string[] | null,
1559 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1560 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1561 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1562 if (projectIds && projectIds.length === 0) return result;
1563 const cloudflare = this.cloudflare;
1564 if (!cloudflare) return result;
1565
1566 const settingsRows =
1567 projectIds == null
1568 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1569 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1570 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1571 if (settings.size === 0) return result;
1572 const ids = [...settings.keys()];
1573
1574 const apps: AppRow[] = [];
1575 const dropped: DroppedBuild[] = [];
1576 for (let i = 0; i < ids.length; i += 50) {
1577 const chunk = ids.slice(i, i + 50);
1578 const marks = chunk.map(() => "?").join(", ");
1579 const [appRows, droppedRows] = await Promise.all([
1580 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1581 // Builds a move dropped, that nothing has been built in place of since.
1582 this.db
1583 .prepare(
1584 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1585 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1586 AND NOT EXISTS (
1587 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1588 AND n.created_at > d.created_at AND n.status != 'skipped'
1589 )`,
1590 )
1591 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1592 .all<DeploymentRow>(),
1593 ]);
1594 apps.push(...appRows.results);
1595 dropped.push(...droppedRows.results);
1596 }
1597 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1598 if (targets.length === 0) return result;
1599
1600 const projects = new Map(options.projects ?? []);
1601 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1602 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1603 const billing = billingClient(this.env.BILLING);
1604 const open = new Map<string, boolean>();
1605 const actors = new Map<string, User | null>();
1606
1607 for (const target of targets) {
1608 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1609 const found = projects.get(target.projectId);
1610 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1611 const project = options.adjust ? options.adjust(found) : found;
1612 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1613 // Built only where deployments has the project now; the projects
1614 // service catches up on its own queue, and a later run builds then.
1615 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1616 result.waiting++;
1617 continue;
1618 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1619 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1620 const script = await this.scriptFor(project, target.branch);
1621 // Already up under its new name: only its old names are left to redirect.
1622 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1623 if (up) {
1624 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1625 continue;
1626 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1627 const history = await this.recentBuilds(script);
1628 const last = history[0];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1629 if (last && (last.status === "queued" || last.status === "building")) {
1630 result.queued++;
1631 continue;
1632 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1633 // A commit that is gone, or a build that failed the same way a few
1634 // times, is not tried again; a push or a redeploy builds it.
1635 // Otherwise the sweep waits longer after each failure.
1636 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 quotas1637 result.waiting++;
1638 continue;
1639 }
1640 // A workspace without Deployments, or over its limit, waits for it
1641 // rather than gathering refused deployments.
1642 if (!open.has(project.workspace)) {
1643 const [plan, limit] = await Promise.all([
1644 billing.hasFeature(project.workspace, "deployments"),
1645 billing.checkLimit(project.workspace),
1646 ]);
1647 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1648 }
1649 if (!open.get(project.workspace)) {
1650 result.waiting++;
1651 continue;
1652 }
1653 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 look1654 const started =
1655 target.kind === "production"
1656 ? await this.deployProduction(project, target.commit, "g1t")
1657 : target.number != null
1658 ? 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 quotas1659 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1660 const outcome = rebuildOutcome(started);
1661 if (outcome === "queued") result.queued++;
1662 else if (outcome === "failed") {
1663 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1664 result.failed.push(`${named}: ${why}`);
1665 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1666 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1667 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1668 }
1669 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1670 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1671 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1672 }
1673
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1674 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1675 private async recentBuilds(script: string): Promise<PastBuild[]> {
1676 const rows = await this.db
1677 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1678 .bind(script, MAX_IDENTICAL_FAILURES)
1679 .all<PastBuild>();
1680 return rows.results;
1681 }
1682
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1683 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1684 private async projectIdsFor(repoId: string): Promise<string[]> {
1685 const rows = await this.db
1686 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1687 .bind(repoId)
1688 .all<{ project_id: string }>();
1689 return rows.results.map((row) => row.project_id);
1690 }
1691
1692 /**
1693 * A repository was deleted, restorable for a while: every app of its
1694 * projects (production and previews) comes down, builds under way are
1695 * dropped, and nothing builds for it until it is restored. Its settings
1696 * and custom domains are kept for the restore; until then a domain has
1697 * nothing up to serve, as when production is turned off.
1698 */
1699 private async repoDeleted(repoId: string): Promise<void> {
1700 const projectIds = await this.projectIdsFor(repoId);
1701 if (projectIds.length === 0) return;
1702 const at = now();
1703 await this.db.batch([
1704 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1705 this.db
1706 .prepare(
1707 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1708 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1709 )
1710 .bind(at, repoId),
1711 ]);
1712 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1713 }
1714
1715 /**
1716 * A deleted repository is back: production goes up again from its
1717 * default branch, for each project that has it on. Previews come back
1718 * with the next push to their pull requests.
1719 */
1720 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1721 const deleted = await this.db
1722 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1723 .bind(repoId)
1724 .all<{ project_id: string }>();
1725 const ids = deleted.results.map((row) => row.project_id);
1726 if (ids.length === 0) return;
1727 // The projects service hears of the restore on its own queue, and hides
1728 // the projects until then: wait for it a few deliveries.
1729 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1730 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1731 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1732 for (const project of projects) {
1733 try {
1734 const started = await this.deployProduction(project, null, "g1t");
1735 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1736 } catch (error) {
1737 console.error("could not deploy after restore", project.slug, error);
1738 }
1739 }
1740 }
1741
1742 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1743 * A workspace was deleted, restorable by g1t's staff for a while: every
1744 * app of its projects (production and previews) is paused, answering with
1745 * a notice and running nothing, builds under way are dropped, and nothing
1746 * builds for it until it is restored. Nothing is taken down: scripts,
1747 * settings and custom domains are kept for the restore. Never for a
1748 * protected workspace, whatever was published.
1749 */
1750 private async workspaceDeleting(slug: string): Promise<void> {
1751 const workspace = slug.toLowerCase();
1752 if (isProtectedWorkspace(workspace)) {
1753 console.error("workspace.deleting ignored for protected", workspace);
1754 return;
1755 }
1756 const at = now();
1757 await this.db.batch([
1758 this.db
1759 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1760 .bind(at, workspace),
1761 this.db
1762 .prepare(
1763 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1764 WHERE workspace = ? AND status IN ('queued', 'building')`,
1765 )
1766 .bind(at, workspace),
1767 ]);
1768 const cloudflare = this.cloudflare;
1769 for (const app of await this.appsOfWorkspace(workspace)) {
1770 if (app.paused_at) continue;
1771 await cloudflare?.pauseScript(app.script);
1772 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1773 }
1774 }
1775
1776 /**
1777 * A deleted workspace is back: it builds again, and its paused apps are
1778 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1779 * (the sweep tries again any it could not).
1780 */
1781 private async workspaceRestored(slug: string): Promise<void> {
1782 const workspace = slug.toLowerCase();
1783 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1784 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1785 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1786 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1787 }
1788
1789 /**
1790 * A deleted workspace is purged: whatever its projects still have up
1791 * comes down and their custom domains go, as for a purged repository.
1792 * Its own repositories' projects are purged with them (`repo.purged`);
1793 * this catches any building from a repository it had transferred away.
1794 */
1795 private async workspacePurged(slug: string): Promise<void> {
1796 const workspace = slug.toLowerCase();
1797 if (isProtectedWorkspace(workspace)) return;
1798 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1799 for (const { project_id } of rows.results) {
1800 await this.takeDownWhere(project_id, null);
1801 await this.domains.removeWhere("project_id", project_id);
1802 }
1803 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1804 await this.db.batch([
1805 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1806 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1807 ]);
1808 }
1809
1810 /** The apps of a workspace's projects, and any still under its name. */
1811 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1812 const rows = await this.db
1813 .prepare(
1814 `SELECT * FROM apps WHERE workspace = ?1
1815 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1816 )
1817 .bind(workspace)
1818 .all<AppRow>();
1819 return rows.results;
1820 }
1821
1822 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1823 * A deleted repository is gone for good: its projects' custom domains are
1824 * removed (from the dispatcher and from Cloudflare), any app or redirect
1825 * still up comes down, and every row kept for them goes. What they used
1826 * stays on their workspace's meter.
1827 */
1828 private async repoPurged(repoId: string): Promise<void> {
1829 const projectIds = await this.projectIdsFor(repoId);
1830 const domains = this.domains;
1831 for (const projectId of projectIds) {
1832 await this.takeDownWhere(projectId, null);
1833 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1834 await domains.removeWhere("project_id", projectId);
1835 }
1836 // The redirects left at names its apps had before.
1837 const scripts = await this.db
1838 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1839 .bind(repoId)
1840 .all<{ script: string }>();
1841 const hosts = scripts.results.map(({ script }) => appHost(script));
1842 for (let i = 0; i < hosts.length; i += 50) {
1843 const chunk = hosts.slice(i, i + 50);
1844 const redirects = await this.db
1845 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1846 .bind(...chunk)
1847 .all<{ script: string }>();
1848 for (const { script } of redirects.results) {
1849 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1850 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 look1851 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1852 }
1853 }
1854 await this.db.batch([
1855 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1856 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1857 ]);
1858 }
1859
1860 /**
1861 * The default branch is another one now: production is built from it, as
1862 * from a push to it, unless production already serves (or is building)
1863 * its commit, as when the default branch was only renamed.
1864 */
1865 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1866 for (const found of await this.projects.byRepo(repoId)) {
1867 if (found.source.kind !== "hosted") continue;
1868 // Projects may not have heard yet: the event names the branch.
1869 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1870 const actor = await this.workspaceActor(project.workspace);
1871 if (!actor) continue;
1872 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1873 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1874 if (!head) continue;
1875 const same = await this.db
1876 .prepare(
1877 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1878 AND status IN ('queued', 'building', 'ready')`,
1879 )
1880 .bind(project.id, head)
1881 .first();
1882 if (same) continue;
1883 await this.deployProduction(project, head, createdBy);
1884 }
1885 }
1886
1887 /**
1888 * A branch was renamed: its preview is the same app, so its rows follow.
1889 * The app keeps its name until it is next built; then it goes up under
1890 * the new branch's name, and the old one redirects there (see `supersede`).
1891 */
1892 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1893 const projectIds = await this.projectIdsFor(repoId);
1894 if (projectIds.length === 0) return;
1895 const ids = projectIds.map(() => "?").join(", ");
1896 await this.db.batch([
1897 this.db
1898 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1899 .bind(to, from, ...projectIds),
1900 this.db
1901 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
1902 .bind(to, from, ...projectIds),
1903 ]);
1904 }
1905
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1906 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1907 private underSlug(project: Project, current: string, stale: string[]): Project {
1908 const source =
1909 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1910 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1911 : project.source;
1912 return { ...project, workspace: current, source };
1913 }
1914
1915 /** A stack's preview (no pull request of its own) built again at `commit`. */
1916 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1917 if (!actor || branch == null) return null;
1918 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1919 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1920 return this.start({
1921 project,
1922 kind: "preview",
1923 branch,
1924 number: null,
1925 commit,
1926 source: repoOf(project).path,
1927 reader: actor,
1928 createdBy: "g1t",
1929 settings,
1930 // As `stack` built it: the project's own default branch.
1931 trusted: true,
1932 });
1933 }
1934
Deployments: a preview for every pull request, production on g1t.page1935 // ---- The sweep -----------------------------------------------------
1936
1937 /**
1938 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page1939 * previews, the apps of workspaces whose plan ended, and scripts no app
1940 * holds come down; and a month that is over is charged past its
1941 * allowance.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1942 *
1943 * Each step stands alone: one that fails is logged and the rest still
1944 * run. Taking idle previews down and charging months, which only read
1945 * g1t's own tables, go before the steps that wait on Cloudflare's API,
1946 * so a slow or failing API never holds them up.
Deployments: a preview for every pull request, production on g1t.page1947 */
1948 async sweep(): Promise<void> {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1949 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
1950 await step("failing stuck builds", async () => {
1951 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1952 const stuck = await this.db
1953 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1954 .bind(cutoff)
1955 .all<{ id: string }>();
1956 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1957 });
1958 await step("taking down idle previews", () => this.takeDownIdle());
1959 await step("charging months", () => this.chargeMonths());
Deployments: a preview for every pull request, production on g1t.page1960
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1961 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1962 // Each app is its project's workspace's, as deployments has it now: an
1963 // app still under the name it had before its project moved is the new
1964 // workspace's, and plans and limits are checked there.
1965 const settings = new Map(
1966 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
1967 );
1968 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 member1969 // A deleted workspace's apps stay paused as they are, for a restore:
1970 // its plan ended with the deletion, and that must not take them down.
1971 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
1972 const live = apps.filter((app) => !held(app));
1973 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page1974
1975 // Apps of workspaces whose plan has ended come down.
1976 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1977 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page1978 for (const workspace of workspaces) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails1979 await step(`ending ${workspace}'s apps`, async () => {
1980 const plan = await billing.hasFeature(workspace, "deployments");
1981 if (!plan.ok && plan.error.code === "payment_required") {
1982 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
1983 // Custom domains cost g1t by the month: they go with the plan.
1984 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
1985 }
1986 });
Deployments: a preview for every pull request, production on g1t.page1987 }
1988
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1989 // Apps whose project moved and are not up under the new name yet: a
1990 // move's rebuild that could not start is tried again here.
1991 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
1992 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1993
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1994 await this.domains
1995 .sweep(async (projectId) => {
1996 const app = await this.db
1997 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1998 .bind(projectId)
1999 .first<{ script: string }>();
2000 return app?.script ?? null;
2001 })
2002 .catch((error) => console.error("could not check domains", error));
2003
Projects: what a workspace builds and runs, first on every page2004 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page2005 await this.count(apps).catch((error) => console.error("could not count usage", error));
2006 }
2007
Usage limits: unpaid usage can only go so far2008 /**
2009 * Pauses the apps of workspaces that reached their limit for usage not
2010 * yet paid for, and rebuilds them from the same commit once they are
2011 * 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 quotas2012 *
2013 * The workspace is the project's now (see `ownerOf`), never the one an
2014 * app's row was written under, so an app left under its old name after
2015 * a transfer is paused only if its new workspace is over its limit. Such
2016 * an app is resumed by being built under its new name (`followMoves`),
2017 * not here.
Usage limits: unpaid usage can only go so far2018 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2019 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far2020 const cloudflare = this.cloudflare;
2021 if (!cloudflare) return;
2022 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2023 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2024 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2025 const limit = await billing.checkLimit(workspace);
2026 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2027 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2028 if (limit.value.state === "stopped") {
2029 for (const app of theirs.filter((a) => !a.paused_at)) {
2030 await cloudflare.pauseScript(app.script);
2031 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2032 }
2033 continue;
2034 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2035 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2036 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2037 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2038 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2039 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2040 const project = projects.get(app.project_id);
2041 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2042 // A failed or refused rebuild leaves it paused, to try again later:
2043 // longer after each failure, and not once its commit is gone or it
2044 // failed the same way a few times.
2045 const history = await this.recentBuilds(app.script);
2046 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2047 const backoff = history[0]?.status === "failed";
2048 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
Usage limits: unpaid usage can only go so far2049 const rebuilt =
2050 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2051 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2052 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2053 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2054 : null;
2055 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2056 }
2057 }
2058 }
2059
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2060 /**
2061 * Scripts in the namespace that no app holds, such as ones renamed. An
2062 * old address that redirects to its app's new one is held until its
2063 * redirect expires, then removed with the rest.
2064 */
Projects: what a workspace builds and runs, first on every page2065 private async removeOrphans(apps: AppRow[]): Promise<void> {
2066 const cloudflare = this.cloudflare;
2067 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2068 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2069 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2070 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2071 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 page2072 const building = await this.db
2073 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2074 .all<{ script: string }>();
2075 for (const row of building.results) held.add(row.script);
2076 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2077 for (const script of await cloudflare.listScripts()) {
2078 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2079 }
2080 }
2081
Deployments: a preview for every pull request, production on g1t.page2082 /** Counts this month's requests and CPU time per workspace, from analytics. */
2083 private async count(apps: AppRow[]): Promise<void> {
2084 const cloudflare = this.cloudflare;
2085 if (!cloudflare || apps.length === 0) return;
2086 const start = `${month()}-01T00:00:00Z`;
2087 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2088 // Analytics only counts apps that are up; the meter keeps what earlier
2089 // apps used by never going down.
2090 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2091 for (const app of apps) {
2092 const used = totals.get(app.script);
2093 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2094 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2095 sum.requests += used.requests;
2096 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2097 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2098 }
2099 const at = now();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2100 // Written only when the count moves the meter: most sweeps it does not.
2101 const stored = new Map(
2102 (
2103 await this.db
2104 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2105 .bind(month())
2106 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2107 ).results.map((row) => [row.namespace, row]),
2108 );
Projects: what a workspace builds and runs, first on every page2109 for (const [workspace, used] of perWorkspace) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2110 if (!movesMeter(stored.get(workspace), used)) continue;
Deployments: a preview for every pull request, production on g1t.page2111 await this.db
2112 .prepare(
2113 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2114 ON CONFLICT (namespace, month) DO UPDATE SET
2115 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2116 )
Projects: what a workspace builds and runs, first on every page2117 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2118 .run();
2119 }
2120 // When each preview last answered anyone, for the idle sweep.
2121 const recent = await cloudflare.usage(
2122 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2123 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2124 at,
2125 );
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2126 // At an hour's resolution: previews idle for days are what it finds.
2127 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2128 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
Deployments: a preview for every pull request, production on g1t.page2129 for (const [script, used] of recent) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2130 const last = lastRequest.get(script);
2131 if (used.requests > 0 && (!last || last < hourAgo)) {
Deployments: a preview for every pull request, production on g1t.page2132 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2133 }
2134 }
Projects: what a workspace builds and runs, first on every page2135 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 quotas2136 // What this month's traffic and custom domains will cost, from the
2137 // first request and the first domain, so the workspace's limit counts
2138 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2139 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2140 const billing = billingClient(this.env.BILLING);
2141 const meters = await this.db
2142 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2143 .bind(month())
2144 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2145 for (const meter of meters.results) {
2146 const cost = monthCost(meter, costs);
2147 await billing
2148 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2149 .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 quotas2150 if ((meter.peak_domains ?? 0) > 0) {
2151 await billing
2152 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2153 .catch((error) => console.error("could not note pending usage", error));
2154 }
Prices keep themselves current with what g1t pays2155 }
Deployments: a preview for every pull request, production on g1t.page2156 }
2157
Projects: what a workspace builds and runs, first on every page2158 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2159 private async takeDownIdle(): Promise<void> {
2160 const idle = await this.db
2161 .prepare(
Projects: what a workspace builds and runs, first on every page2162 `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 member2163 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2164 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2165 )
2166 .all<{ script: string }>();
2167 for (const { script } of idle.results) await this.removeApp(script);
2168 }
2169
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2170 /** 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.page2171 private async chargeMonths(): Promise<void> {
2172 const due = await this.db
2173 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2174 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2175 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2176 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2177 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2178 const cost = monthCost(meter, costs);
2179 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2180 const charged = await billingClient(this.env.BILLING).chargeFeature({
2181 workspace: meter.namespace,
2182 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2183 costMicros: cost.micros,
2184 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2185 reference: `deployments/${meter.namespace}/${meter.month}`,
2186 });
2187 if (!charged.ok) continue;
2188 }
2189 await this.db
2190 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2191 .bind(now(), meter.namespace, meter.month)
2192 .run();
2193 }
2194 }
2195}
2196
2197/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2198async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2199 switch (method) {
2200 case "settings":
2201 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2202 case "is_enabled":
2203 return service.isEnabled(args);
Deployments: a preview for every pull request, production on g1t.page2204 case "update_settings":
2205 return service.updateSettings(args);
2206 case "list":
2207 return service.list(args);
2208 case "get":
2209 return service.get(args);
2210 case "redeploy":
2211 return service.redeploy(args);
2212 case "take_down":
2213 return service.takeDown(args);
Projects: what a workspace builds and runs, first on every page2214 case "overview":
2215 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2216 case "usage":
2217 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2218 case "domains":
2219 return service.listDomains(args);
2220 case "add_domain":
2221 return service.addDomain(args);
2222 case "remove_domain":
2223 return service.removeDomain(args);
2224 case "refresh_domain":
2225 return service.refreshDomain(args);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972226 // A repository's deployments wherever they run (repo-deployments.ts).
2227 case "list_deployments":
2228 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2229 case "get_deployment":
2230 return service.repoDeployments.get(args);
2231 case "list_deployment_statuses":
2232 return service.repoDeployments.statuses(args);
2233 case "list_environments":
2234 return service.repoDeployments.environments(args);
2235 case "get_environment":
2236 return service.repoDeployments.environment(args);
2237 case "create_deployment":
2238 return service.repoDeployments.create(args);
2239 case "create_deployment_status":
2240 return service.repoDeployments.createStatus(args);
2241 // For the actions service: a run's deployment to one environment.
2242 case "actions_deployment":
2243 return service.repoDeployments.fromActions(args);
Deployments: a preview for every pull request, production on g1t.page2244 default:
2245 return undefined;
2246 }
2247}
2248
2249export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2250 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2251 const { pathname } = new URL(request.url);
2252 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2253 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2254 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2255 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2256 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2257 const opened = openD1(env.DB, request);
2258 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2259 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2260 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.page2261 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2262 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2263 // A build's reports, forwarded by the API.
2264 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2265 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2266 return new Response("Not found\n", { status: 404 });
2267 },
2268
2269 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2270 const service = new Deployments(env);
2271 for (const message of batch.messages) {
2272 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2273 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2274 message.ack();
2275 } catch (error) {
2276 console.error("deployments could not handle", message.body.type, error);
2277 message.retry();
2278 }
2279 }
2280 },
2281
2282 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2283 await new Deployments(env).sweep();
2284 },
2285} satisfies ExportedHandler<Env, G1tEvent>;

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