Skip to content
2,391 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,
Mirrors in work, Actions, deployments and the inbox47 mirrorWritable,
Deployments: a preview for every pull request, production on g1t.page48 newId,
49 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents50 openD1,
Projects: what a workspace builds and runs, first on every page51 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52 repoMove,
Deployments: a preview for every pull request, production on g1t.page53 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54 staleMovedPaths,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains55 staleSlugs,
Deployments: a preview for every pull request, production on g1t.page56 workClient,
57 type DeployKind,
58 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look59 type DetectedKind,
Deployments: a preview for every pull request, production on g1t.page60 type DeployStatus,
61 type DeployUsage,
62 type Deployment,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains63 type Domain,
Deployments: a preview for every pull request, production on g1t.page64 type G1tEvent,
65 type LiveApp,
Projects: what a workspace builds and runs, first on every page66 type Project,
67 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look68 type RepoMove,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains69 type ProjectDomains,
Projects: what a workspace builds and runs, first on every page70 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights71 workOwner,
Deployments: a preview for every pull request, production on g1t.page72 type RepoPath,
73 type Result,
74 type ServiceBinding,
75 type User,
76 type Viewer,
77} from "@g1t/contracts";
78
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights79import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Deployments: a preview for every pull request, production on g1t.page80import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains81import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas82import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
Merge main into mirroring: remotes migration becomes integrations 000783import { monthCost, movesMeter } from "./metering";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit84import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
85import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains86import { appHost, appUrl, label, uniqueLabel } from "./names";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9787import { RepoDeployments, sourceOf } from "./repo-deployments";
Deployments: a preview for every pull request, production on g1t.page88
89type Env = {
90 DB: D1Database;
91 REPOS: ServiceBinding;
92 WORK: ServiceBinding;
93 IDENTITY: ServiceBinding;
94 BILLING: ServiceBinding;
95 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page96 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments97 /** Secrets and variables: the actions service holds the one store. */
98 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published99 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
100 EVENTS?: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page101 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
102 CLOUDFLARE_API_TOKEN?: string;
103 CLOUDFLARE_ACCOUNT_ID: string;
104 DISPATCH_NAMESPACE: string;
105 SITE: string;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains106 /** Custom domains: hostname to app, read by the dispatcher. */
107 DOMAINS?: KVNamespace;
108 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
109 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look110 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
111 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Deployments: a preview for every pull request, production on g1t.page112};
113
114/** A build that has not reported in this long has died. */
115const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
Projects: what a workspace builds and runs, first on every page116/** A script in the namespace that no app holds, older than this, is removed. */
117const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page118const LIST_LIMIT = 50;
119const STATUS_CONTEXT = "g1t / deploy";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains120/**
121 * Deliveries of `workspace.renamed` that wait for the projects service to
122 * have seen it too, before going ahead with the new slug regardless.
123 */
124const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas125/** Why a build under way was dropped by a move; the move builds it again under the new name. */
126const MOVED_ERROR = "The repository moved: built again under its new name.";
127const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit128/**
129 * A move's rebuild that could not start, or failed, is tried again by the
130 * sweep after this long, doubled for each failure after the first, up to
131 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
132 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas133const MOVE_RETRY_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page134
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look135/** A build's report of what it found the project to be, if it is one g1t knows. */
136export function detectedKind(value: unknown): DetectedKind | null {
137 return value === "workers" || value === "static" || value === "html" ? value : null;
138}
139
Deployments: a preview for every pull request, production on g1t.page140const now = () => new Date().toISOString();
141const month = (at = new Date()) => at.toISOString().slice(0, 7);
142
143async function sha256(text: string): Promise<string> {
144 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
145 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
146}
147
148function randomToken(): string {
149 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
150}
151
152function isMember(viewer: Viewer, slug: string): boolean {
153 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
154}
155
Projects: what a workspace builds and runs, first on every page156/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look157/** One compute gate per isolate, so entitlements and prices are kept between calls. */
158let computeGate: ComputeGate | null = null;
159function gateFor(billing: ServiceBinding): ComputeGate {
160 computeGate ??= new ComputeGate(billing);
161 return computeGate;
162}
163
164/** The longest a build may run, in minutes: as long as its read token lasts. */
165const BUILD_MINUTES = 30;
166
167/**
168 * A build that was skipped before it started (its plan, its limit, or
169 * billing's refusal) as a failure, so whoever asked for it sees why.
170 */
171function notStarted(result: Result<Deployment>): Result<Deployment> {
172 if (result.ok && result.value.status === "skipped" && result.value.error) {
173 return fail("payment_required", result.value.error);
174 }
175 return result;
176}
177
Projects: what a workspace builds and runs, first on every page178function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
179 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
180 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
181}
182
Deployments: a preview for every pull request, production on g1t.page183type SettingsRow = {
Projects: what a workspace builds and runs, first on every page184 project_id: string;
185 workspace: string;
186 slug: string;
Deployments: a preview for every pull request, production on g1t.page187 repo_id: string;
188 enabled: number;
189 previews: number;
190 production: number;
191 build_command: string | null;
192 output_dir: string | null;
193 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look194 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
195 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member196 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
197 workspace_deleted_at?: string | null;
Deployments: a preview for every pull request, production on g1t.page198};
199
200type DeploymentRow = {
201 id: string;
Projects: what a workspace builds and runs, first on every page202 project_id: string;
203 workspace: string;
204 slug: string;
Deployments: a preview for every pull request, production on g1t.page205 repo_id: string;
Projects: what a workspace builds and runs, first on every page206 repo: string;
Deployments: a preview for every pull request, production on g1t.page207 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page208 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page209 number: number | null;
210 commit_sha: string;
211 script: string;
212 status: DeployStatus;
213 error: string | null;
214 warnings: string;
215 log: string | null;
216 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments217 trusted: number;
Deployments: a preview for every pull request, production on g1t.page218 build_seconds: number | null;
219 created_by: string;
220 created_at: string;
221 finished_at: string | null;
222};
223
224type AppRow = {
225 script: string;
Projects: what a workspace builds and runs, first on every page226 project_id: string;
227 workspace: string;
228 slug: string;
Deployments: a preview for every pull request, production on g1t.page229 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page230 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page231 number: number | null;
232 commit_sha: string;
233 deployed_at: string;
234 created_at: string;
235 last_request_at: string | null;
Usage limits: unpaid usage can only go so far236 /** Set while its workspace is over its limit; see `holdToLimits`. */
237 paused_at: string | null;
Deployments: a preview for every pull request, production on g1t.page238};
239
240function toDeployment(row: DeploymentRow): Deployment {
241 return {
242 id: row.id,
243 kind: row.kind,
Projects: what a workspace builds and runs, first on every page244 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page245 number: row.number,
246 commit: row.commit_sha,
247 status: row.status,
248 url: appUrl(row.script),
249 error: row.error,
250 warnings: JSON.parse(row.warnings || "[]") as string[],
251 buildSeconds: row.build_seconds,
252 createdBy: row.created_by,
253 createdAt: row.created_at,
254 finishedAt: row.finished_at,
255 };
256}
257
Projects: what a workspace builds and runs, first on every page258function toLive(app: AppRow): LiveApp {
259 return {
260 kind: app.kind,
261 branch: app.branch,
262 number: app.number,
263 url: appUrl(app.script),
264 commit: app.commit_sha,
265 deployedAt: app.deployed_at,
266 };
267}
268
Deployments: a preview for every pull request, production on g1t.page269class Deployments {
270 constructor(private readonly env: Env) {}
271
272 private get cloudflare(): Cloudflare | null {
273 const token = this.env.CLOUDFLARE_API_TOKEN;
274 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
275 }
276
277 private get db() {
278 return this.env.DB;
279 }
280
Agents and memory, checks and conflicts, profiles, slug renames, custom domains281 private get domains(): Domains {
282 const token = this.env.CLOUDFLARE_API_TOKEN;
283 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
284 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
285 }
286
Projects: what a workspace builds and runs, first on every page287 private get projects() {
288 return projectsClient(this.env.PROJECTS);
289 }
290
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97291 /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */
292 get repoDeployments(): RepoDeployments {
293 return new RepoDeployments(this.env);
294 }
295
296 /**
297 * Publishes a build's change in the repository-wide model
298 * (`deployment.created`, `deployment_status.created`), as a reported
299 * deployment's are. Never fails the build.
300 */
301 private async buildChanged(id: string, created = false): Promise<void> {
302 try {
303 const row = await this.deploymentRow(id);
304 if (!row) return;
305 const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id);
306 const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main";
307 await this.repoDeployments.buildChanged(row, defaultBranch, created);
308 } catch (error) {
309 console.error("build change not published", id, String(error));
310 }
311 }
312
Deployments: a preview for every pull request, production on g1t.page313 /** The workspace itself, as the service acts for it. */
314 private async workspaceActor(slug: string): Promise<User | null> {
315 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
316 if (!workspace) return null;
317 return {
318 id: workspace.id,
319 username: workspace.slug,
320 kind: "workspace",
321 verified: true,
322 workspaces: [{ slug: workspace.slug, role: "member" }],
323 };
324 }
325
Secrets and variables: one list, rows per environment, for workflows and deployments326 /**
Projects: what a workspace builds and runs, first on every page327 * What the project's secrets and variables available to deployments give
328 * production or a preview: its build's environment, and the same again as
329 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments330 */
331 private async resolve(
Projects: what a workspace builds and runs, first on every page332 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments333 environment: DeployKind,
334 trusted: boolean,
Project dependencies: addresses, preview stacks, Affects, and agents who know335 branch: string | null,
Secrets and variables: one list, rows per environment, for workflows and deployments336 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Project dependencies: addresses, preview stacks, Affects, and agents who know337 const [rows, references] = await Promise.all([
338 this.rows(project, environment, trusted),
339 this.references(project.id, environment === "preview" ? branch : null),
340 ]);
341 // The project's own rows win over a dependency's address of the same name.
342 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
343 }
344
345 /**
346 * Each dependency's address, under the name the dependency gives it:
347 * for a preview, the same branch's preview of it if one is up, else its
348 * production; for production, its production.
349 */
350 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
351 const graph = await this.projects.graph(projectId).catch(() => null);
352 const out: Record<string, string> = {};
353 for (const dependency of graph?.dependsOn ?? []) {
354 if (!dependency.as) continue;
355 const app =
356 (branch
357 ? await this.db
358 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
359 .bind(dependency.id, branch)
360 .first<{ script: string }>()
361 : null) ??
362 (await this.db
363 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
364 .bind(dependency.id)
365 .first<{ script: string }>());
366 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
367 }
368 return out;
369 }
370
371 private async rows(
372 project: { id: string; slug: string; repoId: string; repo: RepoPath },
373 environment: DeployKind,
374 trusted: boolean,
375 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
Secrets and variables: one list, rows per environment, for workflows and deployments376 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
377 method: "POST",
378 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page379 body: JSON.stringify({
380 repoId: project.repoId,
381 repo: project.repo,
382 projectId: project.id,
383 projectSlug: project.slug,
384 consumer: "deployments",
385 environment,
386 trusted,
387 }),
Secrets and variables: one list, rows per environment, for workflows and deployments388 });
389 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
390 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
391 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
392 }
393
394 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights395 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
396 * it, or its author) is trusted with the project's secrets: g1t itself,
397 * or someone who can push to the repository (Write or more, a member's or
398 * a collaborator's). Anyone else gets a preview built without them, as
399 * their workflows run. A change g1t made for someone is trusted as they
400 * are, never more for being g1t's.
Secrets and variables: one list, rows per environment, for workflows and deployments401 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights402 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
403 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look404 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 rights405 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 .catch(() => null);
407 return !!found?.ok && allows(found.value.role, "push");
Secrets and variables: one list, rows per environment, for workflows and deployments408 }
409
Projects: what a workspace builds and runs, first on every page410 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
411 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page412 }
413
Projects: what a workspace builds and runs, first on every page414 /** The name an app gets, unique among apps: production, or a branch's preview. */
415 private async scriptFor(project: Project, branch: string | null): Promise<string> {
416 const base = await label(project.workspace, project.slug, branch);
417 const holder = await this.db
418 .prepare(
419 `SELECT project_id, branch FROM apps WHERE script = ?1
420 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
421 )
422 .bind(base)
423 .first<{ project_id: string; branch: string | null }>();
424 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
425 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
426 }
427
428 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page429 return {
430 enabled: !!row?.enabled,
431 previews: row ? !!row.previews : true,
432 production: row ? !!row.production : true,
433 buildCommand: row?.build_command ?? null,
434 outputDir: row?.output_dir ?? null,
435 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page436 productionUrl: appUrl(await this.scriptFor(project, null)),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains437 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look438 detected: await this.lastDetected(project.id),
Deployments: a preview for every pull request, production on g1t.page439 };
440 }
441
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look442 /** What the project's last finished build found it to be; null before one has. */
443 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
444 const row = await this.db
445 .prepare(
446 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
447 )
448 .bind(projectId)
449 .first<{ detected: string }>()
450 .catch(() => null);
451 return detectedKind(row?.detected);
452 }
453
454 /**
455 * The project, if `viewer` may do what `method` needs on its repository
456 * (see `NEEDS`): not found when they cannot read it, refused when they can
457 * but their role is too low.
458 */
459 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
460 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
461 if (!found.ok) return found;
462 const repo = repoRef(found.value);
463 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
464 const capability = NEEDS[method];
465 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
466 return found;
Deployments: a preview for every pull request, production on g1t.page467 }
468
469 // ---- Methods for the site and the API ------------------------------
470
Projects: what a workspace builds and runs, first on every page471 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 look472 const project = await this.projectFor(a.project, a.viewer, "settings");
Projects: what a workspace builds and runs, first on every page473 if (!project.ok) return project;
474 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page475 }
476
477 async updateSettings(a: {
478 actor: User;
Projects: what a workspace builds and runs, first on every page479 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page480 changes: Partial<DeploySettings>;
481 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look482 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Projects: what a workspace builds and runs, first on every page483 if (!found.ok) return found;
484 const project = found.value;
485 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page486 const next = { ...before, ...a.changes };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97487 // A library, a tool or other, as set in its settings, does not deploy.
488 if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") {
489 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 production490 }
Deployments: a preview for every pull request, production on g1t.page491 if (next.enabled && !before.enabled) {
492 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page493 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page494 if (!plan.ok) return plan;
495 }
496 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
497 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
498 await this.db
499 .prepare(
Projects: what a workspace builds and runs, first on every page500 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
501 output_dir, idle_days, updated_by, updated_at)
502 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
503 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
504 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.page505 )
506 .bind(
Projects: what a workspace builds and runs, first on every page507 project.id,
508 project.workspace,
509 project.slug,
510 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page511 next.enabled ? 1 : 0,
512 next.previews ? 1 : 0,
513 next.production ? 1 : 0,
514 clip(next.buildCommand),
515 clip(next.outputDir),
516 idleDays,
517 a.actor.username,
518 now(),
519 )
520 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production521 // Projects keeps whether it deploys, so a project with Deployments on is an app.
522 if (next.enabled !== before.enabled) {
523 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
524 }
Deployments: a preview for every pull request, production on g1t.page525 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page526 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page527 else {
Projects: what a workspace builds and runs, first on every page528 if (!next.previews) await this.takeDownWhere(project.id, "preview");
529 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page530 }
531 // Turned on: production goes up from the default branch at once.
532 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page533 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page534 }
Projects: what a workspace builds and runs, first on every page535 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page536 }
537
A project is an app or a library: libraries show their package and how to ship a release, not production538 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
539 async isEnabled(a: { projectId: string }): Promise<boolean> {
540 return !!(await this.settingsRow(a.projectId))?.enabled;
541 }
542
Projects: what a workspace builds and runs, first on every page543 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 look544 const project = await this.projectFor(a.project, a.viewer, "list");
Projects: what a workspace builds and runs, first on every page545 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page546 const [deployments, apps] = await Promise.all([
547 this.db
Projects: what a workspace builds and runs, first on every page548 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
549 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page550 .all<DeploymentRow>(),
551 this.db
Projects: what a workspace builds and runs, first on every page552 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
553 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page554 .all<AppRow>(),
555 ]);
Projects: what a workspace builds and runs, first on every page556 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page557 }
558
Projects: what a workspace builds and runs, first on every page559 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 look560 const project = await this.projectFor(a.project, a.viewer, "get");
Projects: what a workspace builds and runs, first on every page561 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page562 const row = await this.db
Projects: what a workspace builds and runs, first on every page563 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
564 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page565 .first<DeploymentRow>();
566 if (!row) return fail("not_found", "No such deployment.");
567 return ok({ ...toDeployment(row), log: row.log });
568 }
569
Projects: what a workspace builds and runs, first on every page570 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 look571 const found = await this.projectFor(a.project, a.actor, "redeploy");
Projects: what a workspace builds and runs, first on every page572 if (!found.ok) return found;
573 const project = found.value;
574 const settings = await this.settingsRow(project.id);
575 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
576 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look577 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 page578 }
579 // A branch's preview comes from its pull request.
580 const app = await this.db
581 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
582 .bind(project.id, a.branch)
583 .first<{ number: number }>();
584 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 look585 return notStarted((await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open."));
Deployments: a preview for every pull request, production on g1t.page586 }
587
Project dependencies: addresses, preview stacks, Affects, and agents who know588 /**
589 * A preview stack: the projects that use this one get previews of their
590 * own default branch, under the same branch name, so each reaches this
591 * branch's preview through its dependency's variable. A change to an API
592 * can then be clicked through in the apps that call it.
593 */
594 async stack(
595 a: { actor: User; project: ProjectRef; branch: string },
596 background: (work: Promise<unknown>) => void,
597 ): Promise<Result<string[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look598 const found = await this.projectFor(a.project, a.actor, "stack");
Project dependencies: addresses, preview stacks, Affects, and agents who know599 if (!found.ok) return found;
600 const upstream = await this.db
601 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
602 .bind(found.value.id, a.branch)
603 .first();
604 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
605 const graph = await this.projects.graph(found.value.id);
606 const ready: { project: Project; settings: SettingsRow }[] = [];
607 for (const dependent of graph.usedBy) {
608 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look609 // Each build spends compute on its own repository: only those the actor can run.
610 if (!project.ok || !can(a.actor, repoRef(project.value), NEEDS.stack)) continue;
Project dependencies: addresses, preview stacks, Affects, and agents who know611 const settings = await this.settingsRow(project.value.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look612 if (settings?.enabled && settings.previews && !settings.repo_deleted_at) ready.push({ project: project.value, settings });
Project dependencies: addresses, preview stacks, Affects, and agents who know613 }
614 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
615 // The builds start after the answer: a person moving on from the page
616 // does not stop them.
617 background(
618 (async () => {
619 for (const { project, settings } of ready) {
620 const actor = await this.workspaceActor(project.workspace);
621 if (!actor) continue;
622 const repo = repoOf(project);
623 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
624 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
625 if (!head) continue;
626 await this.start({
627 project,
628 kind: "preview",
629 branch: a.branch,
630 number: null,
631 commit: head,
632 source: repo.path,
633 reader: actor,
634 createdBy: a.actor.username,
635 settings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look636 // Its own default branch, asked for by someone who can run it.
Project dependencies: addresses, preview stacks, Affects, and agents who know637 trusted: true,
638 });
639 }
640 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
641 );
642 return ok(ready.map(({ project }) => project.name));
643 }
644
Projects: what a workspace builds and runs, first on every page645 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 look646 const project = await this.projectFor(a.project, a.actor, "takeDown");
Projects: what a workspace builds and runs, first on every page647 if (!project.ok) return project;
648 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page649 return ok(true);
650 }
651
Projects: what a workspace builds and runs, first on every page652 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
653 const workspace = a.workspace.toLowerCase();
654 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 look655 // Only the projects whose repositories the viewer can read: the
656 // projects service lists no others.
657 const listed = await this.projects.list(workspace, a.viewer);
658 if (!listed.ok) return listed;
659 const readable = new Set(listed.value.map((project) => project.slug));
Projects: what a workspace builds and runs, first on every page660 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look661 // A deleted repository's projects are hidden until it is restored.
662 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 page663 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
664 this.db
665 .prepare(
666 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
667 )
668 .bind(workspace)
669 .all<DeploymentRow>(),
670 ]);
671 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look672 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Projects: what a workspace builds and runs, first on every page673 const own = apps.results.filter((app) => app.slug === row.slug);
674 const production = own.find((app) => app.kind === "production");
675 const newest = latest.results.find((d) => d.slug === row.slug);
676 return {
677 slug: row.slug,
678 enabled: !!row.enabled,
679 production: production ? toLive(production) : null,
680 previews: own.filter((app) => app.kind === "preview").length,
681 latest: newest ? toDeployment(newest) : null,
682 };
683 }),
684 );
685 }
686
Deployments: a preview for every pull request, production on g1t.page687 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
688 const slug = a.workspace.toLowerCase();
689 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
690 const [meter, apps] = await Promise.all([
691 this.db
692 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
693 .bind(slug, month())
694 .first<{
695 requests: number;
696 cpu_ms: number;
697 peak_apps: number;
698 build_seconds: number;
699 build_micros: number;
700 counted_at: string | null;
701 }>(),
Projects: what a workspace builds and runs, first on every page702 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.page703 ]);
704 return ok({
705 month: month(),
706 requests: meter?.requests ?? 0,
707 cpuMs: meter?.cpu_ms ?? 0,
708 apps: apps?.n ?? 0,
709 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
710 buildSeconds: meter?.build_seconds ?? 0,
711 buildMicros: meter?.build_micros ?? 0,
712 countedAt: meter?.counted_at ?? null,
713 });
714 }
715
Agents and memory, checks and conflicts, profiles, slug renames, custom domains716 // ---- Custom domains ------------------------------------------------
717
718 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 look719 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains720 if (!project.ok) return project;
721 const domains = this.domains;
722 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 quotas723 const [rows, available, used, costs] = await Promise.all([
Agents and memory, checks and conflicts, profiles, slug renames, custom domains724 domains.forProject(project.value.id),
725 domains.available(),
726 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas727 this.costs(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains728 ]);
729 return ok({
730 domains: rows.map(toDomain),
731 target: CUSTOM_DOMAIN_TARGET,
732 available,
733 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas734 monthlyMicros: costs.domainMonthPrice,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains735 used,
736 });
737 }
738
739 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 look740 const found = await this.projectFor(a.project, a.actor, "addDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains741 if (!found.ok) return found;
742 const project = found.value;
743 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
744 if (!plan.ok) return plan;
745 const added = await this.domains.add({
746 project: { id: project.id, workspace: project.workspace, slug: project.slug },
747 script: await this.productionScript(project),
748 hostname: String(a.hostname ?? ""),
749 twin: !!a.twin,
750 by: a.actor.username,
751 });
752 if (!added.ok) return fail(added.code, added.message);
753 await this.notePeak(project.workspace);
754 return ok(added.rows.map(toDomain));
755 }
756
757 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 look758 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains759 if (!found.ok) return found;
760 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
761 if (!row) return fail("not_found", "No such domain.");
762 await this.domains.remove(row);
763 return ok(true);
764 }
765
766 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 look767 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains768 if (!found.ok) return found;
769 const domains = this.domains;
770 const row = await domains.byId(found.value.id, String(a.id ?? ""));
771 if (!row) return fail("not_found", "No such domain.");
772 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
773 await this.notePeak(found.value.workspace);
774 return ok(toDomain(after));
775 }
776
777 /** The script production is up under, or the name it will have. */
778 private async productionScript(project: Project): Promise<string> {
779 const app = await this.db
780 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
781 .bind(project.id)
782 .first<{ script: string }>();
783 return app?.script ?? (await this.scriptFor(project, null));
784 }
785
Deployments: a preview for every pull request, production on g1t.page786 // ---- Starting builds -----------------------------------------------
787
788 /**
789 * Opens a deployment and starts its build. Skipped, with the reason
790 * recorded, when the workspace's plan is off.
791 */
792 private async start(input: {
Projects: what a workspace builds and runs, first on every page793 project: Project;
Deployments: a preview for every pull request, production on g1t.page794 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page795 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page796 number: number | null;
797 commit: string;
798 source: RepoPath;
799 reader: User;
800 createdBy: string;
801 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page802 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments803 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page804 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page805 const { project } = input;
806 const repo = repoOf(project);
807 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page808 const id = newId("dpl");
809 const token = randomToken();
Projects: what a workspace builds and runs, first on every page810 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Usage limits: unpaid usage can only go so far811 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Deployments: a preview for every pull request, production on g1t.page812 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look813 let refused = !plan.ok
Deployments: a preview for every pull request, production on g1t.page814 ? plan.error.message
Usage limits: unpaid usage can only go so far815 : limit.ok && limit.value.state === "stopped"
816 ? (limit.value.message ?? "The workspace reached its usage limit.")
Deployments: a preview for every pull request, production on g1t.page817 : !cloudflare
818 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
819 : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look820 // A build is compute: reserved with billing before it starts, and
821 // settled by its sandbox when it stops. A refusal is the deployment's
822 // status, saying what to do.
823 const compute = gateFor(this.env.BILLING);
824 let reservation: string | null = null;
825 let microsPerSecond = 0;
826 let maxRunMinutes: number | null = null;
827 if (!refused) {
828 const ent = await compute.entitlements(project.workspace);
829 microsPerSecond = await compute.microsPerSecond();
830 maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
831 const isPrivate = await reposClient(this.env.REPOS)
832 .get(repo.path, null)
833 .then((found) => !found.ok || found.value.isPrivate)
834 .catch(() => true);
835 const admitted = await compute.admit(
836 {
837 workspace: project.workspace,
838 repo: repo.path,
839 public: !isPrivate,
840 kind: "deploy",
841 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond),
842 },
843 ent,
844 );
845 if (admitted.ok) reservation = admitted.reservation?.id ?? null;
846 else refused = admitted.message;
847 }
Deployments: a preview for every pull request, production on g1t.page848 await this.db
849 .prepare(
Projects: what a workspace builds and runs, first on every page850 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
851 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
852 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page853 )
854 .bind(
855 id,
Projects: what a workspace builds and runs, first on every page856 project.id,
857 project.workspace,
858 project.slug,
859 repo.id,
860 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page861 input.kind,
Projects: what a workspace builds and runs, first on every page862 input.branch,
Deployments: a preview for every pull request, production on g1t.page863 input.number,
864 input.commit,
865 script,
866 refused ? "skipped" : "queued",
867 refused,
868 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments869 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page870 input.createdBy,
871 now(),
872 refused ? now() : null,
873 )
874 .run();
875 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
876 // Older builds of the same app are replaced by this one.
877 await this.db
878 .prepare(
879 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
880 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
881 )
882 .bind(now(), script, id)
883 .run();
Projects: what a workspace builds and runs, first on every page884 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-a69aeabc4b0deeb97885 await this.buildChanged(id, true);
Projects: what a workspace builds and runs, first on every page886 // What the project's secrets and variables give builds of this kind.
887 const build = await this.resolve(
888 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
889 input.kind,
890 input.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know891 input.branch,
Projects: what a workspace builds and runs, first on every page892 );
Deployments: a preview for every pull request, production on g1t.page893 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
894 method: "POST",
895 headers: { "content-type": "application/json" },
896 body: JSON.stringify({
897 deployId: id,
898 token,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look899 workspace: project.workspace,
900 reservation,
901 microsPerSecond,
902 maxRunMinutes,
Deployments: a preview for every pull request, production on g1t.page903 actor: input.reader,
904 source: input.source,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas905 // The project, whose guardrails the build runs under: a preview's
906 // source is its pull request's working copy, not the project.
907 repo: repo.path,
908 repoId: repo.id,
Deployments: a preview for every pull request, production on g1t.page909 commit: input.commit,
Projects: what a workspace builds and runs, first on every page910 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page911 buildCommand: input.settings.build_command,
912 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments913 buildEnv: build.variables,
914 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page915 }),
916 });
917 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 look918 if (!started.ok) {
919 // Never reached a sandbox: what was reserved is given back.
920 if (reservation) await compute.settle(reservation, 0);
921 await this.finishFailed(id, started.error.message, null, null);
922 }
Deployments: a preview for every pull request, production on g1t.page923 return ok(toDeployment((await this.deploymentRow(id))!));
924 }
925
Projects: what a workspace builds and runs, first on every page926 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
927 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 member928 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 page929 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page930 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page931 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page932 let head = commit;
933 if (!head) {
Projects: what a workspace builds and runs, first on every page934 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
935 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page936 }
937 if (!head) return null;
938 return this.start({
Projects: what a workspace builds and runs, first on every page939 project,
Deployments: a preview for every pull request, production on g1t.page940 kind: "production",
Projects: what a workspace builds and runs, first on every page941 branch: null,
Deployments: a preview for every pull request, production on g1t.page942 number: null,
943 commit: head,
Projects: what a workspace builds and runs, first on every page944 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page945 reader: actor,
946 createdBy,
947 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments948 // The default branch only moves by people and agents with access.
949 trusted: true,
Deployments: a preview for every pull request, production on g1t.page950 });
951 }
952
Projects: what a workspace builds and runs, first on every page953 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
954 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 member955 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 page956 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page957 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page958 const repo = repoOf(project);
959 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page960 if (!detail.ok) return null;
961 const { pull } = detail.value;
962 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page963 // A pull request from a fork (as g1t's agents work) has no branch here.
964 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page965 if (!force) {
966 // Already built, or being built, at this commit.
967 const same = await this.db
968 .prepare(
Projects: what a workspace builds and runs, first on every page969 `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.page970 AND status IN ('queued', 'building', 'ready')`,
971 )
Projects: what a workspace builds and runs, first on every page972 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page973 .first();
974 if (same) return null;
975 }
976 return this.start({
Projects: what a workspace builds and runs, first on every page977 project,
Deployments: a preview for every pull request, production on g1t.page978 kind: "preview",
Projects: what a workspace builds and runs, first on every page979 branch,
Deployments: a preview for every pull request, production on g1t.page980 number,
981 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page982 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 rights983 // The pull request's fork may be private: read it as whoever it is
984 // for (whoever asked g1t for it, or its author), who is also who is
985 // trusted or not with the project's secrets.
986 reader: workOwner(pull),
Deployments: a preview for every pull request, production on g1t.page987 createdBy,
988 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights989 trusted: await this.insider(repo.path, workOwner(pull), actor),
Deployments: a preview for every pull request, production on g1t.page990 });
991 }
992
993 // ---- A build's reports ---------------------------------------------
994
995 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
996 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
997 }
998
999 /** The build, if `token` is its own and it is still under way. */
1000 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
1001 const row = await this.deploymentRow(id);
1002 if (!row?.token_hash || typeof token !== "string") return null;
1003 if (row.token_hash !== (await sha256(token))) return null;
1004 return row.status === "queued" || row.status === "building" ? row : null;
1005 }
1006
1007 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run1008 // Each report, for the logs: a build's own failure says why.
1009 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page1010 const row = await this.building(id, body.token);
1011 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
1012 const cloudflare = this.cloudflare;
1013 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
1014 switch (step) {
1015 case "started":
1016 await this.db
1017 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
1018 .bind(now(), id)
1019 .run();
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971020 await this.buildChanged(id);
Deployments: a preview for every pull request, production on g1t.page1021 return Response.json(ok(true));
1022 case "session": {
1023 const manifest = body.manifest as Manifest | undefined;
1024 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
1025 const session = await cloudflare.openUpload(row.script, manifest);
1026 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
1027 }
1028 case "finish": {
1029 const worker = (body.worker ?? {}) as BuiltWorker;
1030 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page1031 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page1032 try {
Secrets and variables: one list, rows per environment, for workflows and deployments1033 // Running apps' secrets and variables are bound here, by g1t:
1034 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page1035 const runtime = await this.resolve(
1036 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1037 row.kind,
1038 !!row.trusted,
Project dependencies: addresses, preview stacks, Affects, and agents who know1039 row.branch,
Projects: what a workspace builds and runs, first on every page1040 );
Deployments: a preview for every pull request, production on g1t.page1041 await cloudflare.putScript(
1042 row.script,
1043 worker,
1044 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page1045 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments1046 runtime,
Deployments: a preview for every pull request, production on g1t.page1047 );
1048 } catch (error) {
1049 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1050 return Response.json(ok(false));
1051 }
1052 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1053 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.page1054 await this.db.batch([
1055 this.db
1056 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1057 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Deployments: a preview for every pull request, production on g1t.page1058 WHERE id = ?`,
1059 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1060 .bind(
1061 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1062 String(body.log ?? ""),
1063 seconds,
1064 at,
1065 detectedKind(body.detected),
1066 id,
1067 ),
Builds say Replaced or Down once they are no longer live1068 // The build it replaces is no longer what the app serves.
Deployments: a preview for every pull request, production on g1t.page1069 this.db
Builds say Replaced or Down once they are no longer live1070 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1071 .bind(row.script, id),
1072 this.db
Deployments: a preview for every pull request, production on g1t.page1073 .prepare(
Projects: what a workspace builds and runs, first on every page1074 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1075 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
Usage limits: unpaid usage can only go so far1076 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.page1077 )
Projects: what a workspace builds and runs, first on every page1078 .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 quotas1079 // A name that redirected elsewhere (a move undone) is an app again.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1080 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
Deployments: a preview for every pull request, production on g1t.page1081 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1082 if (wasRedirect) {
1083 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1084 }
1085 // The same app at an older name (its project moved) now redirects
1086 // here; it stays up as it was if the redirect cannot be put.
1087 await this.supersede(cloudflare, row, row.script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1088 // The project's own domains serve production wherever it is up.
1089 if (row.kind === "production") {
1090 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1091 }
Deployments: a preview for every pull request, production on g1t.page1092 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1093 await this.notePeak(row.workspace);
1094 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 published1095 await this.announce(row, null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971096 await this.buildChanged(id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1097 // A screenshot of production as it now is, for the project's overview.
1098 if (row.kind === "production") {
1099 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1100 console.error("could not ask for a screenshot", error),
1101 );
1102 }
Deployments: a preview for every pull request, production on g1t.page1103 return Response.json(ok(true));
1104 }
1105 case "fail":
1106 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1107 return Response.json(ok(true));
1108 default:
1109 return Response.json(fail("not_found", "No such step."), { status: 404 });
1110 }
1111 }
1112
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1113 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1114 * Older names of the app `script`, now up: one project's production, or
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1115 * 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 quotas1116 * same is the app under a name it had before its project moved (its
1117 * workspace renamed, its repository renamed or transferred). Each is
1118 * replaced in the namespace by a redirect to the new name, its app row
1119 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1120 * the sweep to hold the old script) and in `DOMAINS` under the old
1121 * hostname, which the dispatcher follows before running anything, so the
1122 * old address redirects even if the old script is paused. A name that
1123 * cannot be redirected is left as it is, for the next deploy or sweep.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1124 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1125 private async supersede(
1126 cloudflare: Cloudflare,
1127 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1128 script: string,
1129 ): Promise<string[]> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1130 const older = await this.db
1131 .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 quotas1132 .bind(app.project_id, app.kind, app.branch, script)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1133 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1134 const target = appHost(script);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1135 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1136 for (const { script: old } of older.results) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1137 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1138 await cloudflare.redirectScript(old, target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1139 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1140 console.error("could not redirect", old, "to", script, error);
1141 continue;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1142 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1143 const at = now();
1144 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1145 await this.db.batch([
1146 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1147 this.db
1148 .prepare(
1149 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1150 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1151 )
1152 .bind(old, target, app.workspace, at, expires),
1153 // Its builds are no longer live under the old name.
1154 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1155 ]);
1156 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1157 expiration: Math.floor(Date.parse(expires) / 1000),
1158 }).catch((error) => console.error("could not record redirect", old, error));
1159 done.push(old);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1160 }
1161 return done;
1162 }
1163
Deployments: a preview for every pull request, production on g1t.page1164 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1165 const row = await this.deploymentRow(id);
1166 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1167 // A commit that is gone says so plainly; git's own words stay in the log.
1168 if (commitMissing(message)) {
1169 log = log ?? message;
1170 message = missingCommitMessage(row);
1171 }
Deployments: a preview for every pull request, production on g1t.page1172 await this.db
1173 .prepare(
1174 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1175 WHERE id = ?`,
1176 )
1177 .bind(message.slice(0, 2000), log, seconds, now(), id)
1178 .run();
1179 // A failed build still used its sandbox.
1180 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page1181 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1182 await this.announce(row, message.slice(0, 300));
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971183 await this.buildChanged(id);
Events: review requests, assignments, stops and deployments are published1184 }
1185
1186 /**
1187 * Publishes a finished build: `deployment.failed` with what went wrong,
1188 * or `deployment.succeeded`, saying whether the build of the same app
1189 * before it failed. Whoever started it is the actor when it was a
1190 * person known by id; otherwise `triggeredBy` names them, or g1t.
1191 */
1192 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1193 if (!this.env.EVENTS) return;
1194 const previous = error
1195 ? null
1196 : await this.db
1197 .prepare(
1198 `SELECT status FROM deployments
1199 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1200 ORDER BY created_at DESC LIMIT 1`,
1201 )
1202 .bind(row.script, row.id, row.created_at)
1203 .first<{ status: string }>();
1204 const byId = row.created_by.startsWith("usr_");
1205 const event = {
1206 source: "deployments",
1207 repoId: row.repo_id,
1208 actor: byId ? row.created_by : null,
1209 data: {
1210 deploymentId: row.id,
1211 projectId: row.project_id,
1212 repoId: row.repo_id,
1213 workspace: row.workspace,
1214 project: row.slug,
1215 kind: row.kind,
1216 branch: row.branch,
1217 number: row.kind === "preview" ? row.number : null,
1218 commit: row.commit_sha,
1219 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1220 error,
1221 recovered: previous?.status === "failed",
1222 triggeredBy: byId ? "" : row.created_by,
1223 },
1224 };
1225 await eventsClient(this.env.EVENTS)
1226 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1227 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Deployments: a preview for every pull request, production on g1t.page1228 }
1229
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1230 /** 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.page1231 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Prices keep themselves current with what g1t pays1232 const costs = await this.costs();
1233 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
Deployments: a preview for every pull request, production on g1t.page1234 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page1235 const what =
1236 row.kind === "preview"
1237 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1238 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page1239 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page1240 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page1241 feature: "deployments",
1242 costMicros: cost,
1243 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page1244 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page1245 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1246 // Every second is metered; billing prices it from its price book and
1247 // 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 put1248 buildSeconds: Math.ceil(seconds),
Deployments: a preview for every pull request, production on g1t.page1249 });
1250 await this.db
1251 .prepare(
1252 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1253 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1254 )
Projects: what a workspace builds and runs, first on every page1255 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page1256 .run();
1257 }
1258
Prices keep themselves current with what g1t pays1259 /**
1260 * What each unit costs g1t now, from billing's price book, which follows
1261 * what Cloudflare bills. The plan's figures if billing cannot say.
1262 */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1263 private async costs(): Promise<{
1264 buildSecond: number;
1265 millionRequests: number;
1266 millionCpuMs: number;
1267 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1268 /** What one custom domain is charged a month: the cost plus the margin. */
1269 domainMonthPrice: number;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1270 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1271 const a = DEPLOYMENT_COSTS;
Prices keep themselves current with what g1t pays1272 const book = await billingClient(this.env.BILLING)
1273 .prices()
1274 .catch(() => null);
1275 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1276 return {
1277 buildSecond: cost("build_second", a.microsPerBuildSecond),
1278 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1279 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1280 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 quotas1281 domainMonthPrice:
1282 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Prices keep themselves current with what g1t pays1283 };
1284 }
1285
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1286 /** 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 page1287 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1288 await this.db
1289 .prepare(
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1290 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1291 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1292 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
Deployments: a preview for every pull request, production on g1t.page1293 ON CONFLICT (namespace, month) DO UPDATE SET
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1294 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1295 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.page1296 )
Projects: what a workspace builds and runs, first on every page1297 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page1298 .run();
1299 }
1300
Projects: what a workspace builds and runs, first on every page1301 /**
1302 * The check on the commit: `g1t / deploy`, or, for one of several
1303 * projects on a repository, `g1t / deploy (<project>)`.
1304 */
1305 private async status(
1306 repoId: string,
1307 sha: string,
1308 project: { slug: string; primary: boolean },
1309 state: string,
1310 description: string,
1311 targetUrl: string,
1312 ): Promise<void> {
1313 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page1314 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1315 method: "POST",
1316 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1317 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
Deployments: a preview for every pull request, production on g1t.page1318 }).catch(() => undefined);
1319 }
1320
Projects: what a workspace builds and runs, first on every page1321 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1322 const projects = await this.projects.byRepo(row.repo_id);
1323 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1324 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1325 }
1326
Deployments: a preview for every pull request, production on g1t.page1327 // ---- Taking apps down ----------------------------------------------
1328
1329 private async removeApp(script: string): Promise<void> {
1330 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1331 await this.db.batch([
1332 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1333 // Its build is no longer live anywhere.
1334 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1335 ]);
Deployments: a preview for every pull request, production on g1t.page1336 }
1337
Projects: what a workspace builds and runs, first on every page1338 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1339 const apps = await this.db
1340 .prepare(
Projects: what a workspace builds and runs, first on every page1341 `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.page1342 )
Projects: what a workspace builds and runs, first on every page1343 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page1344 .all<{ script: string }>();
1345 for (const app of apps.results) await this.removeApp(app.script);
1346 }
1347
1348 // ---- Events --------------------------------------------------------
1349
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1350 /** `attempts`: which delivery of the event this is, from 1. */
1351 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page1352 switch (event.type) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1353 case "workspace.renamed":
1354 await this.renamed(event.data, attempts);
1355 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1356 case "repo.transferred":
1357 case "repo.renamed":
1358 await this.moved(repoMove(event)!, attempts);
1359 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1360 // A repository that went or came back with its workspace is the
1361 // 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 look1362 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1363 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 look1364 break;
1365 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1366 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 look1367 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1368 case "workspace.deleting":
1369 await this.workspaceDeleting(event.data.slug);
1370 break;
1371 case "workspace.restored":
1372 await this.workspaceRestored(event.data.slug);
1373 break;
1374 case "workspace.deleted":
1375 await this.workspacePurged(event.data.slug);
1376 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1377 case "repo.purged":
1378 await this.repoPurged(event.data.repoId);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971379 await this.repoDeployments.purge(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1380 break;
1381 case "repo.default_branch_changed":
1382 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1383 break;
1384 case "branch.renamed":
1385 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1386 break;
1387
Deployments: a preview for every pull request, production on g1t.page1388 case "pull.opened":
1389 case "pull.ready":
Projects: what a workspace builds and runs, first on every page1390 case "pull.updated":
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1391 case "pull.reopened":
Projects: what a workspace builds and runs, first on every page1392 for (const project of await this.projects.byRepo(event.data.repoId)) {
1393 await this.deployPreview(project, event.data.number, "g1t");
1394 }
Deployments: a preview for every pull request, production on g1t.page1395 break;
1396 case "pull.closed":
1397 case "pull.merged":
Projects: what a workspace builds and runs, first on every page1398 for (const project of await this.projects.byRepo(event.data.repoId)) {
1399 const apps = await this.db
1400 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1401 .bind(project.id, event.data.number)
1402 .all<{ script: string }>();
1403 for (const app of apps.results) await this.removeApp(app.script);
1404 }
Deployments: a preview for every pull request, production on g1t.page1405 break;
Projects: what a workspace builds and runs, first on every page1406 case "git.push":
Deployments: a preview for every pull request, production on g1t.page1407 if (!event.data.defaultBranch) break;
Mirrors in work, Actions, deployments and the inbox1408 // A mirror deploys only while g1t leads it: standing by, or in CI
1409 // failover, the remote's own deploys stand (see contracts mirrors).
1410 if (!mirrorWritable(event.data.mirror)) break;
Projects: what a workspace builds and runs, first on every page1411 for (const project of await this.projects.byRepo(event.data.repoId)) {
1412 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1413 }
Deployments: a preview for every pull request, production on g1t.page1414 break;
1415 }
1416 }
1417
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1418 /**
1419 * A workspace's slug changed, and with it every app's name: production
1420 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1421 *
1422 * Its rows move to the slug it has now (asked of identity, so a delivery
1423 * 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 quotas1424 * each app (paused or not) is built again from the same commit under its
1425 * new name; see `followMoves`. The old name keeps serving the app until
1426 * the new one is live; then it redirects to the new name (see
1427 * `supersede`), held for as long as the workspace holds its old slug.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1428 * 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 quotas1429 * the new one.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1430 */
1431 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1432 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1433 const stale = staleSlugs(renamed, current);
1434 if (stale.length === 0) return;
1435 const marks = stale.map(() => "?").join(", ");
1436
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1437 // The workspace's projects, under any of its names: a second delivery
1438 // finds them under the new one, with whatever is left to do.
1439 const rows = await this.db
1440 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1441 .bind(...stale, current)
1442 .all<{ project_id: string }>();
1443 const projectIds = rows.results.map((row) => row.project_id);
1444 const projects = await this.projectsById(projectIds);
1445 // The projects service hears of the rename on its own queue: wait for
1446 // it a few deliveries, so the builds read the repository by its new name.
1447 const behind = [...projects.values()].some((p) => p.workspace !== current);
1448 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 domains1449
1450 // Every row moves at once.
1451 const at = now();
1452 const statements: D1PreparedStatement[] = [];
1453 for (const slug of stale) {
1454 statements.push(
1455 this.db
1456 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1457 `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 domains1458 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1459 .bind(RENAMED_ERROR, at, slug),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1460 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1461 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1462 this.db
1463 .prepare(
1464 `UPDATE deployments SET workspace = ?1,
1465 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1466 WHERE workspace = ?2`,
1467 )
1468 .bind(current, slug),
1469 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1470 this.domains.rename(slug, current),
1471 // Counters add up; the peak is the higher; a month charged stays charged.
1472 this.db
1473 .prepare(
1474 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1475 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1476 FROM meters WHERE namespace = ?2
1477 ON CONFLICT (namespace, month) DO UPDATE SET
1478 requests = requests + excluded.requests,
1479 cpu_ms = cpu_ms + excluded.cpu_ms,
1480 peak_apps = MAX(peak_apps, excluded.peak_apps),
1481 peak_domains = MAX(peak_domains, excluded.peak_domains),
1482 build_seconds = build_seconds + excluded.build_seconds,
1483 build_micros = build_micros + excluded.build_micros,
1484 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1485 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1486 )
1487 .bind(current, slug),
1488 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1489 );
1490 }
1491 await this.db.batch(statements);
1492
1493 // Each app again, under its new name. Its dependencies' addresses are
1494 // read again too, so apps that call one another follow the rename.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1495 const followed = await this.followMoves(projectIds, {
1496 projects,
1497 adjust: (project) => this.underSlug(project, current, stale),
1498 });
1499 if (followed.failed.length > 0) {
1500 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1501 }
1502 }
1503
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1504 /**
1505 * A repository moved: transferred to another workspace, and its projects
1506 * with it, or renamed within its own, when its own project's slug follows
1507 * its name (see the projects service). Each app's name is
1508 * `<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 quotas1509 * slug changed (production and every preview, paused or not) are built
1510 * again, from the same commit, under the new name; see `followMoves`. As
1511 * after a workspace rename, the old name keeps serving until the new one
1512 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1513 * The project's custom domains follow its production. Builds under way
1514 * are started again under the new name. What the apps used this month
1515 * stays on the old workspace's meter; from now on, the new workspace's
1516 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1517 *
1518 * Projects whose name did not change (a rename where the new name was
1519 * 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 quotas1520 * path. What is left to rebuild is read from the rows each time, so a
1521 * second or late delivery builds only what the first could not, and one
1522 * whose rebuild could not be queued is delivered again (and, past the
1523 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1524 */
1525 private async moved(move: RepoMove, attempts: number): Promise<void> {
1526 const current = await currentMovedPath(this.env.REPOS, move);
1527 if (staleMovedPaths(move, current).length === 0) return;
1528 const [workspace, name] = current.split("/") as [string, string];
1529 const settings = await this.db
1530 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1531 .bind(move.repoId)
1532 .all<{ project_id: string; workspace: string; slug: string }>();
1533 if (settings.results.length === 0) return;
1534
1535 // The projects service hears of the move on its own queue: wait for it
1536 // a few deliveries, so builds read the project where and as it is now.
1537 const projects = new Map<string, Project>();
1538 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1539 const behind = settings.results.some(({ project_id }) => {
1540 const project = projects.get(project_id);
1541 if (!project || project.source.kind !== "hosted") return false;
1542 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1543 });
1544 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1545
1546 // Its history goes with it, as the repository's issues do.
1547 const statements: D1PreparedStatement[] = [
1548 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1549 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1550 // The projects whose apps' names change, and have not been moved yet.
1551 const moving = settings.results
1552 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1553 .map((row) => row.project_id);
1554 if (moving.length > 0) {
1555 const ids = moving.map(() => "?").join(", ");
1556 statements.push(
1557 this.db
1558 .prepare(
1559 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1560 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1561 )
1562 .bind(MOVED_ERROR, now(), ...moving),
1563 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1564 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1565 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1566 const slug = projects.get(projectId)?.slug ?? null;
1567 statements.push(
1568 this.db
1569 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1570 .bind(workspace, slug, projectId),
1571 this.db
1572 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1573 .bind(workspace, slug, projectId),
1574 this.db
1575 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1576 .bind(workspace, slug, projectId),
1577 // Within the workspace its apps are listed under the project's name
1578 // now. One left behind in another workspace stays as it is until the
1579 // new name is live and redirects it.
1580 this.db
1581 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1582 .bind(workspace, slug, projectId),
1583 );
1584 }
1585 await this.db.batch(statements);
1586
1587 // Each app again, under its new name. App rows under the old name stay
1588 // 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 quotas1589 const followed = await this.followMoves(
1590 settings.results.map((row) => row.project_id),
1591 {
1592 projects,
1593 adjust: (found) =>
1594 found.source.kind === "hosted"
1595 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1596 : { ...found, workspace },
1597 },
1598 );
1599 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1600 }
1601
1602 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1603 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1604 const projects = new Map<string, Project>();
1605 if (projectIds.length === 0) return projects;
1606 const repoIds = new Set<string>();
1607 for (let i = 0; i < projectIds.length; i += 50) {
1608 const chunk = projectIds.slice(i, i + 50);
1609 const rows = await this.db
1610 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1611 .bind(...chunk)
1612 .all<{ repo_id: string }>();
1613 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1614 }
1615 const wanted = new Set(projectIds);
1616 for (const repoId of repoIds) {
1617 for (const project of await this.projects.byRepo(repoId)) {
1618 if (wanted.has(project.id)) projects.set(project.id, project);
1619 }
1620 }
1621 return projects;
1622 }
1623
1624 /** Whether `script` is the name an app of the project has where the project is now. */
1625 private async namedNow(
1626 script: string,
1627 settings: { project_id: string; workspace: string; slug: string },
1628 branch: string | null,
1629 ): Promise<boolean> {
1630 const base = await label(settings.workspace, settings.slug, branch);
1631 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1632 }
1633
1634 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1635 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1636 const stale: AppRow[] = [];
1637 for (const app of apps) {
1638 const row = settings.get(app.project_id);
1639 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1640 }
1641 return stale;
1642 }
1643
1644 /**
1645 * Brings the apps of the projects `projectIds` (every project when null)
1646 * under the names they have where the projects are now: each app still
1647 * under an older name, paused or not, and each build a move dropped, is
1648 * built again under its new name from the same commit, and once that is
1649 * live the old name redirects to it (`supersede`).
1650 *
1651 * Idempotent, and safe to run again at any time: an app already up under
1652 * its new name only has its old names redirected; one whose rebuild is
1653 * queued or under way is left to it; one whose workspace has no
1654 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1655 * each time; one whose commit is gone, or whose rebuild failed the same
1656 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1657 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1658 * 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 quotas1659 * event's delivery to be retried.
1660 */
1661 private async followMoves(
1662 projectIds: string[] | null,
1663 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1664 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1665 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1666 if (projectIds && projectIds.length === 0) return result;
1667 const cloudflare = this.cloudflare;
1668 if (!cloudflare) return result;
1669
1670 const settingsRows =
1671 projectIds == null
1672 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1673 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1674 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1675 if (settings.size === 0) return result;
1676 const ids = [...settings.keys()];
1677
1678 const apps: AppRow[] = [];
1679 const dropped: DroppedBuild[] = [];
1680 for (let i = 0; i < ids.length; i += 50) {
1681 const chunk = ids.slice(i, i + 50);
1682 const marks = chunk.map(() => "?").join(", ");
1683 const [appRows, droppedRows] = await Promise.all([
1684 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1685 // Builds a move dropped, that nothing has been built in place of since.
1686 this.db
1687 .prepare(
1688 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1689 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1690 AND NOT EXISTS (
1691 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1692 AND n.created_at > d.created_at AND n.status != 'skipped'
1693 )`,
1694 )
1695 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1696 .all<DeploymentRow>(),
1697 ]);
1698 apps.push(...appRows.results);
1699 dropped.push(...droppedRows.results);
1700 }
1701 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1702 if (targets.length === 0) return result;
1703
1704 const projects = new Map(options.projects ?? []);
1705 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1706 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1707 const billing = billingClient(this.env.BILLING);
1708 const open = new Map<string, boolean>();
1709 const actors = new Map<string, User | null>();
1710
1711 for (const target of targets) {
1712 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1713 const found = projects.get(target.projectId);
1714 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1715 const project = options.adjust ? options.adjust(found) : found;
1716 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1717 // Built only where deployments has the project now; the projects
1718 // service catches up on its own queue, and a later run builds then.
1719 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1720 result.waiting++;
1721 continue;
1722 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1723 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1724 const script = await this.scriptFor(project, target.branch);
1725 // Already up under its new name: only its old names are left to redirect.
1726 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1727 if (up) {
1728 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1729 continue;
1730 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1731 const history = await this.recentBuilds(script);
1732 const last = history[0];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1733 if (last && (last.status === "queued" || last.status === "building")) {
1734 result.queued++;
1735 continue;
1736 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1737 // A commit that is gone, or a build that failed the same way a few
1738 // times, is not tried again; a push or a redeploy builds it.
1739 // Otherwise the sweep waits longer after each failure.
1740 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 quotas1741 result.waiting++;
1742 continue;
1743 }
1744 // A workspace without Deployments, or over its limit, waits for it
1745 // rather than gathering refused deployments.
1746 if (!open.has(project.workspace)) {
1747 const [plan, limit] = await Promise.all([
1748 billing.hasFeature(project.workspace, "deployments"),
1749 billing.checkLimit(project.workspace),
1750 ]);
1751 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1752 }
1753 if (!open.get(project.workspace)) {
1754 result.waiting++;
1755 continue;
1756 }
1757 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 look1758 const started =
1759 target.kind === "production"
1760 ? await this.deployProduction(project, target.commit, "g1t")
1761 : target.number != null
1762 ? 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 quotas1763 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1764 const outcome = rebuildOutcome(started);
1765 if (outcome === "queued") result.queued++;
1766 else if (outcome === "failed") {
1767 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1768 result.failed.push(`${named}: ${why}`);
1769 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1770 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1771 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1772 }
1773 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1774 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1775 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1776 }
1777
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1778 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1779 private async recentBuilds(script: string): Promise<PastBuild[]> {
1780 const rows = await this.db
1781 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1782 .bind(script, MAX_IDENTICAL_FAILURES)
1783 .all<PastBuild>();
1784 return rows.results;
1785 }
1786
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1787 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1788 private async projectIdsFor(repoId: string): Promise<string[]> {
1789 const rows = await this.db
1790 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1791 .bind(repoId)
1792 .all<{ project_id: string }>();
1793 return rows.results.map((row) => row.project_id);
1794 }
1795
1796 /**
1797 * A repository was deleted, restorable for a while: every app of its
1798 * projects (production and previews) comes down, builds under way are
1799 * dropped, and nothing builds for it until it is restored. Its settings
1800 * and custom domains are kept for the restore; until then a domain has
1801 * nothing up to serve, as when production is turned off.
1802 */
1803 private async repoDeleted(repoId: string): Promise<void> {
1804 const projectIds = await this.projectIdsFor(repoId);
1805 if (projectIds.length === 0) return;
1806 const at = now();
1807 await this.db.batch([
1808 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1809 this.db
1810 .prepare(
1811 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1812 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1813 )
1814 .bind(at, repoId),
1815 ]);
1816 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1817 }
1818
1819 /**
1820 * A deleted repository is back: production goes up again from its
1821 * default branch, for each project that has it on. Previews come back
1822 * with the next push to their pull requests.
1823 */
1824 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1825 const deleted = await this.db
1826 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1827 .bind(repoId)
1828 .all<{ project_id: string }>();
1829 const ids = deleted.results.map((row) => row.project_id);
1830 if (ids.length === 0) return;
1831 // The projects service hears of the restore on its own queue, and hides
1832 // the projects until then: wait for it a few deliveries.
1833 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1834 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1835 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1836 for (const project of projects) {
1837 try {
1838 const started = await this.deployProduction(project, null, "g1t");
1839 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1840 } catch (error) {
1841 console.error("could not deploy after restore", project.slug, error);
1842 }
1843 }
1844 }
1845
1846 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1847 * A workspace was deleted, restorable by g1t's staff for a while: every
1848 * app of its projects (production and previews) is paused, answering with
1849 * a notice and running nothing, builds under way are dropped, and nothing
1850 * builds for it until it is restored. Nothing is taken down: scripts,
1851 * settings and custom domains are kept for the restore. Never for a
1852 * protected workspace, whatever was published.
1853 */
1854 private async workspaceDeleting(slug: string): Promise<void> {
1855 const workspace = slug.toLowerCase();
1856 if (isProtectedWorkspace(workspace)) {
1857 console.error("workspace.deleting ignored for protected", workspace);
1858 return;
1859 }
1860 const at = now();
1861 await this.db.batch([
1862 this.db
1863 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1864 .bind(at, workspace),
1865 this.db
1866 .prepare(
1867 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1868 WHERE workspace = ? AND status IN ('queued', 'building')`,
1869 )
1870 .bind(at, workspace),
1871 ]);
1872 const cloudflare = this.cloudflare;
1873 for (const app of await this.appsOfWorkspace(workspace)) {
1874 if (app.paused_at) continue;
1875 await cloudflare?.pauseScript(app.script);
1876 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1877 }
1878 }
1879
1880 /**
1881 * A deleted workspace is back: it builds again, and its paused apps are
1882 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1883 * (the sweep tries again any it could not).
1884 */
1885 private async workspaceRestored(slug: string): Promise<void> {
1886 const workspace = slug.toLowerCase();
1887 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1888 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1889 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1890 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1891 }
1892
1893 /**
1894 * A deleted workspace is purged: whatever its projects still have up
1895 * comes down and their custom domains go, as for a purged repository.
1896 * Its own repositories' projects are purged with them (`repo.purged`);
1897 * this catches any building from a repository it had transferred away.
1898 */
1899 private async workspacePurged(slug: string): Promise<void> {
1900 const workspace = slug.toLowerCase();
1901 if (isProtectedWorkspace(workspace)) return;
1902 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1903 for (const { project_id } of rows.results) {
1904 await this.takeDownWhere(project_id, null);
1905 await this.domains.removeWhere("project_id", project_id);
1906 }
1907 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1908 await this.db.batch([
1909 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1910 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1911 ]);
1912 }
1913
1914 /** The apps of a workspace's projects, and any still under its name. */
1915 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1916 const rows = await this.db
1917 .prepare(
1918 `SELECT * FROM apps WHERE workspace = ?1
1919 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1920 )
1921 .bind(workspace)
1922 .all<AppRow>();
1923 return rows.results;
1924 }
1925
1926 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1927 * A deleted repository is gone for good: its projects' custom domains are
1928 * removed (from the dispatcher and from Cloudflare), any app or redirect
1929 * still up comes down, and every row kept for them goes. What they used
1930 * stays on their workspace's meter.
1931 */
1932 private async repoPurged(repoId: string): Promise<void> {
1933 const projectIds = await this.projectIdsFor(repoId);
1934 const domains = this.domains;
1935 for (const projectId of projectIds) {
1936 await this.takeDownWhere(projectId, null);
1937 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1938 await domains.removeWhere("project_id", projectId);
1939 }
1940 // The redirects left at names its apps had before.
1941 const scripts = await this.db
1942 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
1943 .bind(repoId)
1944 .all<{ script: string }>();
1945 const hosts = scripts.results.map(({ script }) => appHost(script));
1946 for (let i = 0; i < hosts.length; i += 50) {
1947 const chunk = hosts.slice(i, i + 50);
1948 const redirects = await this.db
1949 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
1950 .bind(...chunk)
1951 .all<{ script: string }>();
1952 for (const { script } of redirects.results) {
1953 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1954 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 look1955 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
1956 }
1957 }
1958 await this.db.batch([
1959 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
1960 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
1961 ]);
1962 }
1963
1964 /**
1965 * The default branch is another one now: production is built from it, as
1966 * from a push to it, unless production already serves (or is building)
1967 * its commit, as when the default branch was only renamed.
1968 */
1969 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
1970 for (const found of await this.projects.byRepo(repoId)) {
1971 if (found.source.kind !== "hosted") continue;
1972 // Projects may not have heard yet: the event names the branch.
1973 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
1974 const actor = await this.workspaceActor(project.workspace);
1975 if (!actor) continue;
1976 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
1977 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
1978 if (!head) continue;
1979 const same = await this.db
1980 .prepare(
1981 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
1982 AND status IN ('queued', 'building', 'ready')`,
1983 )
1984 .bind(project.id, head)
1985 .first();
1986 if (same) continue;
1987 await this.deployProduction(project, head, createdBy);
1988 }
1989 }
1990
1991 /**
1992 * A branch was renamed: its preview is the same app, so its rows follow.
1993 * The app keeps its name until it is next built; then it goes up under
1994 * the new branch's name, and the old one redirects there (see `supersede`).
1995 */
1996 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
1997 const projectIds = await this.projectIdsFor(repoId);
1998 if (projectIds.length === 0) return;
1999 const ids = projectIds.map(() => "?").join(", ");
2000 await this.db.batch([
2001 this.db
2002 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2003 .bind(to, from, ...projectIds),
2004 this.db
2005 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2006 .bind(to, from, ...projectIds),
2007 ]);
2008 }
2009
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2010 /** `project` under the workspace's slug now, whether or not projects has caught up. */
2011 private underSlug(project: Project, current: string, stale: string[]): Project {
2012 const source =
2013 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
2014 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
2015 : project.source;
2016 return { ...project, workspace: current, source };
2017 }
2018
2019 /** A stack's preview (no pull request of its own) built again at `commit`. */
2020 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
2021 if (!actor || branch == null) return null;
2022 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2023 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2024 return this.start({
2025 project,
2026 kind: "preview",
2027 branch,
2028 number: null,
2029 commit,
2030 source: repoOf(project).path,
2031 reader: actor,
2032 createdBy: "g1t",
2033 settings,
2034 // As `stack` built it: the project's own default branch.
2035 trusted: true,
2036 });
2037 }
2038
Deployments: a preview for every pull request, production on g1t.page2039 // ---- The sweep -----------------------------------------------------
2040
2041 /**
2042 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page2043 * previews, the apps of workspaces whose plan ended, and scripts no app
2044 * holds come down; and a month that is over is charged past its
2045 * allowance.
Merge main into mirroring: remotes migration becomes integrations 00072046 *
2047 * Each step stands alone: one that fails is logged and the rest still
2048 * run. Taking idle previews down and charging months, which only read
2049 * g1t's own tables, go before the steps that wait on Cloudflare's API,
2050 * so a slow or failing API never holds them up.
Deployments: a preview for every pull request, production on g1t.page2051 */
2052 async sweep(): Promise<void> {
Merge main into mirroring: remotes migration becomes integrations 00072053 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
2054 await step("failing stuck builds", async () => {
2055 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2056 const stuck = await this.db
2057 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2058 .bind(cutoff)
2059 .all<{ id: string }>();
2060 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2061 });
2062 await step("taking down idle previews", () => this.takeDownIdle());
2063 await step("charging months", () => this.chargeMonths());
Deployments: a preview for every pull request, production on g1t.page2064
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2065 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2066 // Each app is its project's workspace's, as deployments has it now: an
2067 // app still under the name it had before its project moved is the new
2068 // workspace's, and plans and limits are checked there.
2069 const settings = new Map(
2070 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2071 );
2072 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 member2073 // A deleted workspace's apps stay paused as they are, for a restore:
2074 // its plan ended with the deletion, and that must not take them down.
2075 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2076 const live = apps.filter((app) => !held(app));
2077 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Deployments: a preview for every pull request, production on g1t.page2078
2079 // Apps of workspaces whose plan has ended come down.
2080 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2081 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Deployments: a preview for every pull request, production on g1t.page2082 for (const workspace of workspaces) {
Merge main into mirroring: remotes migration becomes integrations 00072083 await step(`ending ${workspace}'s apps`, async () => {
2084 const plan = await billing.hasFeature(workspace, "deployments");
2085 if (!plan.ok && plan.error.code === "payment_required") {
2086 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2087 // Custom domains cost g1t by the month: they go with the plan.
2088 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2089 }
2090 });
Deployments: a preview for every pull request, production on g1t.page2091 }
2092
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2093 // Apps whose project moved and are not up under the new name yet: a
2094 // move's rebuild that could not start is tried again here.
2095 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2096 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2097
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2098 await this.domains
2099 .sweep(async (projectId) => {
2100 const app = await this.db
2101 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2102 .bind(projectId)
2103 .first<{ script: string }>();
2104 return app?.script ?? null;
2105 })
2106 .catch((error) => console.error("could not check domains", error));
2107
Projects: what a workspace builds and runs, first on every page2108 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page2109 await this.count(apps).catch((error) => console.error("could not count usage", error));
2110 }
2111
Usage limits: unpaid usage can only go so far2112 /**
2113 * Pauses the apps of workspaces that reached their limit for usage not
2114 * yet paid for, and rebuilds them from the same commit once they are
2115 * 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 quotas2116 *
2117 * The workspace is the project's now (see `ownerOf`), never the one an
2118 * app's row was written under, so an app left under its old name after
2119 * a transfer is paused only if its new workspace is over its limit. Such
2120 * an app is resumed by being built under its new name (`followMoves`),
2121 * not here.
Usage limits: unpaid usage can only go so far2122 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2123 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Usage limits: unpaid usage can only go so far2124 const cloudflare = this.cloudflare;
2125 if (!cloudflare) return;
2126 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2127 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2128 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Usage limits: unpaid usage can only go so far2129 const limit = await billing.checkLimit(workspace);
2130 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2131 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Usage limits: unpaid usage can only go so far2132 if (limit.value.state === "stopped") {
2133 for (const app of theirs.filter((a) => !a.paused_at)) {
2134 await cloudflare.pauseScript(app.script);
2135 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2136 }
2137 continue;
2138 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2139 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2140 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Usage limits: unpaid usage can only go so far2141 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2142 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Usage limits: unpaid usage can only go so far2143 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2144 const project = projects.get(app.project_id);
2145 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2146 // A failed or refused rebuild leaves it paused, to try again later:
2147 // longer after each failure, and not once its commit is gone or it
2148 // failed the same way a few times.
2149 const history = await this.recentBuilds(app.script);
2150 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2151 const backoff = history[0]?.status === "failed";
2152 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
Usage limits: unpaid usage can only go so far2153 const rebuilt =
2154 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2155 ? await this.deployProduction(project, app.commit_sha, "g1t")
Usage limits: unpaid usage can only go so far2156 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2157 ? await this.deployPreview(project, app.number, "g1t", true)
Usage limits: unpaid usage can only go so far2158 : null;
2159 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2160 }
2161 }
2162 }
2163
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2164 /**
2165 * Scripts in the namespace that no app holds, such as ones renamed. An
2166 * old address that redirects to its app's new one is held until its
2167 * redirect expires, then removed with the rest.
2168 */
Projects: what a workspace builds and runs, first on every page2169 private async removeOrphans(apps: AppRow[]): Promise<void> {
2170 const cloudflare = this.cloudflare;
2171 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2172 // Their entries in `DOMAINS` expire on their own, at the same time.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2173 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2174 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2175 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 page2176 const building = await this.db
2177 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2178 .all<{ script: string }>();
2179 for (const row of building.results) held.add(row.script);
2180 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2181 for (const script of await cloudflare.listScripts()) {
2182 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2183 }
2184 }
2185
Deployments: a preview for every pull request, production on g1t.page2186 /** Counts this month's requests and CPU time per workspace, from analytics. */
2187 private async count(apps: AppRow[]): Promise<void> {
2188 const cloudflare = this.cloudflare;
2189 if (!cloudflare || apps.length === 0) return;
2190 const start = `${month()}-01T00:00:00Z`;
2191 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2192 // Analytics only counts apps that are up; the meter keeps what earlier
2193 // apps used by never going down.
2194 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2195 for (const app of apps) {
2196 const used = totals.get(app.script);
2197 if (!used) continue;
Projects: what a workspace builds and runs, first on every page2198 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page2199 sum.requests += used.requests;
2200 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page2201 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page2202 }
2203 const at = now();
Merge main into mirroring: remotes migration becomes integrations 00072204 // Written only when the count moves the meter: most sweeps it does not.
2205 const stored = new Map(
2206 (
2207 await this.db
2208 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2209 .bind(month())
2210 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2211 ).results.map((row) => [row.namespace, row]),
2212 );
Projects: what a workspace builds and runs, first on every page2213 for (const [workspace, used] of perWorkspace) {
Merge main into mirroring: remotes migration becomes integrations 00072214 if (!movesMeter(stored.get(workspace), used)) continue;
Deployments: a preview for every pull request, production on g1t.page2215 await this.db
2216 .prepare(
2217 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2218 ON CONFLICT (namespace, month) DO UPDATE SET
2219 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2220 )
Projects: what a workspace builds and runs, first on every page2221 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page2222 .run();
2223 }
2224 // When each preview last answered anyone, for the idle sweep.
2225 const recent = await cloudflare.usage(
2226 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2227 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2228 at,
2229 );
Merge main into mirroring: remotes migration becomes integrations 00072230 // At an hour's resolution: previews idle for days are what it finds.
2231 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2232 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
Deployments: a preview for every pull request, production on g1t.page2233 for (const [script, used] of recent) {
Merge main into mirroring: remotes migration becomes integrations 00072234 const last = lastRequest.get(script);
2235 if (used.requests > 0 && (!last || last < hourAgo)) {
Deployments: a preview for every pull request, production on g1t.page2236 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2237 }
2238 }
Projects: what a workspace builds and runs, first on every page2239 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 quotas2240 // What this month's traffic and custom domains will cost, from the
2241 // first request and the first domain, so the workspace's limit counts
2242 // it now rather than when the month closes, and its Billing page shows it.
Prices keep themselves current with what g1t pays2243 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2244 const billing = billingClient(this.env.BILLING);
2245 const meters = await this.db
2246 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2247 .bind(month())
2248 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2249 for (const meter of meters.results) {
2250 const cost = monthCost(meter, costs);
2251 await billing
2252 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Prices keep themselves current with what g1t pays2253 .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 quotas2254 if ((meter.peak_domains ?? 0) > 0) {
2255 await billing
2256 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2257 .catch((error) => console.error("could not note pending usage", error));
2258 }
Prices keep themselves current with what g1t pays2259 }
Deployments: a preview for every pull request, production on g1t.page2260 }
2261
Projects: what a workspace builds and runs, first on every page2262 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page2263 private async takeDownIdle(): Promise<void> {
2264 const idle = await this.db
2265 .prepare(
Projects: what a workspace builds and runs, first on every page2266 `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 member2267 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page2268 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2269 )
2270 .all<{ script: string }>();
2271 for (const { script } of idle.results) await this.removeApp(script);
2272 }
2273
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2274 /** 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.page2275 private async chargeMonths(): Promise<void> {
2276 const due = await this.db
2277 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2278 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2279 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Prices keep themselves current with what g1t pays2280 const costs = await this.costs();
Deployments: a preview for every pull request, production on g1t.page2281 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2282 const cost = monthCost(meter, costs);
2283 if (cost.micros > 0) {
Deployments: a preview for every pull request, production on g1t.page2284 const charged = await billingClient(this.env.BILLING).chargeFeature({
2285 workspace: meter.namespace,
2286 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2287 costMicros: cost.micros,
2288 description: `Deployments in ${meter.month}: ${cost.description}`,
Deployments: a preview for every pull request, production on g1t.page2289 reference: `deployments/${meter.namespace}/${meter.month}`,
2290 });
2291 if (!charged.ok) continue;
2292 }
2293 await this.db
2294 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2295 .bind(now(), meter.namespace, meter.month)
2296 .run();
2297 }
2298 }
2299}
2300
2301/** `POST /rpc/<method>`: the arguments are the body. */
Project dependencies: addresses, preview stacks, Affects, and agents who know2302async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
Deployments: a preview for every pull request, production on g1t.page2303 switch (method) {
2304 case "settings":
2305 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2306 case "is_enabled":
2307 return service.isEnabled(args);
Deployments: a preview for every pull request, production on g1t.page2308 case "update_settings":
2309 return service.updateSettings(args);
2310 case "list":
2311 return service.list(args);
2312 case "get":
2313 return service.get(args);
2314 case "redeploy":
2315 return service.redeploy(args);
2316 case "take_down":
2317 return service.takeDown(args);
Project dependencies: addresses, preview stacks, Affects, and agents who know2318 case "stack":
2319 return service.stack(args, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page2320 case "overview":
2321 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page2322 case "usage":
2323 return service.usage(args);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2324 case "domains":
2325 return service.listDomains(args);
2326 case "add_domain":
2327 return service.addDomain(args);
2328 case "remove_domain":
2329 return service.removeDomain(args);
2330 case "refresh_domain":
2331 return service.refreshDomain(args);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972332 // A repository's deployments wherever they run (repo-deployments.ts).
2333 case "list_deployments":
2334 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2335 case "get_deployment":
2336 return service.repoDeployments.get(args);
2337 case "list_deployment_statuses":
2338 return service.repoDeployments.statuses(args);
2339 case "list_environments":
2340 return service.repoDeployments.environments(args);
2341 case "get_environment":
2342 return service.repoDeployments.environment(args);
2343 case "create_deployment":
2344 return service.repoDeployments.create(args);
2345 case "create_deployment_status":
2346 return service.repoDeployments.createStatus(args);
2347 // For the actions service: a run's deployment to one environment.
2348 case "actions_deployment":
2349 return service.repoDeployments.fromActions(args);
Deployments: a preview for every pull request, production on g1t.page2350 default:
2351 return undefined;
2352 }
2353}
2354
2355export default {
Project dependencies: addresses, preview stacks, Affects, and agents who know2356 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Deployments: a preview for every pull request, production on g1t.page2357 const { pathname } = new URL(request.url);
2358 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2359 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2360 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2361 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2362 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2363 const opened = openD1(env.DB, request);
2364 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Project dependencies: addresses, preview stacks, Affects, and agents who know2365 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2366 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.page2367 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2368 const service = new Deployments(env);
Deployments: a preview for every pull request, production on g1t.page2369 // A build's reports, forwarded by the API.
2370 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2371 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2372 return new Response("Not found\n", { status: 404 });
2373 },
2374
2375 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2376 const service = new Deployments(env);
2377 for (const message of batch.messages) {
2378 try {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains2379 await service.onEvent(message.body, message.attempts);
Deployments: a preview for every pull request, production on g1t.page2380 message.ack();
2381 } catch (error) {
2382 console.error("deployments could not handle", message.body.type, error);
2383 message.retry();
2384 }
2385 }
2386 },
2387
2388 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2389 await new Deployments(env).sweep();
2390 },
2391} satisfies ExportedHandler<Env, G1tEvent>;

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