Skip to content
2,812 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.

Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1/**
2 * 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.
5 *
6 * It reacts to events (a pull request opened, ready, pushed to, closed or
7 * 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.
14 *
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r915 * A g1t Actions job can open a build too (`startFromWorkflow`, for
16 * `POST /repos/{owner}/{repo}/deployments/builds` and the `g1t/deploy`
17 * action) and build it in its own checkout with `g1t-runner deploy`; it
18 * reports the same way, and its time is the job's, not a sandbox's.
19 *
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays20 * Nothing here is free, and nothing is capped. On the plan, every build
21 * second is charged as it happens; requests, CPU time and custom domains
22 * are charged from the first once the month is over, all at cost plus the
23 * margin and from the plan's included usage first. No count of projects,
24 * previews or apps ever stops or pauses a workspace: apps are not metered
25 * at all. Only the workspace's spend limit pauses its apps
26 * (`holdToLimits`), and only the plan ending takes them down. A Worker
27 * runs only while it answers a request, so an app no one visits costs
28 * nothing, and a preview is taken down when its pull request closes or
29 * after its project's idle days.
30 *
31 * Reached through service bindings (`POST /rpc/<method>`) and, for a
32 * build's reports, through the API (`POST /jobs/<id>/<step>`).
33 */
34
35import {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.36 BUILD_AS,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays37 CUSTOM_DOMAIN_TARGET,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.38 DEFAULT_APP_INSTANCE,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays39 DEPLOYMENT_COSTS,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.40 frameworkId,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays41 SLUG_HOLD_DAYS,
42 ComputeGate,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r943 actionsClient,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays44 allows,
45 billingClient,
46 can,
47 needs,
48 permission,
49 sandboxEstimateMicros,
50 currentMovedPath,
51 currentWorkspaceSlug,
Events: review requests, assignments, stops and deployments are published52 eventsClient,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays53 fail,
54 identityClient,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member55 isProtectedWorkspace,
Merge branch 'mirroring' into artifacts-mode56 mirrorWritable,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays57 newId,
58 ok,
59 openD1,
60 projectsClient,
61 repoMove,
62 reposClient,
63 staleMovedPaths,
64 staleSlugs,
65 workClient,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.66 type BuildAs,
67 type ContainerCosts,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays68 type DeployKind,
69 type DeploySettings,
70 type DetectedKind,
71 type DeployStatus,
72 type DeployUsage,
73 type Deployment,
74 type Domain,
75 type G1tEvent,
76 type LiveApp,
77 type Project,
78 type ProjectDeploys,
79 type RepoMove,
80 type ProjectDomains,
81 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights82 workOwner,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays83 type RepoPath,
84 type Result,
85 type ServiceBinding,
86 type User,
87 type Viewer,
88} from "@g1t/contracts";
89
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights90import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays91import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.92import {
93 REGISTRY_HOST,
94 appWorkerMetadata,
95 containerCost,
96 containerOffer,
97 instanceTypeOf,
98 maxImageBytes,
99 meterReport,
100 pushedImage,
101 repositoryFor,
102 type AppWorkerSettings,
103 type ContainerMeter,
104} from "./containers";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays105import { CustomHostnames } from "./custom-hostnames";
106import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails107import { monthCost, movesMeter } from "./metering";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit108import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
109import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays110import { appHost, appUrl, label, uniqueLabel } from "./names";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97111import { RepoDeployments, sourceOf } from "./repo-deployments";
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.112import { NEEDS_WRITE, commitOf, jobEndedMessage, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays113
114type Env = {
115 DB: D1Database;
116 REPOS: ServiceBinding;
117 WORK: ServiceBinding;
118 IDENTITY: ServiceBinding;
119 BILLING: ServiceBinding;
120 RUNNER: ServiceBinding;
121 PROJECTS: ServiceBinding;
122 /** Secrets and variables: the actions service holds the one store. */
123 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published124 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
125 EVENTS?: ServiceBinding;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays126 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
127 CLOUDFLARE_API_TOKEN?: string;
128 CLOUDFLARE_ACCOUNT_ID: string;
129 DISPATCH_NAMESPACE: string;
130 SITE: string;
131 /** Custom domains: hostname to app, read by the dispatcher. */
132 DOMAINS?: KVNamespace;
133 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
134 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.135 /**
136 * `on` runs container apps (Docker deployments, src/containers.ts); off
137 * until Cloudflare's container APIs are checked against g1t's account.
138 * While off, a container build is refused at its `registry` step with
139 * `not_enabled`, and a project with a site besides its Dockerfile is
140 * built as that site.
141 */
142 CONTAINERS?: string;
143 /** Where container apps' Workers report their running time: the API. */
144 G1T_API?: string;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays145 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
146 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
147};
148
149/** A build that has not reported in this long has died. */
150const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
151/** A script in the namespace that no app holds, older than this, is removed. */
152const ORPHAN_AFTER_MS = 60 * 60 * 1000;
153const LIST_LIMIT = 50;
154const STATUS_CONTEXT = "g1t / deploy";
155/**
156 * Deliveries of `workspace.renamed` that wait for the projects service to
157 * have seen it too, before going ahead with the new slug regardless.
158 */
159const RENAME_WAITS = 3;
160/** Why a build under way was dropped by a move; the move builds it again under the new name. */
161const MOVED_ERROR = "The repository moved: built again under its new name.";
162const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit163/**
164 * A move's rebuild that could not start, or failed, is tried again by the
165 * sweep after this long, doubled for each failure after the first, up to
166 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
167 */
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays168const MOVE_RETRY_MS = 60 * 60 * 1000;
169
170/** A build's report of what it found the project to be, if it is one g1t knows. */
171export function detectedKind(value: unknown): DetectedKind | null {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.172 return value === "workers" || value === "static" || value === "html" || value === "container" ? value : null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays173}
174
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.175/** What a project is built as, as it is kept. */
176export function buildAsOf(value: unknown): BuildAs {
177 return BUILD_AS.includes(value as BuildAs) ? (value as BuildAs) : "auto";
178}
179
180/** A Dockerfile's path as a project may set it: inside the project, or none. */
181export function dockerfileOf(value: unknown): string | null {
182 const path = typeof value === "string" ? value.trim().replace(/^\.\//, "") : "";
183 if (!path || path.startsWith("/") || path.split("/").includes("..") || path.length > 300) return null;
184 return path;
185}
186
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays187const now = () => new Date().toISOString();
188const month = (at = new Date()) => at.toISOString().slice(0, 7);
189
190async function sha256(text: string): Promise<string> {
191 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
192 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
193}
194
195function randomToken(): string {
196 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
197}
198
199function isMember(viewer: Viewer, slug: string): boolean {
200 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
201}
202
203/** The repository a project builds from. */
204/** One compute gate per isolate, so entitlements and prices are kept between calls. */
205let computeGate: ComputeGate | null = null;
206function gateFor(billing: ServiceBinding): ComputeGate {
207 computeGate ??= new ComputeGate(billing);
208 return computeGate;
209}
210
211/** The longest a build may run, in minutes: as long as its read token lasts. */
212const BUILD_MINUTES = 30;
213
214/**
215 * A build that was skipped before it started (its plan, its limit, or
216 * billing's refusal) as a failure, so whoever asked for it sees why.
217 */
218function notStarted(result: Result<Deployment>): Result<Deployment> {
219 if (result.ok && result.value.status === "skipped" && result.value.error) {
220 return fail("payment_required", result.value.error);
221 }
222 return result;
223}
224
225function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
226 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
227 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
228}
229
230type SettingsRow = {
231 project_id: string;
232 workspace: string;
233 slug: string;
234 repo_id: string;
235 enabled: number;
236 previews: number;
237 production: number;
238 build_command: string | null;
239 output_dir: string | null;
240 idle_days: number;
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.241 /** Whether it builds through its managed deploy workflow; see `syncManagedDeploy`. */
242 via_workflow?: number;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays243 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
244 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member245 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
246 workspace_deleted_at?: string | null;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.247 /** What it is built as, its Dockerfile, and its container's size (migration 0014). */
248 build_as?: string | null;
249 dockerfile?: string | null;
250 instance_type?: string | null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays251};
252
253type DeploymentRow = {
254 id: string;
255 project_id: string;
256 workspace: string;
257 slug: string;
258 repo_id: string;
259 repo: string;
260 kind: DeployKind;
261 branch: string | null;
262 number: number | null;
263 commit_sha: string;
264 script: string;
265 status: DeployStatus;
266 error: string | null;
267 warnings: string;
268 log: string | null;
269 token_hash: string | null;
270 trusted: number;
271 build_seconds: number | null;
272 created_by: string;
273 created_at: string;
274 finished_at: string | null;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9275 /** Set for a build a g1t Actions job runs (migration 0011): the job pays for its time. */
276 run_id?: string | null;
277 job_id?: string | null;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.278 detected?: string | null;
279 /** What it was built with, and a container's image by digest (migration 0014). */
280 framework?: string | null;
281 image?: string | null;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9282};
283
284/** What opening any build takes; see `open`. */
285type OpenInput = {
286 project: Project;
287 kind: DeployKind;
288 branch: string | null;
289 number: number | null;
290 commit: string;
291 createdBy: string;
292 settings: SettingsRow;
293 /** A push, or work by a member or an agent; see `insider`. */
294 trusted: boolean;
295 /** The g1t Actions job that builds it, for a build opened from a workflow. */
296 workflow?: { runId: string; jobId: string };
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays297};
298
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9299/** Why a build was not opened: the plan or the limit, or g1t itself. */
300type Refusal = "payment_required" | "conflict";
301
302/** A sandbox's reservation with billing; none for a workflow job's build. */
303type Compute = { reservation: string | null; microsPerSecond: number; maxRunMinutes: number | null };
304
305type Opened =
306 | { refused: string; code: Refusal; id: string }
307 | {
308 refused: null;
309 id: string;
310 token: string;
311 script: string;
312 build: { secrets: Record<string, string>; variables: Record<string, string> };
313 compute: Compute;
314 };
315
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays316type AppRow = {
317 script: string;
318 project_id: string;
319 workspace: string;
320 slug: string;
321 kind: DeployKind;
322 branch: string | null;
323 number: number | null;
324 commit_sha: string;
325 deployed_at: string;
326 created_at: string;
327 last_request_at: string | null;
328 /** Set while its workspace is over its limit; see `holdToLimits`. */
329 paused_at: string | null;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.330 /** `container` for a container app (migration 0014), with its Container application and size. */
331 runtime?: string | null;
332 container_app_id?: string | null;
333 instance_type?: string | null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays334};
335
336function toDeployment(row: DeploymentRow): Deployment {
337 return {
338 id: row.id,
339 kind: row.kind,
340 branch: row.branch,
341 number: row.number,
342 commit: row.commit_sha,
343 status: row.status,
344 url: appUrl(row.script),
345 error: row.error,
346 warnings: JSON.parse(row.warnings || "[]") as string[],
347 buildSeconds: row.build_seconds,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.348 detected: detectedKind(row.detected),
349 framework: frameworkId(row.framework),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays350 createdBy: row.created_by,
351 createdAt: row.created_at,
352 finishedAt: row.finished_at,
353 };
354}
355
356function toLive(app: AppRow): LiveApp {
357 return {
358 kind: app.kind,
359 branch: app.branch,
360 number: app.number,
361 url: appUrl(app.script),
362 commit: app.commit_sha,
363 deployedAt: app.deployed_at,
364 };
365}
366
367class Deployments {
368 constructor(private readonly env: Env) {}
369
370 private get cloudflare(): Cloudflare | null {
371 const token = this.env.CLOUDFLARE_API_TOKEN;
372 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
373 }
374
375 private get db() {
376 return this.env.DB;
377 }
378
379 private get domains(): Domains {
380 const token = this.env.CLOUDFLARE_API_TOKEN;
381 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
382 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
383 }
384
385 private get projects() {
386 return projectsClient(this.env.PROJECTS);
387 }
388
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97389 /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */
390 get repoDeployments(): RepoDeployments {
391 return new RepoDeployments(this.env);
392 }
393
394 /**
395 * Publishes a build's change in the repository-wide model
396 * (`deployment.created`, `deployment_status.created`), as a reported
397 * deployment's are. Never fails the build.
398 */
399 private async buildChanged(id: string, created = false): Promise<void> {
400 try {
401 const row = await this.deploymentRow(id);
402 if (!row) return;
403 const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id);
404 const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main";
405 await this.repoDeployments.buildChanged(row, defaultBranch, created);
406 } catch (error) {
407 console.error("build change not published", id, String(error));
408 }
409 }
410
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays411 /** The workspace itself, as the service acts for it. */
412 private async workspaceActor(slug: string): Promise<User | null> {
413 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
414 if (!workspace) return null;
415 return {
416 id: workspace.id,
417 username: workspace.slug,
418 kind: "workspace",
419 verified: true,
420 workspaces: [{ slug: workspace.slug, role: "member" }],
421 };
422 }
423
424 /**
425 * What the project's secrets and variables available to deployments give
426 * production or a preview: its build's environment, and the same again as
427 * the running app's bindings. Untrusted builds get no secrets.
428 */
429 private async resolve(
430 project: { id: string; slug: string; repoId: string; repo: RepoPath },
431 environment: DeployKind,
432 trusted: boolean,
433 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
434 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
435 method: "POST",
436 headers: { "content-type": "application/json" },
437 body: JSON.stringify({
438 repoId: project.repoId,
439 repo: project.repo,
440 projectId: project.id,
441 projectSlug: project.slug,
442 consumer: "deployments",
443 environment,
444 trusted,
445 }),
446 });
447 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
448 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
449 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
450 }
451
452 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights453 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
454 * it, or its author) is trusted with the project's secrets: g1t itself,
455 * or someone who can push to the repository (Write or more, a member's or
456 * a collaborator's). Anyone else gets a preview built without them, as
457 * their workflows run. A change g1t made for someone is trusted as they
458 * are, never more for being g1t's.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays459 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights460 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
461 if (trustedOutright(owner)) return true;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays462 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 rights463 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays464 .catch(() => null);
465 return !!found?.ok && allows(found.value.role, "push");
466 }
467
468 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
469 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
470 }
471
472 /** The name an app gets, unique among apps: production, or a branch's preview. */
473 private async scriptFor(project: Project, branch: string | null): Promise<string> {
474 const base = await label(project.workspace, project.slug, branch);
475 const holder = await this.db
476 .prepare(
477 `SELECT project_id, branch FROM apps WHERE script = ?1
478 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
479 )
480 .bind(base)
481 .first<{ project_id: string; branch: string | null }>();
482 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
483 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
484 }
485
486 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
487 return {
488 enabled: !!row?.enabled,
489 previews: row ? !!row.previews : true,
490 production: row ? !!row.production : true,
491 buildCommand: row?.build_command ?? null,
492 outputDir: row?.output_dir ?? null,
493 idleDays: row?.idle_days ?? 7,
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.494 viaWorkflow: !!row?.via_workflow,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays495 productionUrl: appUrl(await this.scriptFor(project, null)),
496 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.497 ...(await this.lastDetected(project.id)),
498 buildAs: buildAsOf(row?.build_as),
499 dockerfile: row?.dockerfile ?? null,
500 instanceType: instanceTypeOf(row?.instance_type ?? DEFAULT_APP_INSTANCE),
501 containers: await this.containerOffer(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays502 };
503 }
504
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.505 /** What the project's last finished build found it to be, and built with; null before one has. */
506 private async lastDetected(projectId: string): Promise<{ detected: DetectedKind | null; framework: string | null }> {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays507 const row = await this.db
508 .prepare(
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.509 "SELECT detected, framework FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays510 )
511 .bind(projectId)
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.512 .first<{ detected: string; framework: string | null }>()
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays513 .catch(() => null);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.514 return { detected: detectedKind(row?.detected), framework: frameworkId(row?.framework) };
515 }
516
517 /** Whether container apps run here. */
518 private get containersOn(): boolean {
519 return this.env.CONTAINERS?.trim().toLowerCase() === "on";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays520 }
521
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.522 /** What each container size is charged an hour awake, for Settings, Deployments. */
523 private async containerOffer() {
524 const costs = await this.costs();
525 return containerOffer(costs.container, costs.marginPercent, this.containersOn);
526 }
527
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays528 /**
529 * The project, if `viewer` may do what `method` needs on its repository
530 * (see `NEEDS`): not found when they cannot read it, refused when they can
531 * but their role is too low.
532 */
533 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
534 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
535 if (!found.ok) return found;
536 const repo = repoRef(found.value);
537 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
538 const capability = NEEDS[method];
539 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
540 return found;
541 }
542
543 // ---- Methods for the site and the API ------------------------------
544
545 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
546 const project = await this.projectFor(a.project, a.viewer, "settings");
547 if (!project.ok) return project;
548 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
549 }
550
551 async updateSettings(a: {
552 actor: User;
553 project: ProjectRef;
554 changes: Partial<DeploySettings>;
555 }): Promise<Result<DeploySettings>> {
556 const found = await this.projectFor(a.project, a.actor, "updateSettings");
557 if (!found.ok) return found;
558 const project = found.value;
559 const before = await this.toSettings(project, await this.settingsRow(project.id));
560 const next = { ...before, ...a.changes };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97561 // A library, a tool or other, as set in its settings, does not deploy.
562 if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") {
563 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 production564 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays565 if (next.enabled && !before.enabled) {
566 // Turning it on starts paid work: only with the workspace's plan.
567 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
568 if (!plan.ok) return plan;
569 }
570 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
571 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.572 if (a.changes.dockerfile && !dockerfileOf(a.changes.dockerfile)) {
573 return fail("invalid", "The Dockerfile's path must be inside the project, such as `Dockerfile` or `deploy/web.Dockerfile`.");
574 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays575 await this.db
576 .prepare(
577 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.578 output_dir, idle_days, updated_by, updated_at, via_workflow, build_as, dockerfile, instance_type)
579 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays580 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.581 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.582 via_workflow = ?13, build_as = ?14, dockerfile = ?15, instance_type = ?16`,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays583 )
584 .bind(
585 project.id,
586 project.workspace,
587 project.slug,
588 repoOf(project).id,
589 next.enabled ? 1 : 0,
590 next.previews ? 1 : 0,
591 next.production ? 1 : 0,
592 clip(next.buildCommand),
593 clip(next.outputDir),
594 idleDays,
595 a.actor.username,
596 now(),
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.597 next.viaWorkflow ? 1 : 0,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.598 buildAsOf(next.buildAs),
599 dockerfileOf(next.dockerfile),
600 instanceTypeOf(next.instanceType),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays601 )
602 .run();
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.603 // The managed deploy workflow follows the settings: installed while the
604 // project builds through it, removed when it stops.
605 if (next.viaWorkflow || before.viaWorkflow) {
606 const installed = await this.syncManagedDeploy(project, next);
607 if (!installed.ok) return installed;
608 }
A project is an app or a library: libraries show their package and how to ship a release, not production609 // Projects keeps whether it deploys, so a project with Deployments on is an app.
610 if (next.enabled !== before.enabled) {
611 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
612 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays613 // What was turned off comes down now; nothing keeps running unasked.
614 if (!next.enabled) await this.takeDownWhere(project.id, null);
615 else {
616 if (!next.previews) await this.takeDownWhere(project.id, "preview");
617 if (!next.production) await this.takeDownWhere(project.id, "production");
618 }
619 // Turned on: production goes up from the default branch at once.
620 if (next.enabled && next.production && (!before.enabled || !before.production)) {
621 await this.deployProduction(project, null, a.actor.username);
622 }
623 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
624 }
625
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.626 /**
627 * Installs, replaces or removes the project's managed deploy workflow
628 * (the actions service's `set_managed_deploy`) to match `settings`: it
629 * runs while Deployments are on and the project builds through it.
630 */
631 private async syncManagedDeploy(project: Project, settings: DeploySettings): Promise<Result<boolean>> {
632 const on = settings.enabled && settings.viaWorkflow;
633 const repo = repoOf(project);
634 const response = await this.env.ACTIONS.fetch("https://actions/rpc/set_managed_deploy", {
635 method: "POST",
636 headers: { "content-type": "application/json" },
637 body: JSON.stringify({
638 repoId: repo.id,
639 project: project.slug,
640 productionBranch: repo.defaultBranch,
641 rootDir: project.source.kind === "hosted" ? (project.source.rootDir ?? "") : "",
642 buildCommand: settings.buildCommand ?? "",
643 outputDir: settings.outputDir ?? "",
644 previews: on && settings.previews,
645 production: on && settings.production,
646 }),
647 });
648 const answer = (await response.json().catch(() => null)) as { ok?: boolean; value?: boolean; error?: { message?: string } } | null;
649 if (!response.ok || !answer || answer.ok === false) {
650 return fail("unavailable", answer?.error?.message ?? "The deploy workflow could not be installed. Try again in a moment.");
651 }
652 return ok(!!answer.value);
653 }
654
655 /** Whether `projectId` builds through its managed deploy workflow, so pushes and pull requests start no build here. */
656 private async buildsViaWorkflow(projectId: string): Promise<boolean> {
657 return !!(await this.settingsRow(projectId))?.via_workflow;
658 }
659
A project is an app or a library: libraries show their package and how to ship a release, not production660 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
661 async isEnabled(a: { projectId: string }): Promise<boolean> {
662 return !!(await this.settingsRow(a.projectId))?.enabled;
663 }
664
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays665 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
666 const project = await this.projectFor(a.project, a.viewer, "list");
667 if (!project.ok) return project;
668 const [deployments, apps] = await Promise.all([
669 this.db
670 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
671 .bind(project.value.id, LIST_LIMIT)
672 .all<DeploymentRow>(),
673 this.db
674 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
675 .bind(project.value.id)
676 .all<AppRow>(),
677 ]);
678 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
679 }
680
681 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
682 const project = await this.projectFor(a.project, a.viewer, "get");
683 if (!project.ok) return project;
684 const row = await this.db
685 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
686 .bind(a.id, project.value.id)
687 .first<DeploymentRow>();
688 if (!row) return fail("not_found", "No such deployment.");
689 return ok({ ...toDeployment(row), log: row.log });
690 }
691
692 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
693 const found = await this.projectFor(a.project, a.actor, "redeploy");
694 if (!found.ok) return found;
695 const project = found.value;
696 const settings = await this.settingsRow(project.id);
697 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
698 if (a.branch == null) {
699 return notStarted((await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy."));
700 }
701 // A branch's preview comes from its pull request.
702 const app = await this.db
703 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
704 .bind(project.id, a.branch)
705 .first<{ number: number }>();
706 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
707 return notStarted((await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open."));
708 }
709
710 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
711 const project = await this.projectFor(a.project, a.actor, "takeDown");
712 if (!project.ok) return project;
713 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
714 return ok(true);
715 }
716
717 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
718 const workspace = a.workspace.toLowerCase();
719 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
720 // Only the projects whose repositories the viewer can read: the
721 // projects service lists no others.
722 const listed = await this.projects.list(workspace, a.viewer);
723 if (!listed.ok) return listed;
724 const readable = new Set(listed.value.map((project) => project.slug));
725 const [settings, apps, latest] = await Promise.all([
726 // A deleted repository's projects are hidden until it is restored.
727 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ? AND repo_deleted_at IS NULL").bind(workspace).all<{ slug: string; enabled: number }>(),
728 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
729 this.db
730 .prepare(
731 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
732 )
733 .bind(workspace)
734 .all<DeploymentRow>(),
735 ]);
736 return ok(
737 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
738 const own = apps.results.filter((app) => app.slug === row.slug);
739 const production = own.find((app) => app.kind === "production");
740 const newest = latest.results.find((d) => d.slug === row.slug);
741 return {
742 slug: row.slug,
743 enabled: !!row.enabled,
744 production: production ? toLive(production) : null,
745 previews: own.filter((app) => app.kind === "preview").length,
746 latest: newest ? toDeployment(newest) : null,
747 };
748 }),
749 );
750 }
751
752 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
753 const slug = a.workspace.toLowerCase();
754 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
755 const [meter, apps] = await Promise.all([
756 this.db
757 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
758 .bind(slug, month())
759 .first<{
760 requests: number;
761 cpu_ms: number;
762 peak_apps: number;
763 build_seconds: number;
764 build_micros: number;
765 counted_at: string | null;
766 }>(),
767 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
768 ]);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.769 const container = (await this.containerMonth(month())).get(slug);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays770 return ok({
771 month: month(),
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.772 containerSeconds: container?.seconds ?? 0,
773 containerMicros: container?.micros ?? 0,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays774 requests: meter?.requests ?? 0,
775 cpuMs: meter?.cpu_ms ?? 0,
776 apps: apps?.n ?? 0,
777 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
778 buildSeconds: meter?.build_seconds ?? 0,
779 buildMicros: meter?.build_micros ?? 0,
780 countedAt: meter?.counted_at ?? null,
781 });
782 }
783
784 // ---- Custom domains ------------------------------------------------
785
786 async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> {
787 const project = await this.projectFor(a.project, a.viewer, "listDomains");
788 if (!project.ok) return project;
789 const domains = this.domains;
790 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
791 const [rows, available, used, costs] = await Promise.all([
792 domains.forProject(project.value.id),
793 domains.available(),
794 domains.countFor(project.value.workspace),
795 this.costs(),
796 ]);
797 return ok({
798 domains: rows.map(toDomain),
799 target: CUSTOM_DOMAIN_TARGET,
800 available,
801 notice: available ? null : NOT_ENABLED_NOTICE,
802 monthlyMicros: costs.domainMonthPrice,
803 used,
804 });
805 }
806
807 async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> {
808 const found = await this.projectFor(a.project, a.actor, "addDomain");
809 if (!found.ok) return found;
810 const project = found.value;
811 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
812 if (!plan.ok) return plan;
813 const added = await this.domains.add({
814 project: { id: project.id, workspace: project.workspace, slug: project.slug },
815 script: await this.productionScript(project),
816 hostname: String(a.hostname ?? ""),
817 twin: !!a.twin,
818 by: a.actor.username,
819 });
820 if (!added.ok) return fail(added.code, added.message);
821 await this.notePeak(project.workspace);
822 return ok(added.rows.map(toDomain));
823 }
824
825 async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> {
826 const found = await this.projectFor(a.project, a.actor, "removeDomain");
827 if (!found.ok) return found;
828 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
829 if (!row) return fail("not_found", "No such domain.");
830 await this.domains.remove(row);
831 return ok(true);
832 }
833
834 async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> {
835 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
836 if (!found.ok) return found;
837 const domains = this.domains;
838 const row = await domains.byId(found.value.id, String(a.id ?? ""));
839 if (!row) return fail("not_found", "No such domain.");
840 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
841 await this.notePeak(found.value.workspace);
842 return ok(toDomain(after));
843 }
844
845 /** The script production is up under, or the name it will have. */
846 private async productionScript(project: Project): Promise<string> {
847 const app = await this.db
848 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
849 .bind(project.id)
850 .first<{ script: string }>();
851 return app?.script ?? (await this.scriptFor(project, null));
852 }
853
854 // ---- Starting builds -----------------------------------------------
855
856 /**
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9857 * Opens a deployment and starts its build in a sandbox. Skipped, with the
858 * reason recorded, when the workspace's plan is off.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays859 */
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9860 private async start(input: OpenInput & {
861 /** Where the build reads the commit from: the project, or a pull request's fork. */
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays862 source: RepoPath;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9863 /** Who reads it: the workspace, or whoever the pull request is for. */
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays864 reader: User;
865 }): Promise<Result<Deployment>> {
866 const { project } = input;
867 const repo = repoOf(project);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9868 const opened = await this.open(input, { reserve: true });
869 if (opened.refused !== null) return ok(toDeployment((await this.deploymentRow(opened.id))!));
870 const { build, compute } = opened;
871 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
872 method: "POST",
873 headers: { "content-type": "application/json" },
874 body: JSON.stringify({
875 deployId: opened.id,
876 token: opened.token,
877 workspace: project.workspace,
878 reservation: compute.reservation,
879 microsPerSecond: compute.microsPerSecond,
880 maxRunMinutes: compute.maxRunMinutes,
881 actor: input.reader,
882 source: input.source,
883 // The project, whose guardrails the build runs under: a preview's
884 // source is its pull request's working copy, not the project.
885 repo: repo.path,
886 repoId: repo.id,
887 commit: input.commit,
888 rootDir: project.source.rootDir,
889 buildCommand: input.settings.build_command,
890 outputDir: input.settings.output_dir,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.891 buildAs: buildAsOf(input.settings.build_as),
892 dockerfile: input.settings.dockerfile ?? null,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9893 buildEnv: build.variables,
894 buildSecrets: build.secrets,
895 }),
896 });
897 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
898 if (!started.ok) {
899 // Never reached a sandbox: what was reserved is given back.
900 if (compute.reservation) await gateFor(this.env.BILLING).settle(compute.reservation, 0);
901 await this.finishFailed(opened.id, started.error.message, null, null);
902 }
903 return ok(toDeployment((await this.deploymentRow(opened.id))!));
904 }
905
906 /**
907 * What every build does before anything builds it: checks the plan and
908 * the spend limit (and, for a sandbox, reserves its compute with
909 * billing), records the deployment with a token for that build alone,
910 * replaces older builds of the same app, sets the commit's status, and
911 * reads what the project's secrets and variables give builds of its
912 * kind. A refusal is the deployment's status, saying what to do; nothing
913 * is resolved for a refused build.
914 */
915 private async open(input: OpenInput, options: { reserve: boolean }): Promise<Opened> {
916 const { project } = input;
917 const repo = repoOf(project);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays918 const script = await this.scriptFor(project, input.branch);
919 const id = newId("dpl");
920 const token = randomToken();
921 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
922 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9923 const stopped = limit.ok && limit.value.state === "stopped";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays924 const cloudflare = this.cloudflare;
925 let refused = !plan.ok
926 ? plan.error.message
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9927 : stopped
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays928 ? (limit.value.message ?? "The workspace reached its usage limit.")
929 : !cloudflare
930 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
931 : null;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9932 let code: Refusal = plan.ok && !stopped ? "conflict" : "payment_required";
933 // A sandbox's build is compute: reserved with billing before it starts,
934 // and settled by its sandbox when it stops. A workflow job's build runs
935 // in the job, which pays for its own time.
936 const compute: Compute = { reservation: null, microsPerSecond: 0, maxRunMinutes: null };
937 if (!refused && options.reserve) {
938 const gate = gateFor(this.env.BILLING);
939 const ent = await gate.entitlements(project.workspace);
940 compute.microsPerSecond = await gate.microsPerSecond();
941 compute.maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays942 const isPrivate = await reposClient(this.env.REPOS)
943 .get(repo.path, null)
944 .then((found) => !found.ok || found.value.isPrivate)
945 .catch(() => true);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9946 const admitted = await gate.admit(
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays947 {
948 workspace: project.workspace,
949 repo: repo.path,
950 public: !isPrivate,
951 kind: "deploy",
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9952 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, compute.maxRunMinutes ?? BUILD_MINUTES), compute.microsPerSecond),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays953 },
954 ent,
955 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9956 if (admitted.ok) compute.reservation = admitted.reservation?.id ?? null;
957 else {
958 refused = admitted.message;
959 code = "payment_required";
960 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays961 }
962 await this.db
963 .prepare(
964 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9965 script, status, error, token_hash, trusted, created_by, created_at, finished_at, run_id, job_id)
966 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays967 )
968 .bind(
969 id,
970 project.id,
971 project.workspace,
972 project.slug,
973 repo.id,
974 `${repo.path.namespace}/${repo.path.name}`,
975 input.kind,
976 input.branch,
977 input.number,
978 input.commit,
979 script,
980 refused ? "skipped" : "queued",
981 refused,
982 refused ? null : await sha256(token),
983 input.trusted ? 1 : 0,
984 input.createdBy,
985 now(),
986 refused ? now() : null,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9987 input.workflow?.runId ?? null,
988 input.workflow?.jobId ?? null,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays989 )
990 .run();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9991 if (refused) return { refused, code, id };
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays992 // Older builds of the same app are replaced by this one.
993 await this.db
994 .prepare(
995 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
996 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
997 )
998 .bind(now(), script, id)
999 .run();
1000 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-a69aeabc4b0deeb971001 await this.buildChanged(id, true);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1002 // What the project's secrets and variables give builds of this kind.
1003 const build = await this.resolve(
1004 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
1005 input.kind,
1006 input.trusted,
1007 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91008 return { refused: null, id, token, script, build, compute };
1009 }
1010
1011 /**
1012 * For a g1t Actions job (`POST /repos/{owner}/{repo}/deployments/builds`):
1013 * opens a g1t.page build of one of the repository's projects, which the
1014 * job builds in its own checkout with `g1t-runner deploy`. The caller is
1015 * the job's own token (`token.job`), which needs `deployments: write` and
1016 * reaches this repository only. The run decides what is built: a run on
1017 * the project's production branch builds production, a pull request's
1018 * run its preview, a run on another branch that branch's preview. A pull
1019 * request from a fork or from someone who cannot push here, and a run
1020 * that needed approval, get no build secrets. The job pays for its own
1021 * time, so nothing is reserved or charged here for the build; the plan
1022 * and the spend limit are checked as for any build.
1023 */
1024 async startFromWorkflow(a: { actor: User; repo: RepoPath; project?: string | null; sha?: string | null }): Promise<Result<WorkflowBuild>> {
1025 const repoPath = { namespace: String(a.repo?.namespace ?? ""), name: String(a.repo?.name ?? "") };
1026 const job = jobOf(a.actor, repoPath);
1027 if ("error" in job) return fail("forbidden", job.error);
1028 const actor = await this.workspaceActor(repoPath.namespace);
1029 if (!actor) return fail("not_found", "There is no such repository.");
1030 const found = await reposClient(this.env.REPOS).get(repoPath, actor);
1031 if (!found.ok) return fail("not_found", "There is no such repository.");
1032 const projects = await this.projects.byRepo(found.value.id);
1033 const rows = new Map(await Promise.all(projects.map(async (p) => [p.id, await this.settingsRow(p.id)] as const)));
1034 const live = (row: SettingsRow | null | undefined): row is SettingsRow =>
1035 !!row?.enabled && !row.repo_deleted_at && !row.workspace_deleted_at;
1036 const chosen = projectNamed(projects, (p) => live(rows.get(p.id)), a.project);
1037 if ("error" in chosen) return fail(chosen.code, chosen.error);
1038 const project = chosen;
1039 if (project.source.kind !== "hosted") return fail("conflict", "Only projects hosted on g1t deploy so far.");
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.1040 // A token that writes deployments needs the role a redeploy does; one
1041 // that does not is let through below only for a managed preview.
1042 if (job.writes && !can(a.actor, repoRef(project), NEEDS.redeploy)) return fail("forbidden", needs(NEEDS.redeploy));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91043 const settings = rows.get(project.id);
1044 if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`);
1045 const repo = repoOf(project);
1046
1047 // The run and its job, as the actions service has them: still running.
1048 const detail = await actionsClient(this.env.ACTIONS).run(repo.path, actor, job.runId);
1049 if (!detail.ok) return fail("not_found", "The job's run was not found.");
1050 const { run } = detail.value;
1051 const running = detail.value.jobs.find((j) => j.id === job.jobId);
1052 if (run.status === "completed" || !running || running.status === "completed") {
1053 return fail("conflict", "The job has finished: a build is opened from a job while it runs.");
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1054 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91055 const commit = commitOf(a.sha, run);
1056 if (typeof commit !== "string") return fail("invalid", commit.error);
1057
1058 const target = targetOf(run, repo.defaultBranch);
1059 if ("error" in target) return fail("invalid", target.error);
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.1060 if (!job.writes && !readOnlyAllowed(run, target)) return fail("forbidden", NEEDS_WRITE);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91061 let kind: DeployKind = "production";
1062 let branch: string | null = null;
1063 let number: number | null = null;
1064 let trusted = runTrusted({ approval: detail.value.approval, pull: null, insider: true });
1065 if (target.kind === "pull") {
1066 const pull = await workClient(this.env.WORK).getPull(repo.path, target.number, actor);
1067 if (!pull.ok) return fail("not_found", `Pull request #${target.number} was not found.`);
1068 const open = pull.value.pull;
1069 if (open.status !== "open" && open.status !== "draft") {
1070 return fail("conflict", `Pull request #${target.number} is not open, so it has no preview.`);
1071 }
1072 kind = "preview";
1073 // A pull request from a fork has no branch here (see `deployPreview`).
1074 branch = open.branch ?? `pr-${target.number}`;
1075 number = target.number;
1076 trusted = runTrusted({
1077 approval: detail.value.approval,
1078 pull: open,
1079 insider: await this.insider(repo.path, workOwner(open), actor),
1080 });
1081 } else if (target.kind === "branch") {
1082 kind = "preview";
1083 branch = target.branch;
1084 }
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.1085 // A read-only token's build never gets the project's build secrets.
1086 if (!job.writes) trusted = false;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91087 if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`);
1088 if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`);
1089
1090 const opened = await this.open(
1091 { project, kind, branch, number, commit, createdBy: run.actor ?? "g1t", settings, trusted, workflow: job },
1092 { reserve: false },
1093 );
1094 if (opened.refused !== null) return fail(opened.code, opened.refused);
1095 return ok({
1096 deploy_id: opened.id,
1097 token: opened.token,
1098 kind,
1099 project: project.slug,
1100 branch,
1101 number,
1102 commit,
1103 url: appUrl(opened.script),
1104 trusted,
1105 root_dir: project.source.rootDir ?? "",
1106 build_command: settings.build_command,
1107 output_dir: settings.output_dir,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1108 build_as: buildAsOf(settings.build_as),
1109 dockerfile: settings.dockerfile ?? null,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91110 build_env: opened.build.variables,
1111 build_secrets: opened.build.secrets,
1112 });
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1113 }
1114
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1115 /**
1116 * For the actions service, when a workflow job ends (`finish_job`), with
1117 * any conclusion: a build the job opened and did not finish or fail is
1118 * failed now, saying how the job ended, rather than left building until
1119 * the sweep's 45 minutes. A build the job finished is left as it is.
1120 */
1121 async workflowJobEnded(a: { jobId?: unknown; conclusion?: unknown }): Promise<Result<number>> {
1122 if (typeof a.jobId !== "string" || !a.jobId) return fail("invalid", "No job.");
1123 const open = await this.db
1124 .prepare("SELECT id FROM deployments WHERE job_id = ? AND status IN ('queued', 'building')")
1125 .bind(a.jobId)
1126 .all<{ id: string }>();
1127 const message = jobEndedMessage(typeof a.conclusion === "string" ? a.conclusion : "");
1128 for (const { id } of open.results) await this.finishFailed(id, message, null, null);
1129 return ok(open.results.length);
1130 }
1131
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1132 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
1133 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 member1134 if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1135 const actor = await this.workspaceActor(project.workspace);
1136 if (!actor) return null;
1137 const repo = repoOf(project);
1138 let head = commit;
1139 if (!head) {
1140 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
1141 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
1142 }
1143 if (!head) return null;
1144 return this.start({
1145 project,
1146 kind: "production",
1147 branch: null,
1148 number: null,
1149 commit: head,
1150 source: repo.path,
1151 reader: actor,
1152 createdBy,
1153 settings,
1154 // The default branch only moves by people and agents with access.
1155 trusted: true,
1156 });
1157 }
1158
1159 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
1160 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 member1161 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1162 const actor = await this.workspaceActor(project.workspace);
1163 if (!actor) return null;
1164 const repo = repoOf(project);
1165 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
1166 if (!detail.ok) return null;
1167 const { pull } = detail.value;
1168 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
1169 // A pull request from a fork (as g1t's agents work) has no branch here.
1170 const branch = pull.branch ?? `pr-${number}`;
1171 if (!force) {
1172 // Already built, or being built, at this commit.
1173 const same = await this.db
1174 .prepare(
1175 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
1176 AND status IN ('queued', 'building', 'ready')`,
1177 )
1178 .bind(project.id, branch, pull.headCommit)
1179 .first();
1180 if (same) return null;
1181 }
1182 return this.start({
1183 project,
1184 kind: "preview",
1185 branch,
1186 number,
1187 commit: pull.headCommit,
1188 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 rights1189 // The pull request's fork may be private: read it as whoever it is
1190 // for (whoever asked g1t for it, or its author), who is also who is
1191 // trusted or not with the project's secrets.
1192 reader: workOwner(pull),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1193 createdBy,
1194 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1195 trusted: await this.insider(repo.path, workOwner(pull), actor),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1196 });
1197 }
1198
1199 // ---- A build's reports ---------------------------------------------
1200
1201 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
1202 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
1203 }
1204
1205 /** The build, if `token` is its own and it is still under way. */
1206 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
1207 const row = await this.deploymentRow(id);
1208 if (!row?.token_hash || typeof token !== "string") return null;
1209 if (row.token_hash !== (await sha256(token))) return null;
1210 return row.status === "queued" || row.status === "building" ? row : null;
1211 }
1212
1213 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
1214 // Each report, for the logs: a build's own failure says why.
1215 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
1216 const row = await this.building(id, body.token);
1217 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
1218 const cloudflare = this.cloudflare;
1219 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
1220 switch (step) {
1221 case "started":
1222 await this.db
1223 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
1224 .bind(now(), id)
1225 .run();
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971226 await this.buildChanged(id);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1227 return Response.json(ok(true));
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1228 case "registry": {
1229 // Where a container build pushes its image: its app's repository,
1230 // with a login that can push and nothing else, for minutes.
1231 if (!this.containersOn) {
1232 // Its own code, which the build reads to build the site the
1233 // project is without its Dockerfile, when it is one.
1234 const error = { code: "not_enabled", message: "Docker deployments are not switched on for g1t.page yet." };
1235 return Response.json({ ok: false, error }, { status: 409 });
1236 }
1237 const instanceType = instanceTypeOf((await this.settingsRow(row.project_id))?.instance_type);
1238 const login = await cloudflare.registryLogin();
1239 return Response.json(
1240 ok({
1241 registry: REGISTRY_HOST,
1242 repository: repositoryFor(this.env.CLOUDFLARE_ACCOUNT_ID, row.script),
1243 tag: row.id,
1244 username: login.username,
1245 password: login.password,
1246 maxImageBytes: maxImageBytes(instanceType),
1247 instanceType,
1248 }),
1249 );
1250 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1251 case "session": {
1252 const manifest = body.manifest as Manifest | undefined;
1253 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
1254 const session = await cloudflare.openUpload(row.script, manifest);
1255 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
1256 }
1257 case "finish": {
1258 const worker = (body.worker ?? {}) as BuiltWorker;
1259 const seconds = Number(body.buildSeconds) || 0;
1260 const [namespace, name] = row.repo.split("/") as [string, string];
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1261 const detected = detectedKind(body.detected);
1262 const tags = [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind];
1263 let container: { appId: string; instanceType: string; meterHash: string; image: string } | null = null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1264 try {
1265 // Running apps' secrets and variables are bound here, by g1t:
1266 // they never pass through the build's sandbox.
1267 const runtime = await this.resolve(
1268 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1269 row.kind,
1270 !!row.trusted,
1271 );
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1272 const before = await this.db.prepare("SELECT runtime, container_app_id FROM apps WHERE script = ?").bind(row.script).first<AppRow>();
1273 if (detected === "container") {
1274 container = await this.runContainer(cloudflare, row, body.container, runtime, tags);
1275 } else {
1276 // A container app that is now a site: its container goes first,
1277 // and its Worker, whose class the site's does not have.
1278 if (before?.runtime === "container") {
1279 await cloudflare.deleteContainer(row.script, before.container_app_id);
1280 await cloudflare.deleteScript(row.script);
1281 }
1282 await cloudflare.putScript(row.script, worker, typeof body.completionJwt === "string" ? body.completionJwt : null, tags, runtime);
1283 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1284 } catch (error) {
1285 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1286 return Response.json(ok(false));
1287 }
1288 const at = now();
1289 const wasRedirect = await this.db.prepare("SELECT 1 FROM redirects WHERE script = ?").bind(row.script).first();
1290 await this.db.batch([
1291 this.db
1292 .prepare(
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1293 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?,
1294 framework = ?, image = ?
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1295 WHERE id = ?`,
1296 )
1297 .bind(
1298 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1299 String(body.log ?? ""),
1300 seconds,
1301 at,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1302 detected,
1303 frameworkId(body.framework),
1304 container?.image ?? null,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1305 id,
1306 ),
1307 // The build it replaces is no longer what the app serves.
1308 this.db
1309 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1310 .bind(row.script, id),
1311 this.db
1312 .prepare(
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1313 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at,
1314 runtime, container_app_id, instance_type, meter_hash)
1315 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, ?11, ?12, ?13)
1316 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL,
1317 runtime = ?10, container_app_id = ?11, instance_type = ?12, meter_hash = ?13`,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1318 )
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1319 .bind(
1320 row.script,
1321 row.project_id,
1322 row.workspace,
1323 row.slug,
1324 row.kind,
1325 row.branch,
1326 row.number,
1327 row.commit_sha,
1328 at,
1329 container ? "container" : "worker",
1330 container?.appId ?? null,
1331 container?.instanceType ?? null,
1332 container?.meterHash ?? null,
1333 ),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1334 // A name that redirected elsewhere (a move undone) is an app again.
1335 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
1336 ]);
1337 if (wasRedirect) {
1338 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1339 }
1340 // The same app at an older name (its project moved) now redirects
1341 // here; it stays up as it was if the redirect cannot be put.
1342 await this.supersede(cloudflare, row, row.script);
1343 // The project's own domains serve production wherever it is up.
1344 if (row.kind === "production") {
1345 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1346 }
1347 await this.chargeBuild(row, seconds);
1348 await this.notePeak(row.workspace);
1349 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 published1350 await this.announce(row, null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971351 await this.buildChanged(id);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1352 // A screenshot of production as it now is, for the project's overview.
1353 if (row.kind === "production") {
1354 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1355 console.error("could not ask for a screenshot", error),
1356 );
1357 }
1358 return Response.json(ok(true));
1359 }
1360 case "fail":
1361 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1362 return Response.json(ok(true));
1363 default:
1364 return Response.json(fail("not_found", "No such step."), { status: 404 });
1365 }
1366 }
1367
1368 /**
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1369 * Runs a container build's image as its app: checks it is an image in
1370 * the app's own repository, by digest, that the registry has; puts the
1371 * app's Worker (g1t's, `APP_WORKER`) in the namespace with the project's
1372 * entries and a new token to report its running time with; then makes
1373 * or rolls out its Container application at the project's size.
1374 */
1375 private async runContainer(
1376 cloudflare: Cloudflare,
1377 row: DeploymentRow,
1378 report: unknown,
1379 runtime: { secrets: Record<string, string>; variables: Record<string, string> },
1380 tags: string[],
1381 ): Promise<{ appId: string; instanceType: string; meterHash: string; image: string }> {
1382 if (!this.containersOn) throw new Error("Docker deployments are not switched on for g1t.page yet.");
1383 const repository = repositoryFor(this.env.CLOUDFLARE_ACCOUNT_ID, row.script);
1384 const pushed = pushedImage(report, repository);
1385 if (!pushed) throw new Error(`the build did not report an image by digest in ${REGISTRY_HOST}/${repository}.`);
1386 if (!(await cloudflare.hasImage(repository, pushed.digest))) throw new Error(`${pushed.image} is not in the registry.`);
1387 const instanceType = instanceTypeOf((await this.settingsRow(row.project_id))?.instance_type);
1388 const meterToken = randomToken();
1389 const settings: AppWorkerSettings = { mode: "serve", port: pushed.port, instanceType, meterUrl: this.meterUrl(row.script), meterToken };
1390 await cloudflare.putContainerWorker(row.script, appWorkerMetadata(settings, runtime, [...tags, "container"]));
1391 const appId = await cloudflare.runContainer(row.script, pushed.image, instanceType, `${row.id}: ${row.commit_sha.slice(0, 7)}`);
1392 return { appId, instanceType, meterHash: await sha256(meterToken), image: pushed.image };
1393 }
1394
1395 /** Where a container app's Worker reports its running time: the API, which forwards it here. */
1396 private meterUrl(script: string): string {
1397 return `${(this.env.G1T_API ?? "https://api.g1t.sh").replace(/\/+$/, "")}/deployments/apps/${script}/meter`;
1398 }
1399
1400 /**
1401 * A container app held as it is, without serving: paused (its
1402 * workspace's limit), or redirecting to where its project moved. Its
1403 * Worker keeps its class, so its container stays attached and stops.
1404 */
1405 private async holdContainer(cloudflare: Cloudflare, script: string, mode: "paused" | "redirect", redirect?: string): Promise<void> {
1406 const app = await this.db.prepare("SELECT instance_type FROM apps WHERE script = ?").bind(script).first<{ instance_type: string | null }>();
1407 const meterToken = randomToken();
1408 const settings: AppWorkerSettings = {
1409 mode,
1410 redirect,
1411 port: 8080,
1412 instanceType: instanceTypeOf(app?.instance_type),
1413 meterUrl: this.meterUrl(script),
1414 meterToken,
1415 };
1416 await cloudflare.putContainerWorker(script, appWorkerMetadata(settings, { secrets: {}, variables: {} }, [mode, "container"]));
1417 await this.db.prepare("UPDATE apps SET meter_hash = ? WHERE script = ?").bind(await sha256(meterToken), script).run();
1418 }
1419
1420 /** Whether `script` is a container app. */
1421 private async isContainer(script: string): Promise<boolean> {
1422 const app = await this.db.prepare("SELECT runtime FROM apps WHERE script = ?").bind(script).first<{ runtime: string | null }>();
1423 return app?.runtime === "container";
1424 }
1425
1426 /**
1427 * A container app's Worker reporting how long its container has run this
1428 * month and what it sent (`POST /deployments/apps/<script>/meter`), with
1429 * the token it was deployed with. Each counter is only ever raised.
1430 */
1431 async meter(script: string, body: unknown): Promise<Response> {
1432 const report = meterReport(body);
1433 if (!report) return Response.json(fail("invalid", "Not a report."), { status: 400 });
1434 const app = await this.db
1435 .prepare("SELECT workspace, meter_hash FROM apps WHERE script = ? AND runtime = 'container'")
1436 .bind(script)
1437 .first<{ workspace: string; meter_hash: string | null }>();
1438 if (!app?.meter_hash || app.meter_hash !== (await sha256(report.token))) {
1439 return Response.json(fail("not_found", "No such app."), { status: 404 });
1440 }
1441 await this.db
1442 .prepare(
1443 `INSERT INTO container_meters (script, epoch, month, workspace, instance_type, awake_seconds, egress_bytes, reported_at)
1444 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1445 ON CONFLICT (script, epoch, month) DO UPDATE SET
1446 awake_seconds = MAX(awake_seconds, ?6), egress_bytes = MAX(egress_bytes, ?7), reported_at = ?8`,
1447 )
1448 .bind(script, report.epoch, report.month, app.workspace, report.instanceType, report.awakeSeconds, report.egressBytes, now())
1449 .run();
1450 return Response.json(ok(true));
1451 }
1452
1453 /** What each workspace's containers ran in `month`, and what that cost g1t. */
1454 private async containerMonth(month: string): Promise<Map<string, { micros: number; seconds: number; detail: string | null }>> {
1455 const costs = (await this.costs()).container;
1456 const rows = await this.db
1457 .prepare("SELECT workspace, instance_type, awake_seconds, egress_bytes FROM container_meters WHERE month = ?")
1458 .bind(month)
1459 .all<ContainerMeter & { workspace: string }>()
1460 .catch(() => ({ results: [] as (ContainerMeter & { workspace: string })[] }));
1461 const by = new Map<string, ContainerMeter[]>();
1462 for (const row of rows.results) by.set(row.workspace, [...(by.get(row.workspace) ?? []), row]);
1463 return new Map([...by].map(([workspace, own]) => [workspace, containerCost(own, costs)]));
1464 }
1465
1466 /**
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1467 * Older names of the app `script`, now up: one project's production, or
1468 * its preview of one branch, has one name, so any other app row for the
1469 * same is the app under a name it had before its project moved (its
1470 * workspace renamed, its repository renamed or transferred). Each is
1471 * replaced in the namespace by a redirect to the new name, its app row
1472 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1473 * the sweep to hold the old script) and in `DOMAINS` under the old
1474 * hostname, which the dispatcher follows before running anything, so the
1475 * old address redirects even if the old script is paused. A name that
1476 * cannot be redirected is left as it is, for the next deploy or sweep.
1477 */
1478 private async supersede(
1479 cloudflare: Cloudflare,
1480 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1481 script: string,
1482 ): Promise<string[]> {
1483 const older = await this.db
1484 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
1485 .bind(app.project_id, app.kind, app.branch, script)
1486 .all<{ script: string }>();
1487 const target = appHost(script);
1488 const done: string[] = [];
1489 for (const { script: old } of older.results) {
1490 try {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1491 if (await this.isContainer(old)) await this.holdContainer(cloudflare, old, "redirect", target);
1492 else await cloudflare.redirectScript(old, target);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1493 } catch (error) {
1494 console.error("could not redirect", old, "to", script, error);
1495 continue;
1496 }
1497 const at = now();
1498 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1499 await this.db.batch([
1500 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1501 this.db
1502 .prepare(
1503 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1504 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1505 )
1506 .bind(old, target, app.workspace, at, expires),
1507 // Its builds are no longer live under the old name.
1508 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1509 ]);
1510 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1511 expiration: Math.floor(Date.parse(expires) / 1000),
1512 }).catch((error) => console.error("could not record redirect", old, error));
1513 done.push(old);
1514 }
1515 return done;
1516 }
1517
1518 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1519 const row = await this.deploymentRow(id);
1520 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1521 // A commit that is gone says so plainly; git's own words stay in the log.
1522 if (commitMissing(message)) {
1523 log = log ?? message;
1524 message = missingCommitMessage(row);
1525 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1526 await this.db
1527 .prepare(
1528 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1529 WHERE id = ?`,
1530 )
1531 .bind(message.slice(0, 2000), log, seconds, now(), id)
1532 .run();
1533 // A failed build still used its sandbox.
1534 if (seconds) await this.chargeBuild(row, seconds);
1535 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1536 await this.announce(row, message.slice(0, 300));
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971537 await this.buildChanged(id);
Events: review requests, assignments, stops and deployments are published1538 }
1539
1540 /**
1541 * Publishes a finished build: `deployment.failed` with what went wrong,
1542 * or `deployment.succeeded`, saying whether the build of the same app
1543 * before it failed. Whoever started it is the actor when it was a
1544 * person known by id; otherwise `triggeredBy` names them, or g1t.
1545 */
1546 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1547 if (!this.env.EVENTS) return;
1548 const previous = error
1549 ? null
1550 : await this.db
1551 .prepare(
1552 `SELECT status FROM deployments
1553 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1554 ORDER BY created_at DESC LIMIT 1`,
1555 )
1556 .bind(row.script, row.id, row.created_at)
1557 .first<{ status: string }>();
1558 const byId = row.created_by.startsWith("usr_");
1559 const event = {
1560 source: "deployments",
1561 repoId: row.repo_id,
1562 actor: byId ? row.created_by : null,
1563 data: {
1564 deploymentId: row.id,
1565 projectId: row.project_id,
1566 repoId: row.repo_id,
1567 workspace: row.workspace,
1568 project: row.slug,
1569 kind: row.kind,
1570 branch: row.branch,
1571 number: row.kind === "preview" ? row.number : null,
1572 commit: row.commit_sha,
1573 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1574 error,
1575 recovered: previous?.status === "failed",
1576 triggeredBy: byId ? "" : row.created_by,
1577 },
1578 };
1579 await eventsClient(this.env.EVENTS)
1580 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1581 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1582 }
1583
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91584 /**
1585 * Each build is charged by the second, from the first, at the container
1586 * price plus the margin. A build a workflow job ran is not: the job's
1587 * time is charged as Actions time.
1588 */
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1589 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91590 if (row.run_id) return;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1591 const costs = await this.costs();
1592 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
1593 if (cost <= 0) return;
1594 const what =
1595 row.kind === "preview"
1596 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1597 : `${row.workspace}/${row.slug} to production`;
1598 await billingClient(this.env.BILLING).chargeFeature({
1599 workspace: row.workspace,
1600 feature: "deployments",
1601 costMicros: cost,
1602 description: `Building ${what} (${Math.ceil(seconds)} s)`,
1603 repo: row.repo,
1604 reference: `deploy/${row.id}`,
1605 // Every second is metered; billing prices it from its price book and
1606 // tallies the month's build time.
1607 buildSeconds: Math.ceil(seconds),
1608 });
1609 await this.db
1610 .prepare(
1611 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1612 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1613 )
1614 .bind(row.workspace, month(), Math.ceil(seconds), cost)
1615 .run();
1616 }
1617
1618 /**
1619 * What each unit costs g1t now, from billing's price book, which follows
1620 * what Cloudflare bills. The plan's figures if billing cannot say.
1621 */
1622 private async costs(): Promise<{
1623 buildSecond: number;
1624 millionRequests: number;
1625 millionCpuMs: number;
1626 domainMonth: number;
1627 /** What one custom domain is charged a month: the cost plus the margin. */
1628 domainMonthPrice: number;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1629 /** Container apps' parts, from the price book's `app_container_*` meters. */
1630 container: ContainerCosts;
1631 /** The margin container apps are sold at, in percent: the price book's. */
1632 marginPercent: number;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1633 }> {
1634 const a = DEPLOYMENT_COSTS;
1635 const book = await billingClient(this.env.BILLING)
1636 .prices()
1637 .catch(() => null);
1638 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1639 const vcpu = book?.prices.find((p) => p.meter === "app_container_vcpu_second");
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1640 return {
1641 buildSecond: cost("build_second", a.microsPerBuildSecond),
1642 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1643 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1644 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
1645 domainMonthPrice:
1646 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1647 container: {
1648 vcpuSecond: cost("app_container_vcpu_second", a.container.vcpuSecond),
1649 memoryGibSecond: cost("app_container_memory_gib_second", a.container.memoryGibSecond),
1650 diskGbSecond: cost("app_container_disk_gb_second", a.container.diskGbSecond),
1651 instanceSecond: cost("app_container_second", a.container.instanceSecond),
1652 egressGb: cost("app_container_egress_gb", a.container.egressGb),
1653 },
1654 marginPercent:
1655 vcpu?.priceMicros && vcpu.costMicros ? Math.round((vcpu.priceMicros / vcpu.costMicros - 1) * 100) : 20,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1656 };
1657 }
1658
1659 /** Remembers the most apps (for information; never charged) and custom domains the workspace had at once this month. */
1660 private async notePeak(workspace: string): Promise<void> {
1661 await this.db
1662 .prepare(
1663 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1664 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1665 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
1666 ON CONFLICT (namespace, month) DO UPDATE SET
1667 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1668 peak_domains = MAX(peak_domains, (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))`,
1669 )
1670 .bind(workspace, month())
1671 .run();
1672 }
1673
1674 /**
1675 * The check on the commit: `g1t / deploy`, or, for one of several
1676 * projects on a repository, `g1t / deploy (<project>)`.
1677 */
1678 private async status(
1679 repoId: string,
1680 sha: string,
1681 project: { slug: string; primary: boolean },
1682 state: string,
1683 description: string,
1684 targetUrl: string,
1685 ): Promise<void> {
1686 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
1687 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1688 method: "POST",
1689 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1690 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1691 }).catch(() => undefined);
1692 }
1693
1694 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1695 const projects = await this.projects.byRepo(row.repo_id);
1696 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1697 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1698 }
1699
1700 // ---- Taking apps down ----------------------------------------------
1701
1702 private async removeApp(script: string): Promise<void> {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1703 const app = await this.db
1704 .prepare("SELECT runtime, container_app_id FROM apps WHERE script = ?")
1705 .bind(script)
1706 .first<{ runtime: string | null; container_app_id: string | null }>();
1707 // A container app's container goes first, with any instance it has up.
1708 if (app?.runtime === "container") await this.cloudflare?.deleteContainer(script, app.container_app_id);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1709 await this.cloudflare?.deleteScript(script);
1710 await this.db.batch([
1711 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1712 // Its build is no longer live anywhere.
1713 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1714 ]);
1715 }
1716
1717 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
1718 const apps = await this.db
1719 .prepare(
1720 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
1721 )
1722 .bind(projectId, kind, branch ?? null)
1723 .all<{ script: string }>();
1724 for (const app of apps.results) await this.removeApp(app.script);
1725 }
1726
1727 // ---- Events --------------------------------------------------------
1728
1729 /** `attempts`: which delivery of the event this is, from 1. */
1730 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
1731 switch (event.type) {
1732 case "workspace.renamed":
1733 await this.renamed(event.data, attempts);
1734 break;
1735 case "repo.transferred":
1736 case "repo.renamed":
1737 await this.moved(repoMove(event)!, attempts);
1738 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1739 // A repository that went or came back with its workspace is the
1740 // workspace's to handle: its apps are paused, not taken down.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1741 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1742 if (!event.data.withWorkspace) await this.repoDeleted(event.data.repoId);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1743 break;
1744 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1745 if (!event.data.withWorkspace) await this.repoRestored(event.data.repoId, attempts);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1746 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1747 case "workspace.deleting":
1748 await this.workspaceDeleting(event.data.slug);
1749 break;
1750 case "workspace.restored":
1751 await this.workspaceRestored(event.data.slug);
1752 break;
1753 case "workspace.deleted":
1754 await this.workspacePurged(event.data.slug);
1755 break;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1756 case "repo.purged":
1757 await this.repoPurged(event.data.repoId);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971758 await this.repoDeployments.purge(event.data.repoId);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1759 break;
1760 case "repo.default_branch_changed":
1761 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1762 break;
1763 case "branch.renamed":
1764 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1765 break;
1766
1767 case "pull.opened":
1768 case "pull.ready":
1769 case "pull.updated":
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1770 case "pull.reopened":
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1771 for (const project of await this.projects.byRepo(event.data.repoId)) {
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.1772 // Its managed deploy workflow builds it instead (`syncManagedDeploy`).
1773 if (await this.buildsViaWorkflow(project.id)) continue;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1774 await this.deployPreview(project, event.data.number, "g1t");
1775 }
1776 break;
1777 case "pull.closed":
1778 case "pull.merged":
1779 for (const project of await this.projects.byRepo(event.data.repoId)) {
1780 const apps = await this.db
1781 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1782 .bind(project.id, event.data.number)
1783 .all<{ script: string }>();
1784 for (const app of apps.results) await this.removeApp(app.script);
1785 }
1786 break;
1787 case "git.push":
1788 if (!event.data.defaultBranch) break;
Merge branch 'mirroring' into artifacts-mode1789 // A mirror deploys only while g1t leads it: standing by, or in CI
1790 // failover, the remote's own deploys stand (see contracts mirrors).
1791 if (!mirrorWritable(event.data.mirror)) break;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1792 for (const project of await this.projects.byRepo(event.data.repoId)) {
A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.1793 if (await this.buildsViaWorkflow(project.id)) continue;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1794 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1795 }
1796 break;
1797 }
1798 }
1799
1800 /**
1801 * A workspace's slug changed, and with it every app's name: production
1802 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1803 *
1804 * Its rows move to the slug it has now (asked of identity, so a delivery
1805 * twice over, or an older rename after a newer one, ends the same), and
1806 * each app (paused or not) is built again from the same commit under its
1807 * new name; see `followMoves`. The old name keeps serving the app until
1808 * the new one is live; then it redirects to the new name (see
1809 * `supersede`), held for as long as the workspace holds its old slug.
1810 * Builds under way for the old name are dropped and started again under
1811 * the new one.
1812 */
1813 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1814 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1815 const stale = staleSlugs(renamed, current);
1816 if (stale.length === 0) return;
1817 const marks = stale.map(() => "?").join(", ");
1818
1819 // The workspace's projects, under any of its names: a second delivery
1820 // finds them under the new one, with whatever is left to do.
1821 const rows = await this.db
1822 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1823 .bind(...stale, current)
1824 .all<{ project_id: string }>();
1825 const projectIds = rows.results.map((row) => row.project_id);
1826 const projects = await this.projectsById(projectIds);
1827 // The projects service hears of the rename on its own queue: wait for
1828 // it a few deliveries, so the builds read the repository by its new name.
1829 const behind = [...projects.values()].some((p) => p.workspace !== current);
1830 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
1831
1832 // Every row moves at once.
1833 const at = now();
1834 const statements: D1PreparedStatement[] = [];
1835 for (const slug of stale) {
1836 statements.push(
1837 this.db
1838 .prepare(
1839 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
1840 )
1841 .bind(RENAMED_ERROR, at, slug),
1842 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1843 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1844 this.db
1845 .prepare(
1846 `UPDATE deployments SET workspace = ?1,
1847 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1848 WHERE workspace = ?2`,
1849 )
1850 .bind(current, slug),
1851 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1852 this.domains.rename(slug, current),
1853 // Counters add up; the peak is the higher; a month charged stays charged.
1854 this.db
1855 .prepare(
1856 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1857 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1858 FROM meters WHERE namespace = ?2
1859 ON CONFLICT (namespace, month) DO UPDATE SET
1860 requests = requests + excluded.requests,
1861 cpu_ms = cpu_ms + excluded.cpu_ms,
1862 peak_apps = MAX(peak_apps, excluded.peak_apps),
1863 peak_domains = MAX(peak_domains, excluded.peak_domains),
1864 build_seconds = build_seconds + excluded.build_seconds,
1865 build_micros = build_micros + excluded.build_micros,
1866 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1867 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1868 )
1869 .bind(current, slug),
1870 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1871 );
1872 }
1873 await this.db.batch(statements);
1874
Projects no longer depend on each other: the dependsOn relation, its settings page (old links redirect), the overview card, a pull request's Affects panel and preview stacks, reference variables and agents' notes on what a project uses are gone; the table is dropped in a later deploy1875 // Each app again, under its new name.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1876 const followed = await this.followMoves(projectIds, {
1877 projects,
1878 adjust: (project) => this.underSlug(project, current, stale),
1879 });
1880 if (followed.failed.length > 0) {
1881 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
1882 }
1883 }
1884
1885 /**
1886 * A repository moved: transferred to another workspace, and its projects
1887 * with it, or renamed within its own, when its own project's slug follows
1888 * its name (see the projects service). Each app's name is
1889 * `<project>-<workspace>`, so the apps of every project whose workspace or
1890 * slug changed (production and every preview, paused or not) are built
1891 * again, from the same commit, under the new name; see `followMoves`. As
1892 * after a workspace rename, the old name keeps serving until the new one
1893 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1894 * The project's custom domains follow its production. Builds under way
1895 * are started again under the new name. What the apps used this month
1896 * stays on the old workspace's meter; from now on, the new workspace's
1897 * counts it.
1898 *
1899 * Projects whose name did not change (a rename where the new name was
1900 * taken, or a project of another name) only learn the repository's new
1901 * path. What is left to rebuild is read from the rows each time, so a
1902 * second or late delivery builds only what the first could not, and one
1903 * whose rebuild could not be queued is delivered again (and, past the
1904 * queue's retries, followed up by the sweep).
1905 */
1906 private async moved(move: RepoMove, attempts: number): Promise<void> {
1907 const current = await currentMovedPath(this.env.REPOS, move);
1908 if (staleMovedPaths(move, current).length === 0) return;
1909 const [workspace, name] = current.split("/") as [string, string];
1910 const settings = await this.db
1911 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1912 .bind(move.repoId)
1913 .all<{ project_id: string; workspace: string; slug: string }>();
1914 if (settings.results.length === 0) return;
1915
1916 // The projects service hears of the move on its own queue: wait for it
1917 // a few deliveries, so builds read the project where and as it is now.
1918 const projects = new Map<string, Project>();
1919 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1920 const behind = settings.results.some(({ project_id }) => {
1921 const project = projects.get(project_id);
1922 if (!project || project.source.kind !== "hosted") return false;
1923 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1924 });
1925 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1926
1927 // Its history goes with it, as the repository's issues do.
1928 const statements: D1PreparedStatement[] = [
1929 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1930 ];
1931 // The projects whose apps' names change, and have not been moved yet.
1932 const moving = settings.results
1933 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1934 .map((row) => row.project_id);
1935 if (moving.length > 0) {
1936 const ids = moving.map(() => "?").join(", ");
1937 statements.push(
1938 this.db
1939 .prepare(
1940 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1941 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1942 )
1943 .bind(MOVED_ERROR, now(), ...moving),
1944 );
1945 }
1946 for (const projectId of moving) {
1947 const slug = projects.get(projectId)?.slug ?? null;
1948 statements.push(
1949 this.db
1950 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1951 .bind(workspace, slug, projectId),
1952 this.db
1953 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1954 .bind(workspace, slug, projectId),
1955 this.db
1956 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1957 .bind(workspace, slug, projectId),
1958 // Within the workspace its apps are listed under the project's name
1959 // now. One left behind in another workspace stays as it is until the
1960 // new name is live and redirects it.
1961 this.db
1962 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1963 .bind(workspace, slug, projectId),
1964 );
1965 }
1966 await this.db.batch(statements);
1967
1968 // Each app again, under its new name. App rows under the old name stay
1969 // until the new one is live, which redirects them.
1970 const followed = await this.followMoves(
1971 settings.results.map((row) => row.project_id),
1972 {
1973 projects,
1974 adjust: (found) =>
1975 found.source.kind === "hosted"
1976 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1977 : { ...found, workspace },
1978 },
1979 );
1980 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1981 }
1982
1983 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1984 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1985 const projects = new Map<string, Project>();
1986 if (projectIds.length === 0) return projects;
1987 const repoIds = new Set<string>();
1988 for (let i = 0; i < projectIds.length; i += 50) {
1989 const chunk = projectIds.slice(i, i + 50);
1990 const rows = await this.db
1991 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1992 .bind(...chunk)
1993 .all<{ repo_id: string }>();
1994 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1995 }
1996 const wanted = new Set(projectIds);
1997 for (const repoId of repoIds) {
1998 for (const project of await this.projects.byRepo(repoId)) {
1999 if (wanted.has(project.id)) projects.set(project.id, project);
2000 }
2001 }
2002 return projects;
2003 }
2004
2005 /** Whether `script` is the name an app of the project has where the project is now. */
2006 private async namedNow(
2007 script: string,
2008 settings: { project_id: string; workspace: string; slug: string },
2009 branch: string | null,
2010 ): Promise<boolean> {
2011 const base = await label(settings.workspace, settings.slug, branch);
2012 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
2013 }
2014
2015 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
2016 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
2017 const stale: AppRow[] = [];
2018 for (const app of apps) {
2019 const row = settings.get(app.project_id);
2020 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
2021 }
2022 return stale;
2023 }
2024
2025 /**
2026 * Brings the apps of the projects `projectIds` (every project when null)
2027 * under the names they have where the projects are now: each app still
2028 * under an older name, paused or not, and each build a move dropped, is
2029 * built again under its new name from the same commit, and once that is
2030 * live the old name redirects to it (`supersede`).
2031 *
2032 * Idempotent, and safe to run again at any time: an app already up under
2033 * its new name only has its old names redirected; one whose rebuild is
2034 * queued or under way is left to it; one whose workspace has no
2035 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2036 * each time; one whose commit is gone, or whose rebuild failed the same
2037 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
2038 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
2039 * doubled for each failure, waits. Returns what could not be queued, for an
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2040 * event's delivery to be retried.
2041 */
2042 private async followMoves(
2043 projectIds: string[] | null,
2044 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
2045 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
2046 const result = { queued: 0, waiting: 0, failed: [] as string[] };
2047 if (projectIds && projectIds.length === 0) return result;
2048 const cloudflare = this.cloudflare;
2049 if (!cloudflare) return result;
2050
2051 const settingsRows =
2052 projectIds == null
2053 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
2054 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
2055 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
2056 if (settings.size === 0) return result;
2057 const ids = [...settings.keys()];
2058
2059 const apps: AppRow[] = [];
2060 const dropped: DroppedBuild[] = [];
2061 for (let i = 0; i < ids.length; i += 50) {
2062 const chunk = ids.slice(i, i + 50);
2063 const marks = chunk.map(() => "?").join(", ");
2064 const [appRows, droppedRows] = await Promise.all([
2065 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
2066 // Builds a move dropped, that nothing has been built in place of since.
2067 this.db
2068 .prepare(
2069 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
2070 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
2071 AND NOT EXISTS (
2072 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
2073 AND n.created_at > d.created_at AND n.status != 'skipped'
2074 )`,
2075 )
2076 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
2077 .all<DeploymentRow>(),
2078 ]);
2079 apps.push(...appRows.results);
2080 dropped.push(...droppedRows.results);
2081 }
2082 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
2083 if (targets.length === 0) return result;
2084
2085 const projects = new Map(options.projects ?? []);
2086 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
2087 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
2088 const billing = billingClient(this.env.BILLING);
2089 const open = new Map<string, boolean>();
2090 const actors = new Map<string, User | null>();
2091
2092 for (const target of targets) {
2093 const row = settings.get(target.projectId)!;
2094 const found = projects.get(target.projectId);
2095 if (!found) continue;
2096 const project = options.adjust ? options.adjust(found) : found;
2097 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
2098 // Built only where deployments has the project now; the projects
2099 // service catches up on its own queue, and a later run builds then.
2100 if (project.workspace !== row.workspace || project.slug !== row.slug) {
2101 result.waiting++;
2102 continue;
2103 }
2104 try {
2105 const script = await this.scriptFor(project, target.branch);
2106 // Already up under its new name: only its old names are left to redirect.
2107 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
2108 if (up) {
2109 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
2110 continue;
2111 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2112 const history = await this.recentBuilds(script);
2113 const last = history[0];
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2114 if (last && (last.status === "queued" || last.status === "building")) {
2115 result.queued++;
2116 continue;
2117 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2118 // A commit that is gone, or a build that failed the same way a few
2119 // times, is not tried again; a push or a redeploy builds it.
2120 // Otherwise the sweep waits longer after each failure.
2121 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff: options.backoff }).kind !== "build") {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2122 result.waiting++;
2123 continue;
2124 }
2125 // A workspace without Deployments, or over its limit, waits for it
2126 // rather than gathering refused deployments.
2127 if (!open.has(project.workspace)) {
2128 const [plan, limit] = await Promise.all([
2129 billing.hasFeature(project.workspace, "deployments"),
2130 billing.checkLimit(project.workspace),
2131 ]);
2132 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
2133 }
2134 if (!open.get(project.workspace)) {
2135 result.waiting++;
2136 continue;
2137 }
2138 if (!actors.has(project.workspace)) actors.set(project.workspace, await this.workspaceActor(project.workspace));
2139 const started =
2140 target.kind === "production"
2141 ? await this.deployProduction(project, target.commit, "g1t")
2142 : target.number != null
2143 ? await this.deployPreview(project, target.number, "g1t", true)
2144 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
2145 const outcome = rebuildOutcome(started);
2146 if (outcome === "queued") result.queued++;
2147 else if (outcome === "failed") {
2148 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
2149 result.failed.push(`${named}: ${why}`);
2150 }
2151 } catch (error) {
2152 result.failed.push(`${named}: ${String(error)}`);
2153 }
2154 }
2155 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
2156 return result;
2157 }
2158
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2159 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
2160 private async recentBuilds(script: string): Promise<PastBuild[]> {
2161 const rows = await this.db
2162 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
2163 .bind(script, MAX_IDENTICAL_FAILURES)
2164 .all<PastBuild>();
2165 return rows.results;
2166 }
2167
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2168 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
2169 private async projectIdsFor(repoId: string): Promise<string[]> {
2170 const rows = await this.db
2171 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
2172 .bind(repoId)
2173 .all<{ project_id: string }>();
2174 return rows.results.map((row) => row.project_id);
2175 }
2176
2177 /**
2178 * A repository was deleted, restorable for a while: every app of its
2179 * projects (production and previews) comes down, builds under way are
2180 * dropped, and nothing builds for it until it is restored. Its settings
2181 * and custom domains are kept for the restore; until then a domain has
2182 * nothing up to serve, as when production is turned off.
2183 */
2184 private async repoDeleted(repoId: string): Promise<void> {
2185 const projectIds = await this.projectIdsFor(repoId);
2186 if (projectIds.length === 0) return;
2187 const at = now();
2188 await this.db.batch([
2189 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
2190 this.db
2191 .prepare(
2192 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
2193 WHERE repo_id = ? AND status IN ('queued', 'building')`,
2194 )
2195 .bind(at, repoId),
2196 ]);
2197 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
2198 }
2199
2200 /**
2201 * A deleted repository is back: production goes up again from its
2202 * default branch, for each project that has it on. Previews come back
2203 * with the next push to their pull requests.
2204 */
2205 private async repoRestored(repoId: string, attempts: number): Promise<void> {
2206 const deleted = await this.db
2207 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
2208 .bind(repoId)
2209 .all<{ project_id: string }>();
2210 const ids = deleted.results.map((row) => row.project_id);
2211 if (ids.length === 0) return;
2212 // The projects service hears of the restore on its own queue, and hides
2213 // the projects until then: wait for it a few deliveries.
2214 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
2215 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
2216 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
2217 for (const project of projects) {
2218 try {
2219 const started = await this.deployProduction(project, null, "g1t");
2220 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
2221 } catch (error) {
2222 console.error("could not deploy after restore", project.slug, error);
2223 }
2224 }
2225 }
2226
2227 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2228 * A workspace was deleted, restorable by g1t's staff for a while: every
2229 * app of its projects (production and previews) is paused, answering with
2230 * a notice and running nothing, builds under way are dropped, and nothing
2231 * builds for it until it is restored. Nothing is taken down: scripts,
2232 * settings and custom domains are kept for the restore. Never for a
2233 * protected workspace, whatever was published.
2234 */
2235 private async workspaceDeleting(slug: string): Promise<void> {
2236 const workspace = slug.toLowerCase();
2237 if (isProtectedWorkspace(workspace)) {
2238 console.error("workspace.deleting ignored for protected", workspace);
2239 return;
2240 }
2241 const at = now();
2242 await this.db.batch([
2243 this.db
2244 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
2245 .bind(at, workspace),
2246 this.db
2247 .prepare(
2248 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
2249 WHERE workspace = ? AND status IN ('queued', 'building')`,
2250 )
2251 .bind(at, workspace),
2252 ]);
2253 const cloudflare = this.cloudflare;
2254 for (const app of await this.appsOfWorkspace(workspace)) {
2255 if (app.paused_at) continue;
2256 await cloudflare?.pauseScript(app.script);
2257 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
2258 }
2259 }
2260
2261 /**
2262 * A deleted workspace is back: it builds again, and its paused apps are
2263 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
2264 * (the sweep tries again any it could not).
2265 */
2266 private async workspaceRestored(slug: string): Promise<void> {
2267 const workspace = slug.toLowerCase();
2268 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
2269 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
2270 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
2271 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
2272 }
2273
2274 /**
2275 * A deleted workspace is purged: whatever its projects still have up
2276 * comes down and their custom domains go, as for a purged repository.
2277 * Its own repositories' projects are purged with them (`repo.purged`);
2278 * this catches any building from a repository it had transferred away.
2279 */
2280 private async workspacePurged(slug: string): Promise<void> {
2281 const workspace = slug.toLowerCase();
2282 if (isProtectedWorkspace(workspace)) return;
2283 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
2284 for (const { project_id } of rows.results) {
2285 await this.takeDownWhere(project_id, null);
2286 await this.domains.removeWhere("project_id", project_id);
2287 }
2288 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
2289 await this.db.batch([
2290 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
2291 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
2292 ]);
2293 }
2294
2295 /** The apps of a workspace's projects, and any still under its name. */
2296 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
2297 const rows = await this.db
2298 .prepare(
2299 `SELECT * FROM apps WHERE workspace = ?1
2300 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
2301 )
2302 .bind(workspace)
2303 .all<AppRow>();
2304 return rows.results;
2305 }
2306
2307 /**
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2308 * A deleted repository is gone for good: its projects' custom domains are
2309 * removed (from the dispatcher and from Cloudflare), any app or redirect
2310 * still up comes down, and every row kept for them goes. What they used
2311 * stays on their workspace's meter.
2312 */
2313 private async repoPurged(repoId: string): Promise<void> {
2314 const projectIds = await this.projectIdsFor(repoId);
2315 const domains = this.domains;
2316 for (const projectId of projectIds) {
2317 await this.takeDownWhere(projectId, null);
2318 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
2319 await domains.removeWhere("project_id", projectId);
2320 }
2321 // The redirects left at names its apps had before.
2322 const scripts = await this.db
2323 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
2324 .bind(repoId)
2325 .all<{ script: string }>();
2326 const hosts = scripts.results.map(({ script }) => appHost(script));
2327 for (let i = 0; i < hosts.length; i += 50) {
2328 const chunk = hosts.slice(i, i + 50);
2329 const redirects = await this.db
2330 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
2331 .bind(...chunk)
2332 .all<{ script: string }>();
2333 for (const { script } of redirects.results) {
2334 await this.cloudflare?.deleteScript(script);
2335 await this.env.DOMAINS?.delete(appHost(script)).catch((error) => console.error("could not drop redirect", script, error));
2336 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
2337 }
2338 }
2339 await this.db.batch([
2340 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
2341 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
2342 ]);
2343 }
2344
2345 /**
2346 * The default branch is another one now: production is built from it, as
2347 * from a push to it, unless production already serves (or is building)
2348 * its commit, as when the default branch was only renamed.
2349 */
2350 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
2351 for (const found of await this.projects.byRepo(repoId)) {
2352 if (found.source.kind !== "hosted") continue;
2353 // Projects may not have heard yet: the event names the branch.
2354 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
2355 const actor = await this.workspaceActor(project.workspace);
2356 if (!actor) continue;
2357 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
2358 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
2359 if (!head) continue;
2360 const same = await this.db
2361 .prepare(
2362 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
2363 AND status IN ('queued', 'building', 'ready')`,
2364 )
2365 .bind(project.id, head)
2366 .first();
2367 if (same) continue;
2368 await this.deployProduction(project, head, createdBy);
2369 }
2370 }
2371
2372 /**
2373 * A branch was renamed: its preview is the same app, so its rows follow.
2374 * The app keeps its name until it is next built; then it goes up under
2375 * the new branch's name, and the old one redirects there (see `supersede`).
2376 */
2377 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
2378 const projectIds = await this.projectIdsFor(repoId);
2379 if (projectIds.length === 0) return;
2380 const ids = projectIds.map(() => "?").join(", ");
2381 await this.db.batch([
2382 this.db
2383 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2384 .bind(to, from, ...projectIds),
2385 this.db
2386 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2387 .bind(to, from, ...projectIds),
2388 ]);
2389 }
2390
2391 /** `project` under the workspace's slug now, whether or not projects has caught up. */
2392 private underSlug(project: Project, current: string, stale: string[]): Project {
2393 const source =
2394 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
2395 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
2396 : project.source;
2397 return { ...project, workspace: current, source };
2398 }
2399
2400 /** A stack's preview (no pull request of its own) built again at `commit`. */
2401 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
2402 if (!actor || branch == null) return null;
2403 const settings = await this.settingsRow(project.id);
2404 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
2405 return this.start({
2406 project,
2407 kind: "preview",
2408 branch,
2409 number: null,
2410 commit,
2411 source: repoOf(project).path,
2412 reader: actor,
2413 createdBy: "g1t",
2414 settings,
2415 // As `stack` built it: the project's own default branch.
2416 trusted: true,
2417 });
2418 }
2419
2420 // ---- The sweep -----------------------------------------------------
2421
2422 /**
2423 * Every few minutes: builds that died are failed; usage is counted; idle
2424 * previews, the apps of workspaces whose plan ended, and scripts no app
2425 * holds come down; and a month that is over is charged past its
2426 * allowance.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2427 *
2428 * Each step stands alone: one that fails is logged and the rest still
2429 * run. Taking idle previews down and charging months, which only read
2430 * g1t's own tables, go before the steps that wait on Cloudflare's API,
2431 * so a slow or failing API never holds them up.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2432 */
2433 async sweep(): Promise<void> {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2434 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
2435 await step("failing stuck builds", async () => {
2436 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2437 const stuck = await this.db
2438 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2439 .bind(cutoff)
2440 .all<{ id: string }>();
2441 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2442 });
2443 await step("taking down idle previews", () => this.takeDownIdle());
2444 await step("charging months", () => this.chargeMonths());
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2445
2446 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2447 // Each app is its project's workspace's, as deployments has it now: an
2448 // app still under the name it had before its project moved is the new
2449 // workspace's, and plans and limits are checked there.
2450 const settings = new Map(
2451 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2452 );
2453 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 member2454 // A deleted workspace's apps stay paused as they are, for a restore:
2455 // its plan ended with the deletion, and that must not take them down.
2456 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2457 const live = apps.filter((app) => !held(app));
2458 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2459
2460 // Apps of workspaces whose plan has ended come down.
2461 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2462 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2463 for (const workspace of workspaces) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2464 await step(`ending ${workspace}'s apps`, async () => {
2465 const plan = await billing.hasFeature(workspace, "deployments");
2466 if (!plan.ok && plan.error.code === "payment_required") {
2467 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2468 // Custom domains cost g1t by the month: they go with the plan.
2469 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2470 }
2471 });
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2472 }
2473
2474 // Apps whose project moved and are not up under the new name yet: a
2475 // move's rebuild that could not start is tried again here.
2476 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2477 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2478
2479 await this.domains
2480 .sweep(async (projectId) => {
2481 const app = await this.db
2482 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2483 .bind(projectId)
2484 .first<{ script: string }>();
2485 return app?.script ?? null;
2486 })
2487 .catch((error) => console.error("could not check domains", error));
2488
2489 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
2490 await this.count(apps).catch((error) => console.error("could not count usage", error));
2491 }
2492
2493 /**
2494 * Pauses the apps of workspaces that reached their limit for usage not
2495 * yet paid for, and rebuilds them from the same commit once they are
2496 * under it again. Paused apps answer with a notice and run nothing.
2497 *
2498 * The workspace is the project's now (see `ownerOf`), never the one an
2499 * app's row was written under, so an app left under its old name after
2500 * a transfer is paused only if its new workspace is over its limit. Such
2501 * an app is resumed by being built under its new name (`followMoves`),
2502 * not here.
2503 */
2504 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
2505 const cloudflare = this.cloudflare;
2506 if (!cloudflare) return;
2507 const billing = billingClient(this.env.BILLING);
2508 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2509 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
2510 const limit = await billing.checkLimit(workspace);
2511 if (!limit.ok) continue;
2512 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
2513 if (limit.value.state === "stopped") {
2514 for (const app of theirs.filter((a) => !a.paused_at)) {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2515 // A container app stops its container and starts none until it is resumed.
2516 if (app.runtime === "container") await this.holdContainer(cloudflare, app.script, "paused");
2517 else await cloudflare.pauseScript(app.script);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2518 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2519 }
2520 continue;
2521 }
2522 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2523 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
2524 if (paused.length === 0) continue;
2525 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
2526 for (const app of paused) {
2527 const project = projects.get(app.project_id);
2528 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2529 // A failed or refused rebuild leaves it paused, to try again later:
2530 // longer after each failure, and not once its commit is gone or it
2531 // failed the same way a few times.
2532 const history = await this.recentBuilds(app.script);
2533 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2534 const backoff = history[0]?.status === "failed";
2535 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2536 const rebuilt =
2537 app.kind === "production"
2538 ? await this.deployProduction(project, app.commit_sha, "g1t")
2539 : app.number != null
2540 ? await this.deployPreview(project, app.number, "g1t", true)
2541 : null;
2542 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2543 }
2544 }
2545 }
2546
2547 /**
2548 * Scripts in the namespace that no app holds, such as ones renamed. An
2549 * old address that redirects to its app's new one is held until its
2550 * redirect expires, then removed with the rest.
2551 */
2552 private async removeOrphans(apps: AppRow[]): Promise<void> {
2553 const cloudflare = this.cloudflare;
2554 if (!cloudflare) return;
2555 // Their entries in `DOMAINS` expire on their own, at the same time.
2556 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2557 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2558 const held = new Set([...apps.map((app) => app.script), ...redirects.results.map((r) => r.script)]);
2559 const building = await this.db
2560 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2561 .all<{ script: string }>();
2562 for (const row of building.results) held.add(row.script);
2563 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2564 for (const script of await cloudflare.listScripts()) {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2565 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) {
2566 // A container app's old name (a redirect that expired) has its
2567 // Container application still: it goes too.
2568 await cloudflare.deleteContainer(script.id).catch((error) => console.error("could not remove container", script.id, error));
2569 await cloudflare.deleteScript(script.id);
2570 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2571 }
2572 }
2573
2574 /** Counts this month's requests and CPU time per workspace, from analytics. */
2575 private async count(apps: AppRow[]): Promise<void> {
2576 const cloudflare = this.cloudflare;
2577 if (!cloudflare || apps.length === 0) return;
2578 const start = `${month()}-01T00:00:00Z`;
2579 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2580 // Analytics only counts apps that are up; the meter keeps what earlier
2581 // apps used by never going down.
2582 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2583 for (const app of apps) {
2584 const used = totals.get(app.script);
2585 if (!used) continue;
2586 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
2587 sum.requests += used.requests;
2588 sum.cpuMs += used.cpuMs;
2589 perWorkspace.set(app.workspace, sum);
2590 }
2591 const at = now();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2592 // Written only when the count moves the meter: most sweeps it does not.
2593 const stored = new Map(
2594 (
2595 await this.db
2596 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2597 .bind(month())
2598 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2599 ).results.map((row) => [row.namespace, row]),
2600 );
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2601 for (const [workspace, used] of perWorkspace) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2602 if (!movesMeter(stored.get(workspace), used)) continue;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2603 await this.db
2604 .prepare(
2605 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2606 ON CONFLICT (namespace, month) DO UPDATE SET
2607 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2608 )
2609 .bind(workspace, month(), used.requests, used.cpuMs, at)
2610 .run();
2611 }
2612 // When each preview last answered anyone, for the idle sweep.
2613 const recent = await cloudflare.usage(
2614 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2615 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2616 at,
2617 );
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2618 // At an hour's resolution: previews idle for days are what it finds.
2619 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2620 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2621 for (const [script, used] of recent) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2622 const last = lastRequest.get(script);
2623 if (used.requests > 0 && (!last || last < hourAgo)) {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2624 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2625 }
2626 }
2627 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
2628 // What this month's traffic and custom domains will cost, from the
2629 // first request and the first domain, so the workspace's limit counts
2630 // it now rather than when the month closes, and its Billing page shows it.
2631 const costs = await this.costs();
2632 const billing = billingClient(this.env.BILLING);
2633 const meters = await this.db
2634 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2635 .bind(month())
2636 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2637 // Container time is the same feature's, noted with its traffic.
2638 const containers = await this.containerMonth(month());
2639 const seen = new Set(meters.results.map((meter) => meter.namespace));
2640 const rows = [
2641 ...meters.results,
2642 ...[...containers.keys()].filter((w) => !seen.has(w)).map((namespace) => ({ namespace, requests: 0, cpu_ms: 0, peak_domains: 0 })),
2643 ];
2644 for (const meter of rows) {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2645 const cost = monthCost(meter, costs);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2646 const container = containers.get(meter.namespace);
2647 const detail = [cost.traffic.detail, container?.detail].filter(Boolean).join("; ") || null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2648 await billing
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2649 .notePending(meter.namespace, "deployments", cost.traffic.micros + (container?.micros ?? 0), detail)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2650 .catch((error) => console.error("could not note pending usage", error));
2651 if ((meter.peak_domains ?? 0) > 0) {
2652 await billing
2653 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2654 .catch((error) => console.error("could not note pending usage", error));
2655 }
2656 }
2657 }
2658
2659 /** Previews no one has visited in their project's idle days. */
2660 private async takeDownIdle(): Promise<void> {
2661 const idle = await this.db
2662 .prepare(
2663 `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 member2664 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2665 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2666 )
2667 .all<{ script: string }>();
2668 for (const { script } of idle.results) await this.removeApp(script);
2669 }
2670
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2671 /**
2672 * Charges each month that is over for its requests, CPU time, custom
2673 * domains and container time, from the first, once.
2674 */
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2675 private async chargeMonths(): Promise<void> {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2676 // A workspace whose only usage was container time has a meter row too.
2677 await this.db
2678 .prepare(
2679 `INSERT OR IGNORE INTO meters (namespace, month)
2680 SELECT DISTINCT workspace, month FROM container_meters WHERE month < ?`,
2681 )
2682 .bind(month())
2683 .run()
2684 .catch((error) => console.error("could not note container months", error));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2685 const due = await this.db
2686 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2687 .bind(month())
2688 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2689 const costs = await this.costs();
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2690 const containers = new Map<string, Map<string, { micros: number; seconds: number; detail: string | null }>>();
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2691 for (const meter of due.results) {
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2692 if (!containers.has(meter.month)) containers.set(meter.month, await this.containerMonth(meter.month));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2693 const cost = monthCost(meter, costs);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2694 const container = containers.get(meter.month)?.get(meter.namespace);
2695 const micros = cost.micros + (container?.micros ?? 0);
2696 const description = [cost.description, container?.detail].filter(Boolean).join(", ");
2697 if (micros > 0) {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2698 const charged = await billingClient(this.env.BILLING).chargeFeature({
2699 workspace: meter.namespace,
2700 feature: "deployments",
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2701 costMicros: micros,
2702 description: `Deployments in ${meter.month}: ${description}`,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2703 reference: `deployments/${meter.namespace}/${meter.month}`,
2704 });
2705 if (!charged.ok) continue;
2706 }
2707 await this.db
2708 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2709 .bind(now(), meter.namespace, meter.month)
2710 .run();
2711 }
2712 }
2713}
2714
2715/** `POST /rpc/<method>`: the arguments are the body. */
2716async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
2717 switch (method) {
2718 case "settings":
2719 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2720 case "is_enabled":
2721 return service.isEnabled(args);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2722 case "update_settings":
2723 return service.updateSettings(args);
2724 case "list":
2725 return service.list(args);
2726 case "get":
2727 return service.get(args);
2728 case "redeploy":
2729 return service.redeploy(args);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92730 // For the API: a g1t Actions job opening a build it runs itself.
2731 case "start_from_workflow":
2732 return service.startFromWorkflow(args);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2733 // For the actions service: a job ended, so its open builds end too.
2734 case "workflow_job_ended":
2735 return service.workflowJobEnded(args);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2736 case "take_down":
2737 return service.takeDown(args);
2738 case "overview":
2739 return service.overview(args);
2740 case "usage":
2741 return service.usage(args);
2742 case "domains":
2743 return service.listDomains(args);
2744 case "add_domain":
2745 return service.addDomain(args);
2746 case "remove_domain":
2747 return service.removeDomain(args);
2748 case "refresh_domain":
2749 return service.refreshDomain(args);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972750 // A repository's deployments wherever they run (repo-deployments.ts).
2751 case "list_deployments":
2752 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2753 case "get_deployment":
2754 return service.repoDeployments.get(args);
2755 case "list_deployment_statuses":
2756 return service.repoDeployments.statuses(args);
2757 case "list_environments":
2758 return service.repoDeployments.environments(args);
2759 case "get_environment":
2760 return service.repoDeployments.environment(args);
2761 case "create_deployment":
2762 return service.repoDeployments.create(args);
2763 case "create_deployment_status":
2764 return service.repoDeployments.createStatus(args);
2765 // For the actions service: a run's deployment to one environment.
2766 case "actions_deployment":
2767 return service.repoDeployments.fromActions(args);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2768 default:
2769 return undefined;
2770 }
2771}
2772
2773export default {
2774 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2775 const { pathname } = new URL(request.url);
2776 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2777 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2778 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2779 if (rpcMatch) {
2780 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2781 const opened = openD1(env.DB, request);
2782 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
2783 const result = await rpc(service, rpcMatch[1], body, ctx);
2784 return opened.finish(result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result));
2785 }
2786 const service = new Deployments(env);
2787 // A build's reports, forwarded by the API.
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2788 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|registry|session|finish|fail)$/);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2789 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.2790 // A container app's Worker reporting its running time, forwarded by the API.
2791 const meterMatch = pathname.match(/^\/apps\/([a-z0-9-]{1,63})\/meter$/);
2792 if (meterMatch) return service.meter(meterMatch[1], body);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays2793 return new Response("Not found\n", { status: 404 });
2794 },
2795
2796 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2797 const service = new Deployments(env);
2798 for (const message of batch.messages) {
2799 try {
2800 await service.onEvent(message.body, message.attempts);
2801 message.ack();
2802 } catch (error) {
2803 console.error("deployments could not handle", message.body.type, error);
2804 message.retry();
2805 }
2806 }
2807 },
2808
2809 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2810 await new Deployments(env).sweep();
2811 },
2812} satisfies ExportedHandler<Env, G1tEvent>;

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