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

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

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