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

The statement is a month at a time, a line per kind of charge1/**
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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.15 * 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 *
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas20 * 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.
The statement is a month at a time, a line per kind of charge30 *
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 {
36 CUSTOM_DOMAIN_TARGET,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas37 DEPLOYMENT_COSTS,
The statement is a month at a time, a line per kind of charge38 SLUG_HOLD_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39 ComputeGate,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.40 actionsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41 allows,
The statement is a month at a time, a line per kind of charge42 billingClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43 can,
44 needs,
45 permission,
46 sandboxEstimateMicros,
47 currentMovedPath,
The statement is a month at a time, a line per kind of charge48 currentWorkspaceSlug,
Events: review requests, assignments, stops and deployments are published49 eventsClient,
The statement is a month at a time, a line per kind of charge50 fail,
51 identityClient,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member52 isProtectedWorkspace,
Merge branch 'mirroring' into artifacts-mode53 mirrorWritable,
The statement is a month at a time, a line per kind of charge54 newId,
55 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents56 openD1,
The statement is a month at a time, a line per kind of charge57 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 repoMove,
The statement is a month at a time, a line per kind of charge59 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look60 staleMovedPaths,
The statement is a month at a time, a line per kind of charge61 staleSlugs,
62 workClient,
63 type DeployKind,
64 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 type DetectedKind,
The statement is a month at a time, a line per kind of charge66 type DeployStatus,
67 type DeployUsage,
68 type Deployment,
69 type Domain,
70 type G1tEvent,
71 type LiveApp,
72 type Project,
73 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74 type RepoMove,
The statement is a month at a time, a line per kind of charge75 type ProjectDomains,
76 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights77 workOwner,
The statement is a month at a time, a line per kind of charge78 type RepoPath,
79 type Result,
80 type ServiceBinding,
81 type User,
82 type Viewer,
83} from "@g1t/contracts";
84
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights85import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
The statement is a month at a time, a line per kind of charge86import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
87import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas88import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails89import { monthCost, movesMeter } from "./metering";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit90import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
91import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
The statement is a month at a time, a line per kind of charge92import { appHost, appUrl, label, uniqueLabel } from "./names";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9793import { RepoDeployments, sourceOf } from "./repo-deployments";
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.94import { NEEDS_WRITE, commitOf, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds";
The statement is a month at a time, a line per kind of charge95
96type Env = {
97 DB: D1Database;
98 REPOS: ServiceBinding;
99 WORK: ServiceBinding;
100 IDENTITY: ServiceBinding;
101 BILLING: ServiceBinding;
102 RUNNER: ServiceBinding;
103 PROJECTS: ServiceBinding;
104 /** Secrets and variables: the actions service holds the one store. */
105 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published106 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
107 EVENTS?: ServiceBinding;
The statement is a month at a time, a line per kind of charge108 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
109 CLOUDFLARE_API_TOKEN?: string;
110 CLOUDFLARE_ACCOUNT_ID: string;
111 DISPATCH_NAMESPACE: string;
112 SITE: string;
113 /** Custom domains: hostname to app, read by the dispatcher. */
114 DOMAINS?: KVNamespace;
115 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
116 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look117 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
118 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
The statement is a month at a time, a line per kind of charge119};
120
121/** A build that has not reported in this long has died. */
122const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
123/** A script in the namespace that no app holds, older than this, is removed. */
124const ORPHAN_AFTER_MS = 60 * 60 * 1000;
125const LIST_LIMIT = 50;
126const STATUS_CONTEXT = "g1t / deploy";
127/**
128 * Deliveries of `workspace.renamed` that wait for the projects service to
129 * have seen it too, before going ahead with the new slug regardless.
130 */
131const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas132/** Why a build under way was dropped by a move; the move builds it again under the new name. */
133const MOVED_ERROR = "The repository moved: built again under its new name.";
134const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit135/**
136 * A move's rebuild that could not start, or failed, is tried again by the
137 * sweep after this long, doubled for each failure after the first, up to
138 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
139 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas140const MOVE_RETRY_MS = 60 * 60 * 1000;
The statement is a month at a time, a line per kind of charge141
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look142/** A build's report of what it found the project to be, if it is one g1t knows. */
143export function detectedKind(value: unknown): DetectedKind | null {
144 return value === "workers" || value === "static" || value === "html" ? value : null;
145}
146
The statement is a month at a time, a line per kind of charge147const now = () => new Date().toISOString();
148const month = (at = new Date()) => at.toISOString().slice(0, 7);
149
150async function sha256(text: string): Promise<string> {
151 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
152 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
153}
154
155function randomToken(): string {
156 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
157}
158
159function isMember(viewer: Viewer, slug: string): boolean {
160 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
161}
162
163/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look164/** One compute gate per isolate, so entitlements and prices are kept between calls. */
165let computeGate: ComputeGate | null = null;
166function gateFor(billing: ServiceBinding): ComputeGate {
167 computeGate ??= new ComputeGate(billing);
168 return computeGate;
169}
170
171/** The longest a build may run, in minutes: as long as its read token lasts. */
172const BUILD_MINUTES = 30;
173
174/**
175 * A build that was skipped before it started (its plan, its limit, or
176 * billing's refusal) as a failure, so whoever asked for it sees why.
177 */
178function notStarted(result: Result<Deployment>): Result<Deployment> {
179 if (result.ok && result.value.status === "skipped" && result.value.error) {
180 return fail("payment_required", result.value.error);
181 }
182 return result;
183}
184
The statement is a month at a time, a line per kind of charge185function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
186 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
187 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
188}
189
190type SettingsRow = {
191 project_id: string;
192 workspace: string;
193 slug: string;
194 repo_id: string;
195 enabled: number;
196 previews: number;
197 production: number;
198 build_command: string | null;
199 output_dir: string | null;
200 idle_days: number;
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.201 /** Whether it builds through its managed deploy workflow; see `syncManagedDeploy`. */
202 via_workflow?: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look203 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
204 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member205 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
206 workspace_deleted_at?: string | null;
The statement is a month at a time, a line per kind of charge207};
208
209type DeploymentRow = {
210 id: string;
211 project_id: string;
212 workspace: string;
213 slug: string;
214 repo_id: string;
215 repo: string;
216 kind: DeployKind;
217 branch: string | null;
218 number: number | null;
219 commit_sha: string;
220 script: string;
221 status: DeployStatus;
222 error: string | null;
223 warnings: string;
224 log: string | null;
225 token_hash: string | null;
226 trusted: number;
227 build_seconds: number | null;
228 created_by: string;
229 created_at: string;
230 finished_at: string | null;
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.231 /** Set for a build a g1t Actions job runs (migration 0011): the job pays for its time. */
232 run_id?: string | null;
233 job_id?: string | null;
234};
235
236/** What opening any build takes; see `open`. */
237type OpenInput = {
238 project: Project;
239 kind: DeployKind;
240 branch: string | null;
241 number: number | null;
242 commit: string;
243 createdBy: string;
244 settings: SettingsRow;
245 /** A push, or work by a member or an agent; see `insider`. */
246 trusted: boolean;
247 /** The g1t Actions job that builds it, for a build opened from a workflow. */
248 workflow?: { runId: string; jobId: string };
The statement is a month at a time, a line per kind of charge249};
250
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.251/** Why a build was not opened: the plan or the limit, or g1t itself. */
252type Refusal = "payment_required" | "conflict";
253
254/** A sandbox's reservation with billing; none for a workflow job's build. */
255type Compute = { reservation: string | null; microsPerSecond: number; maxRunMinutes: number | null };
256
257type Opened =
258 | { refused: string; code: Refusal; id: string }
259 | {
260 refused: null;
261 id: string;
262 token: string;
263 script: string;
264 build: { secrets: Record<string, string>; variables: Record<string, string> };
265 compute: Compute;
266 };
267
The statement is a month at a time, a line per kind of charge268type AppRow = {
269 script: string;
270 project_id: string;
271 workspace: string;
272 slug: string;
273 kind: DeployKind;
274 branch: string | null;
275 number: number | null;
276 commit_sha: string;
277 deployed_at: string;
278 created_at: string;
279 last_request_at: string | null;
280 /** Set while its workspace is over its limit; see `holdToLimits`. */
281 paused_at: string | null;
282};
283
284function toDeployment(row: DeploymentRow): Deployment {
285 return {
286 id: row.id,
287 kind: row.kind,
288 branch: row.branch,
289 number: row.number,
290 commit: row.commit_sha,
291 status: row.status,
292 url: appUrl(row.script),
293 error: row.error,
294 warnings: JSON.parse(row.warnings || "[]") as string[],
295 buildSeconds: row.build_seconds,
296 createdBy: row.created_by,
297 createdAt: row.created_at,
298 finishedAt: row.finished_at,
299 };
300}
301
302function toLive(app: AppRow): LiveApp {
303 return {
304 kind: app.kind,
305 branch: app.branch,
306 number: app.number,
307 url: appUrl(app.script),
308 commit: app.commit_sha,
309 deployedAt: app.deployed_at,
310 };
311}
312
313class Deployments {
314 constructor(private readonly env: Env) {}
315
316 private get cloudflare(): Cloudflare | null {
317 const token = this.env.CLOUDFLARE_API_TOKEN;
318 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
319 }
320
321 private get db() {
322 return this.env.DB;
323 }
324
325 private get domains(): Domains {
326 const token = this.env.CLOUDFLARE_API_TOKEN;
327 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
328 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
329 }
330
331 private get projects() {
332 return projectsClient(this.env.PROJECTS);
333 }
334
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97335 /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */
336 get repoDeployments(): RepoDeployments {
337 return new RepoDeployments(this.env);
338 }
339
340 /**
341 * Publishes a build's change in the repository-wide model
342 * (`deployment.created`, `deployment_status.created`), as a reported
343 * deployment's are. Never fails the build.
344 */
345 private async buildChanged(id: string, created = false): Promise<void> {
346 try {
347 const row = await this.deploymentRow(id);
348 if (!row) return;
349 const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id);
350 const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main";
351 await this.repoDeployments.buildChanged(row, defaultBranch, created);
352 } catch (error) {
353 console.error("build change not published", id, String(error));
354 }
355 }
356
The statement is a month at a time, a line per kind of charge357 /** The workspace itself, as the service acts for it. */
358 private async workspaceActor(slug: string): Promise<User | null> {
359 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
360 if (!workspace) return null;
361 return {
362 id: workspace.id,
363 username: workspace.slug,
364 kind: "workspace",
365 verified: true,
366 workspaces: [{ slug: workspace.slug, role: "member" }],
367 };
368 }
369
370 /**
371 * What the project's secrets and variables available to deployments give
372 * production or a preview: its build's environment, and the same again as
373 * the running app's bindings. Untrusted builds get no secrets.
374 */
375 private async resolve(
376 project: { id: string; slug: string; repoId: string; repo: RepoPath },
377 environment: DeployKind,
378 trusted: boolean,
379 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
380 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
381 method: "POST",
382 headers: { "content-type": "application/json" },
383 body: JSON.stringify({
384 repoId: project.repoId,
385 repo: project.repo,
386 projectId: project.id,
387 projectSlug: project.slug,
388 consumer: "deployments",
389 environment,
390 trusted,
391 }),
392 });
393 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
394 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
395 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
396 }
397
398 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights399 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
400 * it, or its author) is trusted with the project's secrets: g1t itself,
401 * or someone who can push to the repository (Write or more, a member's or
402 * a collaborator's). Anyone else gets a preview built without them, as
403 * their workflows run. A change g1t made for someone is trusted as they
404 * are, never more for being g1t's.
The statement is a month at a time, a line per kind of charge405 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights406 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
407 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look408 const found = await identityClient(this.env.IDENTITY)
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights409 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look410 .catch(() => null);
411 return !!found?.ok && allows(found.value.role, "push");
The statement is a month at a time, a line per kind of charge412 }
413
414 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
415 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
416 }
417
418 /** The name an app gets, unique among apps: production, or a branch's preview. */
419 private async scriptFor(project: Project, branch: string | null): Promise<string> {
420 const base = await label(project.workspace, project.slug, branch);
421 const holder = await this.db
422 .prepare(
423 `SELECT project_id, branch FROM apps WHERE script = ?1
424 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
425 )
426 .bind(base)
427 .first<{ project_id: string; branch: string | null }>();
428 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
429 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
430 }
431
432 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
433 return {
434 enabled: !!row?.enabled,
435 previews: row ? !!row.previews : true,
436 production: row ? !!row.production : true,
437 buildCommand: row?.build_command ?? null,
438 outputDir: row?.output_dir ?? null,
439 idleDays: row?.idle_days ?? 7,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.440 viaWorkflow: !!row?.via_workflow,
The statement is a month at a time, a line per kind of charge441 productionUrl: appUrl(await this.scriptFor(project, null)),
442 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look443 detected: await this.lastDetected(project.id),
The statement is a month at a time, a line per kind of charge444 };
445 }
446
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look447 /** What the project's last finished build found it to be; null before one has. */
448 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
449 const row = await this.db
450 .prepare(
451 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
452 )
453 .bind(projectId)
454 .first<{ detected: string }>()
455 .catch(() => null);
456 return detectedKind(row?.detected);
457 }
458
459 /**
460 * The project, if `viewer` may do what `method` needs on its repository
461 * (see `NEEDS`): not found when they cannot read it, refused when they can
462 * but their role is too low.
463 */
464 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
465 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
466 if (!found.ok) return found;
467 const repo = repoRef(found.value);
468 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
469 const capability = NEEDS[method];
470 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
471 return found;
The statement is a month at a time, a line per kind of charge472 }
473
474 // ---- Methods for the site and the API ------------------------------
475
476 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look477 const project = await this.projectFor(a.project, a.viewer, "settings");
The statement is a month at a time, a line per kind of charge478 if (!project.ok) return project;
479 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
480 }
481
482 async updateSettings(a: {
483 actor: User;
484 project: ProjectRef;
485 changes: Partial<DeploySettings>;
486 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look487 const found = await this.projectFor(a.project, a.actor, "updateSettings");
The statement is a month at a time, a line per kind of charge488 if (!found.ok) return found;
489 const project = found.value;
490 const before = await this.toSettings(project, await this.settingsRow(project.id));
491 const next = { ...before, ...a.changes };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97492 // A library, a tool or other, as set in its settings, does not deploy.
493 if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") {
494 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 production495 }
The statement is a month at a time, a line per kind of charge496 if (next.enabled && !before.enabled) {
497 // Turning it on starts paid work: only with the workspace's plan.
498 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
499 if (!plan.ok) return plan;
500 }
501 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
502 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
503 await this.db
504 .prepare(
505 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.506 output_dir, idle_days, updated_by, updated_at, via_workflow)
507 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
The statement is a month at a time, a line per kind of charge508 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.509 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12,
510 via_workflow = ?13`,
The statement is a month at a time, a line per kind of charge511 )
512 .bind(
513 project.id,
514 project.workspace,
515 project.slug,
516 repoOf(project).id,
517 next.enabled ? 1 : 0,
518 next.previews ? 1 : 0,
519 next.production ? 1 : 0,
520 clip(next.buildCommand),
521 clip(next.outputDir),
522 idleDays,
523 a.actor.username,
524 now(),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.525 next.viaWorkflow ? 1 : 0,
The statement is a month at a time, a line per kind of charge526 )
527 .run();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.528 // The managed deploy workflow follows the settings: installed while the
529 // project builds through it, removed when it stops.
530 if (next.viaWorkflow || before.viaWorkflow) {
531 const installed = await this.syncManagedDeploy(project, next);
532 if (!installed.ok) return installed;
533 }
A project is an app or a library: libraries show their package and how to ship a release, not production534 // Projects keeps whether it deploys, so a project with Deployments on is an app.
535 if (next.enabled !== before.enabled) {
536 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
537 }
The statement is a month at a time, a line per kind of charge538 // What was turned off comes down now; nothing keeps running unasked.
539 if (!next.enabled) await this.takeDownWhere(project.id, null);
540 else {
541 if (!next.previews) await this.takeDownWhere(project.id, "preview");
542 if (!next.production) await this.takeDownWhere(project.id, "production");
543 }
544 // Turned on: production goes up from the default branch at once.
545 if (next.enabled && next.production && (!before.enabled || !before.production)) {
546 await this.deployProduction(project, null, a.actor.username);
547 }
548 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
549 }
550
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.551 /**
552 * Installs, replaces or removes the project's managed deploy workflow
553 * (the actions service's `set_managed_deploy`) to match `settings`: it
554 * runs while Deployments are on and the project builds through it.
555 */
556 private async syncManagedDeploy(project: Project, settings: DeploySettings): Promise<Result<boolean>> {
557 const on = settings.enabled && settings.viaWorkflow;
558 const repo = repoOf(project);
559 const response = await this.env.ACTIONS.fetch("https://actions/rpc/set_managed_deploy", {
560 method: "POST",
561 headers: { "content-type": "application/json" },
562 body: JSON.stringify({
563 repoId: repo.id,
564 project: project.slug,
565 productionBranch: repo.defaultBranch,
566 rootDir: project.source.kind === "hosted" ? (project.source.rootDir ?? "") : "",
567 buildCommand: settings.buildCommand ?? "",
568 outputDir: settings.outputDir ?? "",
569 previews: on && settings.previews,
570 production: on && settings.production,
571 }),
572 });
573 const answer = (await response.json().catch(() => null)) as { ok?: boolean; value?: boolean; error?: { message?: string } } | null;
574 if (!response.ok || !answer || answer.ok === false) {
575 return fail("unavailable", answer?.error?.message ?? "The deploy workflow could not be installed. Try again in a moment.");
576 }
577 return ok(!!answer.value);
578 }
579
580 /** Whether `projectId` builds through its managed deploy workflow, so pushes and pull requests start no build here. */
581 private async buildsViaWorkflow(projectId: string): Promise<boolean> {
582 return !!(await this.settingsRow(projectId))?.via_workflow;
583 }
584
A project is an app or a library: libraries show their package and how to ship a release, not production585 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
586 async isEnabled(a: { projectId: string }): Promise<boolean> {
587 return !!(await this.settingsRow(a.projectId))?.enabled;
588 }
589
The statement is a month at a time, a line per kind of charge590 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look591 const project = await this.projectFor(a.project, a.viewer, "list");
The statement is a month at a time, a line per kind of charge592 if (!project.ok) return project;
593 const [deployments, apps] = await Promise.all([
594 this.db
595 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
596 .bind(project.value.id, LIST_LIMIT)
597 .all<DeploymentRow>(),
598 this.db
599 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
600 .bind(project.value.id)
601 .all<AppRow>(),
602 ]);
603 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
604 }
605
606 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look607 const project = await this.projectFor(a.project, a.viewer, "get");
The statement is a month at a time, a line per kind of charge608 if (!project.ok) return project;
609 const row = await this.db
610 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
611 .bind(a.id, project.value.id)
612 .first<DeploymentRow>();
613 if (!row) return fail("not_found", "No such deployment.");
614 return ok({ ...toDeployment(row), log: row.log });
615 }
616
617 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look618 const found = await this.projectFor(a.project, a.actor, "redeploy");
The statement is a month at a time, a line per kind of charge619 if (!found.ok) return found;
620 const project = found.value;
621 const settings = await this.settingsRow(project.id);
622 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
623 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look624 return notStarted((await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy."));
The statement is a month at a time, a line per kind of charge625 }
626 // A branch's preview comes from its pull request.
627 const app = await this.db
628 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
629 .bind(project.id, a.branch)
630 .first<{ number: number }>();
631 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look632 return notStarted((await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open."));
The statement is a month at a time, a line per kind of charge633 }
634
635 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look636 const project = await this.projectFor(a.project, a.actor, "takeDown");
The statement is a month at a time, a line per kind of charge637 if (!project.ok) return project;
638 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
639 return ok(true);
640 }
641
642 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
643 const workspace = a.workspace.toLowerCase();
644 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look645 // Only the projects whose repositories the viewer can read: the
646 // projects service lists no others.
647 const listed = await this.projects.list(workspace, a.viewer);
648 if (!listed.ok) return listed;
649 const readable = new Set(listed.value.map((project) => project.slug));
The statement is a month at a time, a line per kind of charge650 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look651 // A deleted repository's projects are hidden until it is restored.
652 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ? AND repo_deleted_at IS NULL").bind(workspace).all<{ slug: string; enabled: number }>(),
The statement is a month at a time, a line per kind of charge653 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
654 this.db
655 .prepare(
656 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
657 )
658 .bind(workspace)
659 .all<DeploymentRow>(),
660 ]);
661 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look662 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
The statement is a month at a time, a line per kind of charge663 const own = apps.results.filter((app) => app.slug === row.slug);
664 const production = own.find((app) => app.kind === "production");
665 const newest = latest.results.find((d) => d.slug === row.slug);
666 return {
667 slug: row.slug,
668 enabled: !!row.enabled,
669 production: production ? toLive(production) : null,
670 previews: own.filter((app) => app.kind === "preview").length,
671 latest: newest ? toDeployment(newest) : null,
672 };
673 }),
674 );
675 }
676
677 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
678 const slug = a.workspace.toLowerCase();
679 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
680 const [meter, apps] = await Promise.all([
681 this.db
682 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
683 .bind(slug, month())
684 .first<{
685 requests: number;
686 cpu_ms: number;
687 peak_apps: number;
688 build_seconds: number;
689 build_micros: number;
690 counted_at: string | null;
691 }>(),
692 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
693 ]);
694 return ok({
695 month: month(),
696 requests: meter?.requests ?? 0,
697 cpuMs: meter?.cpu_ms ?? 0,
698 apps: apps?.n ?? 0,
699 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
700 buildSeconds: meter?.build_seconds ?? 0,
701 buildMicros: meter?.build_micros ?? 0,
702 countedAt: meter?.counted_at ?? null,
703 });
704 }
705
706 // ---- Custom domains ------------------------------------------------
707
708 async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look709 const project = await this.projectFor(a.project, a.viewer, "listDomains");
The statement is a month at a time, a line per kind of charge710 if (!project.ok) return project;
711 const domains = this.domains;
712 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas713 const [rows, available, used, costs] = await Promise.all([
The statement is a month at a time, a line per kind of charge714 domains.forProject(project.value.id),
715 domains.available(),
716 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas717 this.costs(),
The statement is a month at a time, a line per kind of charge718 ]);
719 return ok({
720 domains: rows.map(toDomain),
721 target: CUSTOM_DOMAIN_TARGET,
722 available,
723 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas724 monthlyMicros: costs.domainMonthPrice,
The statement is a month at a time, a line per kind of charge725 used,
726 });
727 }
728
729 async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look730 const found = await this.projectFor(a.project, a.actor, "addDomain");
The statement is a month at a time, a line per kind of charge731 if (!found.ok) return found;
732 const project = found.value;
733 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
734 if (!plan.ok) return plan;
735 const added = await this.domains.add({
736 project: { id: project.id, workspace: project.workspace, slug: project.slug },
737 script: await this.productionScript(project),
738 hostname: String(a.hostname ?? ""),
739 twin: !!a.twin,
740 by: a.actor.username,
741 });
742 if (!added.ok) return fail(added.code, added.message);
743 await this.notePeak(project.workspace);
744 return ok(added.rows.map(toDomain));
745 }
746
747 async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look748 const found = await this.projectFor(a.project, a.actor, "removeDomain");
The statement is a month at a time, a line per kind of charge749 if (!found.ok) return found;
750 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
751 if (!row) return fail("not_found", "No such domain.");
752 await this.domains.remove(row);
753 return ok(true);
754 }
755
756 async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look757 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
The statement is a month at a time, a line per kind of charge758 if (!found.ok) return found;
759 const domains = this.domains;
760 const row = await domains.byId(found.value.id, String(a.id ?? ""));
761 if (!row) return fail("not_found", "No such domain.");
762 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
763 await this.notePeak(found.value.workspace);
764 return ok(toDomain(after));
765 }
766
767 /** The script production is up under, or the name it will have. */
768 private async productionScript(project: Project): Promise<string> {
769 const app = await this.db
770 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
771 .bind(project.id)
772 .first<{ script: string }>();
773 return app?.script ?? (await this.scriptFor(project, null));
774 }
775
776 // ---- Starting builds -----------------------------------------------
777
778 /**
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.779 * Opens a deployment and starts its build in a sandbox. Skipped, with the
780 * reason recorded, when the workspace's plan is off.
The statement is a month at a time, a line per kind of charge781 */
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.782 private async start(input: OpenInput & {
783 /** Where the build reads the commit from: the project, or a pull request's fork. */
The statement is a month at a time, a line per kind of charge784 source: RepoPath;
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.785 /** Who reads it: the workspace, or whoever the pull request is for. */
The statement is a month at a time, a line per kind of charge786 reader: User;
787 }): Promise<Result<Deployment>> {
788 const { project } = input;
789 const repo = repoOf(project);
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.790 const opened = await this.open(input, { reserve: true });
791 if (opened.refused !== null) return ok(toDeployment((await this.deploymentRow(opened.id))!));
792 const { build, compute } = opened;
793 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
794 method: "POST",
795 headers: { "content-type": "application/json" },
796 body: JSON.stringify({
797 deployId: opened.id,
798 token: opened.token,
799 workspace: project.workspace,
800 reservation: compute.reservation,
801 microsPerSecond: compute.microsPerSecond,
802 maxRunMinutes: compute.maxRunMinutes,
803 actor: input.reader,
804 source: input.source,
805 // The project, whose guardrails the build runs under: a preview's
806 // source is its pull request's working copy, not the project.
807 repo: repo.path,
808 repoId: repo.id,
809 commit: input.commit,
810 rootDir: project.source.rootDir,
811 buildCommand: input.settings.build_command,
812 outputDir: input.settings.output_dir,
813 buildEnv: build.variables,
814 buildSecrets: build.secrets,
815 }),
816 });
817 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
818 if (!started.ok) {
819 // Never reached a sandbox: what was reserved is given back.
820 if (compute.reservation) await gateFor(this.env.BILLING).settle(compute.reservation, 0);
821 await this.finishFailed(opened.id, started.error.message, null, null);
822 }
823 return ok(toDeployment((await this.deploymentRow(opened.id))!));
824 }
825
826 /**
827 * What every build does before anything builds it: checks the plan and
828 * the spend limit (and, for a sandbox, reserves its compute with
829 * billing), records the deployment with a token for that build alone,
830 * replaces older builds of the same app, sets the commit's status, and
831 * reads what the project's secrets and variables give builds of its
832 * kind. A refusal is the deployment's status, saying what to do; nothing
833 * is resolved for a refused build.
834 */
835 private async open(input: OpenInput, options: { reserve: boolean }): Promise<Opened> {
836 const { project } = input;
837 const repo = repoOf(project);
The statement is a month at a time, a line per kind of charge838 const script = await this.scriptFor(project, input.branch);
839 const id = newId("dpl");
840 const token = randomToken();
841 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
842 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.843 const stopped = limit.ok && limit.value.state === "stopped";
The statement is a month at a time, a line per kind of charge844 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look845 let refused = !plan.ok
The statement is a month at a time, a line per kind of charge846 ? plan.error.message
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.847 : stopped
The statement is a month at a time, a line per kind of charge848 ? (limit.value.message ?? "The workspace reached its usage limit.")
849 : !cloudflare
850 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
851 : null;
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.852 let code: Refusal = plan.ok && !stopped ? "conflict" : "payment_required";
853 // A sandbox's build is compute: reserved with billing before it starts,
854 // and settled by its sandbox when it stops. A workflow job's build runs
855 // in the job, which pays for its own time.
856 const compute: Compute = { reservation: null, microsPerSecond: 0, maxRunMinutes: null };
857 if (!refused && options.reserve) {
858 const gate = gateFor(this.env.BILLING);
859 const ent = await gate.entitlements(project.workspace);
860 compute.microsPerSecond = await gate.microsPerSecond();
861 compute.maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look862 const isPrivate = await reposClient(this.env.REPOS)
863 .get(repo.path, null)
864 .then((found) => !found.ok || found.value.isPrivate)
865 .catch(() => true);
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.866 const admitted = await gate.admit(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look867 {
868 workspace: project.workspace,
869 repo: repo.path,
870 public: !isPrivate,
871 kind: "deploy",
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.872 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, compute.maxRunMinutes ?? BUILD_MINUTES), compute.microsPerSecond),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look873 },
874 ent,
875 );
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.876 if (admitted.ok) compute.reservation = admitted.reservation?.id ?? null;
877 else {
878 refused = admitted.message;
879 code = "payment_required";
880 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look881 }
The statement is a month at a time, a line per kind of charge882 await this.db
883 .prepare(
884 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.885 script, status, error, token_hash, trusted, created_by, created_at, finished_at, run_id, job_id)
886 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
The statement is a month at a time, a line per kind of charge887 )
888 .bind(
889 id,
890 project.id,
891 project.workspace,
892 project.slug,
893 repo.id,
894 `${repo.path.namespace}/${repo.path.name}`,
895 input.kind,
896 input.branch,
897 input.number,
898 input.commit,
899 script,
900 refused ? "skipped" : "queued",
901 refused,
902 refused ? null : await sha256(token),
903 input.trusted ? 1 : 0,
904 input.createdBy,
905 now(),
906 refused ? now() : null,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.907 input.workflow?.runId ?? null,
908 input.workflow?.jobId ?? null,
The statement is a month at a time, a line per kind of charge909 )
910 .run();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.911 if (refused) return { refused, code, id };
The statement is a month at a time, a line per kind of charge912 // Older builds of the same app are replaced by this one.
913 await this.db
914 .prepare(
915 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
916 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
917 )
918 .bind(now(), script, id)
919 .run();
920 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-a69aeabc4b0deeb97921 await this.buildChanged(id, true);
The statement is a month at a time, a line per kind of charge922 // What the project's secrets and variables give builds of this kind.
923 const build = await this.resolve(
924 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
925 input.kind,
926 input.trusted,
927 );
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.928 return { refused: null, id, token, script, build, compute };
929 }
930
931 /**
932 * For a g1t Actions job (`POST /repos/{owner}/{repo}/deployments/builds`):
933 * opens a g1t.page build of one of the repository's projects, which the
934 * job builds in its own checkout with `g1t-runner deploy`. The caller is
935 * the job's own token (`token.job`), which needs `deployments: write` and
936 * reaches this repository only. The run decides what is built: a run on
937 * the project's production branch builds production, a pull request's
938 * run its preview, a run on another branch that branch's preview. A pull
939 * request from a fork or from someone who cannot push here, and a run
940 * that needed approval, get no build secrets. The job pays for its own
941 * time, so nothing is reserved or charged here for the build; the plan
942 * and the spend limit are checked as for any build.
943 */
944 async startFromWorkflow(a: { actor: User; repo: RepoPath; project?: string | null; sha?: string | null }): Promise<Result<WorkflowBuild>> {
945 const repoPath = { namespace: String(a.repo?.namespace ?? ""), name: String(a.repo?.name ?? "") };
946 const job = jobOf(a.actor, repoPath);
947 if ("error" in job) return fail("forbidden", job.error);
948 const actor = await this.workspaceActor(repoPath.namespace);
949 if (!actor) return fail("not_found", "There is no such repository.");
950 const found = await reposClient(this.env.REPOS).get(repoPath, actor);
951 if (!found.ok) return fail("not_found", "There is no such repository.");
952 const projects = await this.projects.byRepo(found.value.id);
953 const rows = new Map(await Promise.all(projects.map(async (p) => [p.id, await this.settingsRow(p.id)] as const)));
954 const live = (row: SettingsRow | null | undefined): row is SettingsRow =>
955 !!row?.enabled && !row.repo_deleted_at && !row.workspace_deleted_at;
956 const chosen = projectNamed(projects, (p) => live(rows.get(p.id)), a.project);
957 if ("error" in chosen) return fail(chosen.code, chosen.error);
958 const project = chosen;
959 if (project.source.kind !== "hosted") return fail("conflict", "Only projects hosted on g1t deploy so far.");
960 // A token that writes deployments needs the role a redeploy does; one
961 // that does not is let through below only for a managed preview.
962 if (job.writes && !can(a.actor, repoRef(project), NEEDS.redeploy)) return fail("forbidden", needs(NEEDS.redeploy));
963 const settings = rows.get(project.id);
964 if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`);
965 const repo = repoOf(project);
966
967 // The run and its job, as the actions service has them: still running.
968 const detail = await actionsClient(this.env.ACTIONS).run(repo.path, actor, job.runId);
969 if (!detail.ok) return fail("not_found", "The job's run was not found.");
970 const { run } = detail.value;
971 const running = detail.value.jobs.find((j) => j.id === job.jobId);
972 if (run.status === "completed" || !running || running.status === "completed") {
973 return fail("conflict", "The job has finished: a build is opened from a job while it runs.");
974 }
975 const commit = commitOf(a.sha, run);
976 if (typeof commit !== "string") return fail("invalid", commit.error);
977
978 const target = targetOf(run, repo.defaultBranch);
979 if ("error" in target) return fail("invalid", target.error);
980 if (!job.writes && !readOnlyAllowed(run, target)) return fail("forbidden", NEEDS_WRITE);
981 let kind: DeployKind = "production";
982 let branch: string | null = null;
983 let number: number | null = null;
984 let trusted = runTrusted({ approval: detail.value.approval, pull: null, insider: true });
985 if (target.kind === "pull") {
986 const pull = await workClient(this.env.WORK).getPull(repo.path, target.number, actor);
987 if (!pull.ok) return fail("not_found", `Pull request #${target.number} was not found.`);
988 const open = pull.value.pull;
989 if (open.status !== "open" && open.status !== "draft") {
990 return fail("conflict", `Pull request #${target.number} is not open, so it has no preview.`);
991 }
992 kind = "preview";
993 // A pull request from a fork has no branch here (see `deployPreview`).
994 branch = open.branch ?? `pr-${target.number}`;
995 number = target.number;
996 trusted = runTrusted({
997 approval: detail.value.approval,
998 pull: open,
999 insider: await this.insider(repo.path, workOwner(open), actor),
1000 });
1001 } else if (target.kind === "branch") {
1002 kind = "preview";
1003 branch = target.branch;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1004 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1005 // A read-only token's build never gets the project's build secrets.
1006 if (!job.writes) trusted = false;
1007 if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`);
1008 if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`);
1009
1010 const opened = await this.open(
1011 { project, kind, branch, number, commit, createdBy: run.actor ?? "g1t", settings, trusted, workflow: job },
1012 { reserve: false },
1013 );
1014 if (opened.refused !== null) return fail(opened.code, opened.refused);
1015 return ok({
1016 deploy_id: opened.id,
1017 token: opened.token,
1018 kind,
1019 project: project.slug,
1020 branch,
1021 number,
1022 commit,
1023 url: appUrl(opened.script),
1024 trusted,
1025 root_dir: project.source.rootDir ?? "",
1026 build_command: settings.build_command,
1027 output_dir: settings.output_dir,
1028 build_env: opened.build.variables,
1029 build_secrets: opened.build.secrets,
1030 });
The statement is a month at a time, a line per kind of charge1031 }
1032
1033 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
1034 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 member1035 if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
The statement is a month at a time, a line per kind of charge1036 const actor = await this.workspaceActor(project.workspace);
1037 if (!actor) return null;
1038 const repo = repoOf(project);
1039 let head = commit;
1040 if (!head) {
1041 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
1042 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
1043 }
1044 if (!head) return null;
1045 return this.start({
1046 project,
1047 kind: "production",
1048 branch: null,
1049 number: null,
1050 commit: head,
1051 source: repo.path,
1052 reader: actor,
1053 createdBy,
1054 settings,
1055 // The default branch only moves by people and agents with access.
1056 trusted: true,
1057 });
1058 }
1059
1060 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
1061 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 member1062 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
The statement is a month at a time, a line per kind of charge1063 const actor = await this.workspaceActor(project.workspace);
1064 if (!actor) return null;
1065 const repo = repoOf(project);
1066 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
1067 if (!detail.ok) return null;
1068 const { pull } = detail.value;
1069 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
1070 // A pull request from a fork (as g1t's agents work) has no branch here.
1071 const branch = pull.branch ?? `pr-${number}`;
1072 if (!force) {
1073 // Already built, or being built, at this commit.
1074 const same = await this.db
1075 .prepare(
1076 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
1077 AND status IN ('queued', 'building', 'ready')`,
1078 )
1079 .bind(project.id, branch, pull.headCommit)
1080 .first();
1081 if (same) return null;
1082 }
1083 return this.start({
1084 project,
1085 kind: "preview",
1086 branch,
1087 number,
1088 commit: pull.headCommit,
1089 source: pull.fork ?? repo.path,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1090 // The pull request's fork may be private: read it as whoever it is
1091 // for (whoever asked g1t for it, or its author), who is also who is
1092 // trusted or not with the project's secrets.
1093 reader: workOwner(pull),
The statement is a month at a time, a line per kind of charge1094 createdBy,
1095 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1096 trusted: await this.insider(repo.path, workOwner(pull), actor),
The statement is a month at a time, a line per kind of charge1097 });
1098 }
1099
1100 // ---- A build's reports ---------------------------------------------
1101
1102 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
1103 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
1104 }
1105
1106 /** The build, if `token` is its own and it is still under way. */
1107 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
1108 const row = await this.deploymentRow(id);
1109 if (!row?.token_hash || typeof token !== "string") return null;
1110 if (row.token_hash !== (await sha256(token))) return null;
1111 return row.status === "queued" || row.status === "building" ? row : null;
1112 }
1113
1114 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
1115 // Each report, for the logs: a build's own failure says why.
1116 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
1117 const row = await this.building(id, body.token);
1118 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
1119 const cloudflare = this.cloudflare;
1120 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
1121 switch (step) {
1122 case "started":
1123 await this.db
1124 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
1125 .bind(now(), id)
1126 .run();
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971127 await this.buildChanged(id);
The statement is a month at a time, a line per kind of charge1128 return Response.json(ok(true));
1129 case "session": {
1130 const manifest = body.manifest as Manifest | undefined;
1131 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
1132 const session = await cloudflare.openUpload(row.script, manifest);
1133 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
1134 }
1135 case "finish": {
1136 const worker = (body.worker ?? {}) as BuiltWorker;
1137 const seconds = Number(body.buildSeconds) || 0;
1138 const [namespace, name] = row.repo.split("/") as [string, string];
1139 try {
1140 // Running apps' secrets and variables are bound here, by g1t:
1141 // they never pass through the build's sandbox.
1142 const runtime = await this.resolve(
1143 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1144 row.kind,
1145 !!row.trusted,
1146 );
1147 await cloudflare.putScript(
1148 row.script,
1149 worker,
1150 typeof body.completionJwt === "string" ? body.completionJwt : null,
1151 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
1152 runtime,
1153 );
1154 } catch (error) {
1155 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1156 return Response.json(ok(false));
1157 }
1158 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1159 const wasRedirect = await this.db.prepare("SELECT 1 FROM redirects WHERE script = ?").bind(row.script).first();
The statement is a month at a time, a line per kind of charge1160 await this.db.batch([
1161 this.db
1162 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1163 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
The statement is a month at a time, a line per kind of charge1164 WHERE id = ?`,
1165 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1166 .bind(
1167 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1168 String(body.log ?? ""),
1169 seconds,
1170 at,
1171 detectedKind(body.detected),
1172 id,
1173 ),
The statement is a month at a time, a line per kind of charge1174 // The build it replaces is no longer what the app serves.
1175 this.db
1176 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1177 .bind(row.script, id),
1178 this.db
1179 .prepare(
1180 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1181 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
1182 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`,
1183 )
1184 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1185 // A name that redirected elsewhere (a move undone) is an app again.
The statement is a month at a time, a line per kind of charge1186 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
1187 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1188 if (wasRedirect) {
1189 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1190 }
1191 // The same app at an older name (its project moved) now redirects
1192 // here; it stays up as it was if the redirect cannot be put.
1193 await this.supersede(cloudflare, row, row.script);
The statement is a month at a time, a line per kind of charge1194 // The project's own domains serve production wherever it is up.
1195 if (row.kind === "production") {
1196 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1197 }
1198 await this.chargeBuild(row, seconds);
1199 await this.notePeak(row.workspace);
1200 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 published1201 await this.announce(row, null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971202 await this.buildChanged(id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1203 // A screenshot of production as it now is, for the project's overview.
1204 if (row.kind === "production") {
1205 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1206 console.error("could not ask for a screenshot", error),
1207 );
1208 }
The statement is a month at a time, a line per kind of charge1209 return Response.json(ok(true));
1210 }
1211 case "fail":
1212 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1213 return Response.json(ok(true));
1214 default:
1215 return Response.json(fail("not_found", "No such step."), { status: 404 });
1216 }
1217 }
1218
1219 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1220 * Older names of the app `script`, now up: one project's production, or
The statement is a month at a time, a line per kind of charge1221 * its preview of one branch, has one name, so any other app row for the
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1222 * same is the app under a name it had before its project moved (its
1223 * workspace renamed, its repository renamed or transferred). Each is
1224 * replaced in the namespace by a redirect to the new name, its app row
1225 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1226 * the sweep to hold the old script) and in `DOMAINS` under the old
1227 * hostname, which the dispatcher follows before running anything, so the
1228 * old address redirects even if the old script is paused. A name that
1229 * cannot be redirected is left as it is, for the next deploy or sweep.
The statement is a month at a time, a line per kind of charge1230 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1231 private async supersede(
1232 cloudflare: Cloudflare,
1233 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1234 script: string,
1235 ): Promise<string[]> {
The statement is a month at a time, a line per kind of charge1236 const older = await this.db
1237 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1238 .bind(app.project_id, app.kind, app.branch, script)
The statement is a month at a time, a line per kind of charge1239 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1240 const target = appHost(script);
The statement is a month at a time, a line per kind of charge1241 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1242 for (const { script: old } of older.results) {
The statement is a month at a time, a line per kind of charge1243 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1244 await cloudflare.redirectScript(old, target);
The statement is a month at a time, a line per kind of charge1245 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1246 console.error("could not redirect", old, "to", script, error);
1247 continue;
The statement is a month at a time, a line per kind of charge1248 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1249 const at = now();
1250 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1251 await this.db.batch([
1252 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1253 this.db
1254 .prepare(
1255 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1256 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1257 )
1258 .bind(old, target, app.workspace, at, expires),
1259 // Its builds are no longer live under the old name.
1260 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1261 ]);
1262 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1263 expiration: Math.floor(Date.parse(expires) / 1000),
1264 }).catch((error) => console.error("could not record redirect", old, error));
1265 done.push(old);
The statement is a month at a time, a line per kind of charge1266 }
1267 return done;
1268 }
1269
1270 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1271 const row = await this.deploymentRow(id);
1272 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1273 // A commit that is gone says so plainly; git's own words stay in the log.
1274 if (commitMissing(message)) {
1275 log = log ?? message;
1276 message = missingCommitMessage(row);
1277 }
The statement is a month at a time, a line per kind of charge1278 await this.db
1279 .prepare(
1280 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1281 WHERE id = ?`,
1282 )
1283 .bind(message.slice(0, 2000), log, seconds, now(), id)
1284 .run();
1285 // A failed build still used its sandbox.
1286 if (seconds) await this.chargeBuild(row, seconds);
1287 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1288 await this.announce(row, message.slice(0, 300));
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971289 await this.buildChanged(id);
Events: review requests, assignments, stops and deployments are published1290 }
1291
1292 /**
1293 * Publishes a finished build: `deployment.failed` with what went wrong,
1294 * or `deployment.succeeded`, saying whether the build of the same app
1295 * before it failed. Whoever started it is the actor when it was a
1296 * person known by id; otherwise `triggeredBy` names them, or g1t.
1297 */
1298 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1299 if (!this.env.EVENTS) return;
1300 const previous = error
1301 ? null
1302 : await this.db
1303 .prepare(
1304 `SELECT status FROM deployments
1305 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1306 ORDER BY created_at DESC LIMIT 1`,
1307 )
1308 .bind(row.script, row.id, row.created_at)
1309 .first<{ status: string }>();
1310 const byId = row.created_by.startsWith("usr_");
1311 const event = {
1312 source: "deployments",
1313 repoId: row.repo_id,
1314 actor: byId ? row.created_by : null,
1315 data: {
1316 deploymentId: row.id,
1317 projectId: row.project_id,
1318 repoId: row.repo_id,
1319 workspace: row.workspace,
1320 project: row.slug,
1321 kind: row.kind,
1322 branch: row.branch,
1323 number: row.kind === "preview" ? row.number : null,
1324 commit: row.commit_sha,
1325 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1326 error,
1327 recovered: previous?.status === "failed",
1328 triggeredBy: byId ? "" : row.created_by,
1329 },
1330 };
1331 await eventsClient(this.env.EVENTS)
1332 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1333 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
The statement is a month at a time, a line per kind of charge1334 }
1335
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1336 /**
1337 * Each build is charged by the second, from the first, at the container
1338 * price plus the margin. A build a workflow job ran is not: the job's
1339 * time is charged as Actions time.
1340 */
The statement is a month at a time, a line per kind of charge1341 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1342 if (row.run_id) return;
The statement is a month at a time, a line per kind of charge1343 const costs = await this.costs();
1344 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
1345 if (cost <= 0) return;
1346 const what =
1347 row.kind === "preview"
1348 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1349 : `${row.workspace}/${row.slug} to production`;
1350 await billingClient(this.env.BILLING).chargeFeature({
1351 workspace: row.workspace,
1352 feature: "deployments",
1353 costMicros: cost,
1354 description: `Building ${what} (${Math.ceil(seconds)} s)`,
1355 repo: row.repo,
1356 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1357 // Every second is metered; billing prices it from its price book and
1358 // tallies the month's build time.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1359 buildSeconds: Math.ceil(seconds),
The statement is a month at a time, a line per kind of charge1360 });
1361 await this.db
1362 .prepare(
1363 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1364 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1365 )
1366 .bind(row.workspace, month(), Math.ceil(seconds), cost)
1367 .run();
1368 }
1369
1370 /**
1371 * What each unit costs g1t now, from billing's price book, which follows
1372 * what Cloudflare bills. The plan's figures if billing cannot say.
1373 */
1374 private async costs(): Promise<{
1375 buildSecond: number;
1376 millionRequests: number;
1377 millionCpuMs: number;
1378 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1379 /** What one custom domain is charged a month: the cost plus the margin. */
1380 domainMonthPrice: number;
The statement is a month at a time, a line per kind of charge1381 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1382 const a = DEPLOYMENT_COSTS;
The statement is a month at a time, a line per kind of charge1383 const book = await billingClient(this.env.BILLING)
1384 .prices()
1385 .catch(() => null);
1386 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1387 return {
1388 buildSecond: cost("build_second", a.microsPerBuildSecond),
1389 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1390 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1391 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1392 domainMonthPrice:
1393 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
The statement is a month at a time, a line per kind of charge1394 };
1395 }
1396
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1397 /** Remembers the most apps (for information; never charged) and custom domains the workspace had at once this month. */
The statement is a month at a time, a line per kind of charge1398 private async notePeak(workspace: string): Promise<void> {
1399 await this.db
1400 .prepare(
1401 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1402 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1403 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
1404 ON CONFLICT (namespace, month) DO UPDATE SET
1405 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1406 peak_domains = MAX(peak_domains, (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))`,
1407 )
1408 .bind(workspace, month())
1409 .run();
1410 }
1411
1412 /**
1413 * The check on the commit: `g1t / deploy`, or, for one of several
1414 * projects on a repository, `g1t / deploy (<project>)`.
1415 */
1416 private async status(
1417 repoId: string,
1418 sha: string,
1419 project: { slug: string; primary: boolean },
1420 state: string,
1421 description: string,
1422 targetUrl: string,
1423 ): Promise<void> {
1424 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
1425 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1426 method: "POST",
1427 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1428 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
The statement is a month at a time, a line per kind of charge1429 }).catch(() => undefined);
1430 }
1431
1432 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1433 const projects = await this.projects.byRepo(row.repo_id);
1434 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1435 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1436 }
1437
1438 // ---- Taking apps down ----------------------------------------------
1439
1440 private async removeApp(script: string): Promise<void> {
1441 await this.cloudflare?.deleteScript(script);
1442 await this.db.batch([
1443 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1444 // Its build is no longer live anywhere.
1445 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1446 ]);
1447 }
1448
1449 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
1450 const apps = await this.db
1451 .prepare(
1452 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
1453 )
1454 .bind(projectId, kind, branch ?? null)
1455 .all<{ script: string }>();
1456 for (const app of apps.results) await this.removeApp(app.script);
1457 }
1458
1459 // ---- Events --------------------------------------------------------
1460
1461 /** `attempts`: which delivery of the event this is, from 1. */
1462 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
1463 switch (event.type) {
1464 case "workspace.renamed":
1465 await this.renamed(event.data, attempts);
1466 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1467 case "repo.transferred":
1468 case "repo.renamed":
1469 await this.moved(repoMove(event)!, attempts);
1470 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1471 // A repository that went or came back with its workspace is the
1472 // workspace's to handle: its apps are paused, not taken down.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1473 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1474 if (!event.data.withWorkspace) await this.repoDeleted(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1475 break;
1476 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1477 if (!event.data.withWorkspace) await this.repoRestored(event.data.repoId, attempts);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1478 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1479 case "workspace.deleting":
1480 await this.workspaceDeleting(event.data.slug);
1481 break;
1482 case "workspace.restored":
1483 await this.workspaceRestored(event.data.slug);
1484 break;
1485 case "workspace.deleted":
1486 await this.workspacePurged(event.data.slug);
1487 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1488 case "repo.purged":
1489 await this.repoPurged(event.data.repoId);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971490 await this.repoDeployments.purge(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1491 break;
1492 case "repo.default_branch_changed":
1493 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1494 break;
1495 case "branch.renamed":
1496 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1497 break;
1498
The statement is a month at a time, a line per kind of charge1499 case "pull.opened":
1500 case "pull.ready":
1501 case "pull.updated":
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1502 case "pull.reopened":
The statement is a month at a time, a line per kind of charge1503 for (const project of await this.projects.byRepo(event.data.repoId)) {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1504 // Its managed deploy workflow builds it instead (`syncManagedDeploy`).
1505 if (await this.buildsViaWorkflow(project.id)) continue;
The statement is a month at a time, a line per kind of charge1506 await this.deployPreview(project, event.data.number, "g1t");
1507 }
1508 break;
1509 case "pull.closed":
1510 case "pull.merged":
1511 for (const project of await this.projects.byRepo(event.data.repoId)) {
1512 const apps = await this.db
1513 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1514 .bind(project.id, event.data.number)
1515 .all<{ script: string }>();
1516 for (const app of apps.results) await this.removeApp(app.script);
1517 }
1518 break;
1519 case "git.push":
1520 if (!event.data.defaultBranch) break;
Merge branch 'mirroring' into artifacts-mode1521 // A mirror deploys only while g1t leads it: standing by, or in CI
1522 // failover, the remote's own deploys stand (see contracts mirrors).
1523 if (!mirrorWritable(event.data.mirror)) break;
The statement is a month at a time, a line per kind of charge1524 for (const project of await this.projects.byRepo(event.data.repoId)) {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1525 if (await this.buildsViaWorkflow(project.id)) continue;
The statement is a month at a time, a line per kind of charge1526 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1527 }
1528 break;
1529 }
1530 }
1531
1532 /**
1533 * A workspace's slug changed, and with it every app's name: production
1534 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1535 *
1536 * Its rows move to the slug it has now (asked of identity, so a delivery
1537 * twice over, or an older rename after a newer one, ends the same), and
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1538 * each app (paused or not) is built again from the same commit under its
1539 * new name; see `followMoves`. The old name keeps serving the app until
1540 * the new one is live; then it redirects to the new name (see
1541 * `supersede`), held for as long as the workspace holds its old slug.
The statement is a month at a time, a line per kind of charge1542 * Builds under way for the old name are dropped and started again under
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1543 * the new one.
The statement is a month at a time, a line per kind of charge1544 */
1545 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1546 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1547 const stale = staleSlugs(renamed, current);
1548 if (stale.length === 0) return;
1549 const marks = stale.map(() => "?").join(", ");
1550
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1551 // The workspace's projects, under any of its names: a second delivery
1552 // finds them under the new one, with whatever is left to do.
1553 const rows = await this.db
1554 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1555 .bind(...stale, current)
1556 .all<{ project_id: string }>();
1557 const projectIds = rows.results.map((row) => row.project_id);
1558 const projects = await this.projectsById(projectIds);
1559 // The projects service hears of the rename on its own queue: wait for
1560 // it a few deliveries, so the builds read the repository by its new name.
1561 const behind = [...projects.values()].some((p) => p.workspace !== current);
1562 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
The statement is a month at a time, a line per kind of charge1563
1564 // Every row moves at once.
1565 const at = now();
1566 const statements: D1PreparedStatement[] = [];
1567 for (const slug of stale) {
1568 statements.push(
1569 this.db
1570 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1571 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
The statement is a month at a time, a line per kind of charge1572 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1573 .bind(RENAMED_ERROR, at, slug),
The statement is a month at a time, a line per kind of charge1574 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1575 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1576 this.db
1577 .prepare(
1578 `UPDATE deployments SET workspace = ?1,
1579 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1580 WHERE workspace = ?2`,
1581 )
1582 .bind(current, slug),
1583 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1584 this.domains.rename(slug, current),
1585 // Counters add up; the peak is the higher; a month charged stays charged.
1586 this.db
1587 .prepare(
1588 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1589 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1590 FROM meters WHERE namespace = ?2
1591 ON CONFLICT (namespace, month) DO UPDATE SET
1592 requests = requests + excluded.requests,
1593 cpu_ms = cpu_ms + excluded.cpu_ms,
1594 peak_apps = MAX(peak_apps, excluded.peak_apps),
1595 peak_domains = MAX(peak_domains, excluded.peak_domains),
1596 build_seconds = build_seconds + excluded.build_seconds,
1597 build_micros = build_micros + excluded.build_micros,
1598 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1599 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1600 )
1601 .bind(current, slug),
1602 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1603 );
1604 }
1605 await this.db.batch(statements);
1606
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 deploy1607 // Each app again, under its new name.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1608 const followed = await this.followMoves(projectIds, {
1609 projects,
1610 adjust: (project) => this.underSlug(project, current, stale),
1611 });
1612 if (followed.failed.length > 0) {
1613 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
The statement is a month at a time, a line per kind of charge1614 }
1615 }
1616
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1617 /**
1618 * A repository moved: transferred to another workspace, and its projects
1619 * with it, or renamed within its own, when its own project's slug follows
1620 * its name (see the projects service). Each app's name is
1621 * `<project>-<workspace>`, so the apps of every project whose workspace or
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1622 * slug changed (production and every preview, paused or not) are built
1623 * again, from the same commit, under the new name; see `followMoves`. As
1624 * after a workspace rename, the old name keeps serving until the new one
1625 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1626 * The project's custom domains follow its production. Builds under way
1627 * are started again under the new name. What the apps used this month
1628 * stays on the old workspace's meter; from now on, the new workspace's
1629 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1630 *
1631 * Projects whose name did not change (a rename where the new name was
1632 * taken, or a project of another name) only learn the repository's new
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1633 * path. What is left to rebuild is read from the rows each time, so a
1634 * second or late delivery builds only what the first could not, and one
1635 * whose rebuild could not be queued is delivered again (and, past the
1636 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1637 */
1638 private async moved(move: RepoMove, attempts: number): Promise<void> {
1639 const current = await currentMovedPath(this.env.REPOS, move);
1640 if (staleMovedPaths(move, current).length === 0) return;
1641 const [workspace, name] = current.split("/") as [string, string];
1642 const settings = await this.db
1643 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1644 .bind(move.repoId)
1645 .all<{ project_id: string; workspace: string; slug: string }>();
1646 if (settings.results.length === 0) return;
1647
1648 // The projects service hears of the move on its own queue: wait for it
1649 // a few deliveries, so builds read the project where and as it is now.
1650 const projects = new Map<string, Project>();
1651 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1652 const behind = settings.results.some(({ project_id }) => {
1653 const project = projects.get(project_id);
1654 if (!project || project.source.kind !== "hosted") return false;
1655 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1656 });
1657 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1658
1659 // Its history goes with it, as the repository's issues do.
1660 const statements: D1PreparedStatement[] = [
1661 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1662 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1663 // The projects whose apps' names change, and have not been moved yet.
1664 const moving = settings.results
1665 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1666 .map((row) => row.project_id);
1667 if (moving.length > 0) {
1668 const ids = moving.map(() => "?").join(", ");
1669 statements.push(
1670 this.db
1671 .prepare(
1672 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1673 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1674 )
1675 .bind(MOVED_ERROR, now(), ...moving),
1676 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1677 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1678 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1679 const slug = projects.get(projectId)?.slug ?? null;
1680 statements.push(
1681 this.db
1682 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1683 .bind(workspace, slug, projectId),
1684 this.db
1685 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1686 .bind(workspace, slug, projectId),
1687 this.db
1688 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1689 .bind(workspace, slug, projectId),
1690 // Within the workspace its apps are listed under the project's name
1691 // now. One left behind in another workspace stays as it is until the
1692 // new name is live and redirects it.
1693 this.db
1694 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1695 .bind(workspace, slug, projectId),
1696 );
1697 }
1698 await this.db.batch(statements);
1699
1700 // Each app again, under its new name. App rows under the old name stay
1701 // until the new one is live, which redirects them.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1702 const followed = await this.followMoves(
1703 settings.results.map((row) => row.project_id),
1704 {
1705 projects,
1706 adjust: (found) =>
1707 found.source.kind === "hosted"
1708 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1709 : { ...found, workspace },
1710 },
1711 );
1712 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1713 }
1714
1715 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1716 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1717 const projects = new Map<string, Project>();
1718 if (projectIds.length === 0) return projects;
1719 const repoIds = new Set<string>();
1720 for (let i = 0; i < projectIds.length; i += 50) {
1721 const chunk = projectIds.slice(i, i + 50);
1722 const rows = await this.db
1723 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1724 .bind(...chunk)
1725 .all<{ repo_id: string }>();
1726 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1727 }
1728 const wanted = new Set(projectIds);
1729 for (const repoId of repoIds) {
1730 for (const project of await this.projects.byRepo(repoId)) {
1731 if (wanted.has(project.id)) projects.set(project.id, project);
1732 }
1733 }
1734 return projects;
1735 }
1736
1737 /** Whether `script` is the name an app of the project has where the project is now. */
1738 private async namedNow(
1739 script: string,
1740 settings: { project_id: string; workspace: string; slug: string },
1741 branch: string | null,
1742 ): Promise<boolean> {
1743 const base = await label(settings.workspace, settings.slug, branch);
1744 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1745 }
1746
1747 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1748 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1749 const stale: AppRow[] = [];
1750 for (const app of apps) {
1751 const row = settings.get(app.project_id);
1752 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1753 }
1754 return stale;
1755 }
1756
1757 /**
1758 * Brings the apps of the projects `projectIds` (every project when null)
1759 * under the names they have where the projects are now: each app still
1760 * under an older name, paused or not, and each build a move dropped, is
1761 * built again under its new name from the same commit, and once that is
1762 * live the old name redirects to it (`supersede`).
1763 *
1764 * Idempotent, and safe to run again at any time: an app already up under
1765 * its new name only has its old names redirected; one whose rebuild is
1766 * queued or under way is left to it; one whose workspace has no
1767 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1768 * each time; one whose commit is gone, or whose rebuild failed the same
1769 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1770 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1771 * doubled for each failure, waits. Returns what could not be queued, for an
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1772 * event's delivery to be retried.
1773 */
1774 private async followMoves(
1775 projectIds: string[] | null,
1776 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1777 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1778 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1779 if (projectIds && projectIds.length === 0) return result;
1780 const cloudflare = this.cloudflare;
1781 if (!cloudflare) return result;
1782
1783 const settingsRows =
1784 projectIds == null
1785 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1786 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1787 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1788 if (settings.size === 0) return result;
1789 const ids = [...settings.keys()];
1790
1791 const apps: AppRow[] = [];
1792 const dropped: DroppedBuild[] = [];
1793 for (let i = 0; i < ids.length; i += 50) {
1794 const chunk = ids.slice(i, i + 50);
1795 const marks = chunk.map(() => "?").join(", ");
1796 const [appRows, droppedRows] = await Promise.all([
1797 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1798 // Builds a move dropped, that nothing has been built in place of since.
1799 this.db
1800 .prepare(
1801 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1802 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1803 AND NOT EXISTS (
1804 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1805 AND n.created_at > d.created_at AND n.status != 'skipped'
1806 )`,
1807 )
1808 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1809 .all<DeploymentRow>(),
1810 ]);
1811 apps.push(...appRows.results);
1812 dropped.push(...droppedRows.results);
1813 }
1814 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1815 if (targets.length === 0) return result;
1816
1817 const projects = new Map(options.projects ?? []);
1818 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1819 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1820 const billing = billingClient(this.env.BILLING);
1821 const open = new Map<string, boolean>();
1822 const actors = new Map<string, User | null>();
1823
1824 for (const target of targets) {
1825 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1826 const found = projects.get(target.projectId);
1827 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1828 const project = options.adjust ? options.adjust(found) : found;
1829 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1830 // Built only where deployments has the project now; the projects
1831 // service catches up on its own queue, and a later run builds then.
1832 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1833 result.waiting++;
1834 continue;
1835 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1836 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1837 const script = await this.scriptFor(project, target.branch);
1838 // Already up under its new name: only its old names are left to redirect.
1839 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1840 if (up) {
1841 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1842 continue;
1843 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1844 const history = await this.recentBuilds(script);
1845 const last = history[0];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1846 if (last && (last.status === "queued" || last.status === "building")) {
1847 result.queued++;
1848 continue;
1849 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1850 // A commit that is gone, or a build that failed the same way a few
1851 // times, is not tried again; a push or a redeploy builds it.
1852 // Otherwise the sweep waits longer after each failure.
1853 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff: options.backoff }).kind !== "build") {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1854 result.waiting++;
1855 continue;
1856 }
1857 // A workspace without Deployments, or over its limit, waits for it
1858 // rather than gathering refused deployments.
1859 if (!open.has(project.workspace)) {
1860 const [plan, limit] = await Promise.all([
1861 billing.hasFeature(project.workspace, "deployments"),
1862 billing.checkLimit(project.workspace),
1863 ]);
1864 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1865 }
1866 if (!open.get(project.workspace)) {
1867 result.waiting++;
1868 continue;
1869 }
1870 if (!actors.has(project.workspace)) actors.set(project.workspace, await this.workspaceActor(project.workspace));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1871 const started =
1872 target.kind === "production"
1873 ? await this.deployProduction(project, target.commit, "g1t")
1874 : target.number != null
1875 ? await this.deployPreview(project, target.number, "g1t", true)
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1876 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1877 const outcome = rebuildOutcome(started);
1878 if (outcome === "queued") result.queued++;
1879 else if (outcome === "failed") {
1880 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1881 result.failed.push(`${named}: ${why}`);
1882 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1883 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1884 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1885 }
1886 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1887 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1888 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1889 }
1890
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1891 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1892 private async recentBuilds(script: string): Promise<PastBuild[]> {
1893 const rows = await this.db
1894 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1895 .bind(script, MAX_IDENTICAL_FAILURES)
1896 .all<PastBuild>();
1897 return rows.results;
1898 }
1899
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1900 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1901 private async projectIdsFor(repoId: string): Promise<string[]> {
1902 const rows = await this.db
1903 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1904 .bind(repoId)
1905 .all<{ project_id: string }>();
1906 return rows.results.map((row) => row.project_id);
1907 }
1908
1909 /**
1910 * A repository was deleted, restorable for a while: every app of its
1911 * projects (production and previews) comes down, builds under way are
1912 * dropped, and nothing builds for it until it is restored. Its settings
1913 * and custom domains are kept for the restore; until then a domain has
1914 * nothing up to serve, as when production is turned off.
1915 */
1916 private async repoDeleted(repoId: string): Promise<void> {
1917 const projectIds = await this.projectIdsFor(repoId);
1918 if (projectIds.length === 0) return;
1919 const at = now();
1920 await this.db.batch([
1921 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1922 this.db
1923 .prepare(
1924 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1925 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1926 )
1927 .bind(at, repoId),
1928 ]);
1929 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1930 }
1931
1932 /**
1933 * A deleted repository is back: production goes up again from its
1934 * default branch, for each project that has it on. Previews come back
1935 * with the next push to their pull requests.
1936 */
1937 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1938 const deleted = await this.db
1939 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1940 .bind(repoId)
1941 .all<{ project_id: string }>();
1942 const ids = deleted.results.map((row) => row.project_id);
1943 if (ids.length === 0) return;
1944 // The projects service hears of the restore on its own queue, and hides
1945 // the projects until then: wait for it a few deliveries.
1946 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1947 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1948 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1949 for (const project of projects) {
1950 try {
1951 const started = await this.deployProduction(project, null, "g1t");
1952 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1953 } catch (error) {
1954 console.error("could not deploy after restore", project.slug, error);
1955 }
1956 }
1957 }
1958
1959 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1960 * A workspace was deleted, restorable by g1t's staff for a while: every
1961 * app of its projects (production and previews) is paused, answering with
1962 * a notice and running nothing, builds under way are dropped, and nothing
1963 * builds for it until it is restored. Nothing is taken down: scripts,
1964 * settings and custom domains are kept for the restore. Never for a
1965 * protected workspace, whatever was published.
1966 */
1967 private async workspaceDeleting(slug: string): Promise<void> {
1968 const workspace = slug.toLowerCase();
1969 if (isProtectedWorkspace(workspace)) {
1970 console.error("workspace.deleting ignored for protected", workspace);
1971 return;
1972 }
1973 const at = now();
1974 await this.db.batch([
1975 this.db
1976 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1977 .bind(at, workspace),
1978 this.db
1979 .prepare(
1980 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1981 WHERE workspace = ? AND status IN ('queued', 'building')`,
1982 )
1983 .bind(at, workspace),
1984 ]);
1985 const cloudflare = this.cloudflare;
1986 for (const app of await this.appsOfWorkspace(workspace)) {
1987 if (app.paused_at) continue;
1988 await cloudflare?.pauseScript(app.script);
1989 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1990 }
1991 }
1992
1993 /**
1994 * A deleted workspace is back: it builds again, and its paused apps are
1995 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1996 * (the sweep tries again any it could not).
1997 */
1998 private async workspaceRestored(slug: string): Promise<void> {
1999 const workspace = slug.toLowerCase();
2000 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
2001 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
2002 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
2003 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
2004 }
2005
2006 /**
2007 * A deleted workspace is purged: whatever its projects still have up
2008 * comes down and their custom domains go, as for a purged repository.
2009 * Its own repositories' projects are purged with them (`repo.purged`);
2010 * this catches any building from a repository it had transferred away.
2011 */
2012 private async workspacePurged(slug: string): Promise<void> {
2013 const workspace = slug.toLowerCase();
2014 if (isProtectedWorkspace(workspace)) return;
2015 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
2016 for (const { project_id } of rows.results) {
2017 await this.takeDownWhere(project_id, null);
2018 await this.domains.removeWhere("project_id", project_id);
2019 }
2020 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
2021 await this.db.batch([
2022 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
2023 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
2024 ]);
2025 }
2026
2027 /** The apps of a workspace's projects, and any still under its name. */
2028 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
2029 const rows = await this.db
2030 .prepare(
2031 `SELECT * FROM apps WHERE workspace = ?1
2032 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
2033 )
2034 .bind(workspace)
2035 .all<AppRow>();
2036 return rows.results;
2037 }
2038
2039 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2040 * A deleted repository is gone for good: its projects' custom domains are
2041 * removed (from the dispatcher and from Cloudflare), any app or redirect
2042 * still up comes down, and every row kept for them goes. What they used
2043 * stays on their workspace's meter.
2044 */
2045 private async repoPurged(repoId: string): Promise<void> {
2046 const projectIds = await this.projectIdsFor(repoId);
2047 const domains = this.domains;
2048 for (const projectId of projectIds) {
2049 await this.takeDownWhere(projectId, null);
2050 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
2051 await domains.removeWhere("project_id", projectId);
2052 }
2053 // The redirects left at names its apps had before.
2054 const scripts = await this.db
2055 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
2056 .bind(repoId)
2057 .all<{ script: string }>();
2058 const hosts = scripts.results.map(({ script }) => appHost(script));
2059 for (let i = 0; i < hosts.length; i += 50) {
2060 const chunk = hosts.slice(i, i + 50);
2061 const redirects = await this.db
2062 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
2063 .bind(...chunk)
2064 .all<{ script: string }>();
2065 for (const { script } of redirects.results) {
2066 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2067 await this.env.DOMAINS?.delete(appHost(script)).catch((error) => console.error("could not drop redirect", script, error));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2068 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
2069 }
2070 }
2071 await this.db.batch([
2072 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
2073 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
2074 ]);
2075 }
2076
2077 /**
2078 * The default branch is another one now: production is built from it, as
2079 * from a push to it, unless production already serves (or is building)
2080 * its commit, as when the default branch was only renamed.
2081 */
2082 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
2083 for (const found of await this.projects.byRepo(repoId)) {
2084 if (found.source.kind !== "hosted") continue;
2085 // Projects may not have heard yet: the event names the branch.
2086 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
2087 const actor = await this.workspaceActor(project.workspace);
2088 if (!actor) continue;
2089 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
2090 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
2091 if (!head) continue;
2092 const same = await this.db
2093 .prepare(
2094 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
2095 AND status IN ('queued', 'building', 'ready')`,
2096 )
2097 .bind(project.id, head)
2098 .first();
2099 if (same) continue;
2100 await this.deployProduction(project, head, createdBy);
2101 }
2102 }
2103
2104 /**
2105 * A branch was renamed: its preview is the same app, so its rows follow.
2106 * The app keeps its name until it is next built; then it goes up under
2107 * the new branch's name, and the old one redirects there (see `supersede`).
2108 */
2109 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
2110 const projectIds = await this.projectIdsFor(repoId);
2111 if (projectIds.length === 0) return;
2112 const ids = projectIds.map(() => "?").join(", ");
2113 await this.db.batch([
2114 this.db
2115 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2116 .bind(to, from, ...projectIds),
2117 this.db
2118 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2119 .bind(to, from, ...projectIds),
2120 ]);
2121 }
2122
The statement is a month at a time, a line per kind of charge2123 /** `project` under the workspace's slug now, whether or not projects has caught up. */
2124 private underSlug(project: Project, current: string, stale: string[]): Project {
2125 const source =
2126 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
2127 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
2128 : project.source;
2129 return { ...project, workspace: current, source };
2130 }
2131
2132 /** A stack's preview (no pull request of its own) built again at `commit`. */
2133 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
2134 if (!actor || branch == null) return null;
2135 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2136 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
The statement is a month at a time, a line per kind of charge2137 return this.start({
2138 project,
2139 kind: "preview",
2140 branch,
2141 number: null,
2142 commit,
2143 source: repoOf(project).path,
2144 reader: actor,
2145 createdBy: "g1t",
2146 settings,
2147 // As `stack` built it: the project's own default branch.
2148 trusted: true,
2149 });
2150 }
2151
2152 // ---- The sweep -----------------------------------------------------
2153
2154 /**
2155 * Every few minutes: builds that died are failed; usage is counted; idle
2156 * previews, the apps of workspaces whose plan ended, and scripts no app
2157 * holds come down; and a month that is over is charged past its
2158 * allowance.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2159 *
2160 * Each step stands alone: one that fails is logged and the rest still
2161 * run. Taking idle previews down and charging months, which only read
2162 * g1t's own tables, go before the steps that wait on Cloudflare's API,
2163 * so a slow or failing API never holds them up.
The statement is a month at a time, a line per kind of charge2164 */
2165 async sweep(): Promise<void> {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2166 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
2167 await step("failing stuck builds", async () => {
2168 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2169 const stuck = await this.db
2170 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2171 .bind(cutoff)
2172 .all<{ id: string }>();
2173 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2174 });
2175 await step("taking down idle previews", () => this.takeDownIdle());
2176 await step("charging months", () => this.chargeMonths());
The statement is a month at a time, a line per kind of charge2177
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2178 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2179 // Each app is its project's workspace's, as deployments has it now: an
2180 // app still under the name it had before its project moved is the new
2181 // workspace's, and plans and limits are checked there.
2182 const settings = new Map(
2183 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2184 );
2185 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 member2186 // A deleted workspace's apps stay paused as they are, for a restore:
2187 // its plan ended with the deletion, and that must not take them down.
2188 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2189 const live = apps.filter((app) => !held(app));
2190 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
The statement is a month at a time, a line per kind of charge2191
2192 // Apps of workspaces whose plan has ended come down.
2193 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2194 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
The statement is a month at a time, a line per kind of charge2195 for (const workspace of workspaces) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2196 await step(`ending ${workspace}'s apps`, async () => {
2197 const plan = await billing.hasFeature(workspace, "deployments");
2198 if (!plan.ok && plan.error.code === "payment_required") {
2199 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2200 // Custom domains cost g1t by the month: they go with the plan.
2201 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2202 }
2203 });
The statement is a month at a time, a line per kind of charge2204 }
2205
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2206 // Apps whose project moved and are not up under the new name yet: a
2207 // move's rebuild that could not start is tried again here.
2208 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2209 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2210
The statement is a month at a time, a line per kind of charge2211 await this.domains
2212 .sweep(async (projectId) => {
2213 const app = await this.db
2214 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2215 .bind(projectId)
2216 .first<{ script: string }>();
2217 return app?.script ?? null;
2218 })
2219 .catch((error) => console.error("could not check domains", error));
2220
2221 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
2222 await this.count(apps).catch((error) => console.error("could not count usage", error));
2223 }
2224
2225 /**
2226 * Pauses the apps of workspaces that reached their limit for usage not
2227 * yet paid for, and rebuilds them from the same commit once they are
2228 * under it again. Paused apps answer with a notice and run nothing.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2229 *
2230 * The workspace is the project's now (see `ownerOf`), never the one an
2231 * app's row was written under, so an app left under its old name after
2232 * a transfer is paused only if its new workspace is over its limit. Such
2233 * an app is resumed by being built under its new name (`followMoves`),
2234 * not here.
The statement is a month at a time, a line per kind of charge2235 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2236 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
The statement is a month at a time, a line per kind of charge2237 const cloudflare = this.cloudflare;
2238 if (!cloudflare) return;
2239 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2240 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2241 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
The statement is a month at a time, a line per kind of charge2242 const limit = await billing.checkLimit(workspace);
2243 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2244 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
The statement is a month at a time, a line per kind of charge2245 if (limit.value.state === "stopped") {
2246 for (const app of theirs.filter((a) => !a.paused_at)) {
2247 await cloudflare.pauseScript(app.script);
2248 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2249 }
2250 continue;
2251 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2252 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2253 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
The statement is a month at a time, a line per kind of charge2254 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2255 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
The statement is a month at a time, a line per kind of charge2256 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2257 const project = projects.get(app.project_id);
2258 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2259 // A failed or refused rebuild leaves it paused, to try again later:
2260 // longer after each failure, and not once its commit is gone or it
2261 // failed the same way a few times.
2262 const history = await this.recentBuilds(app.script);
2263 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2264 const backoff = history[0]?.status === "failed";
2265 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
The statement is a month at a time, a line per kind of charge2266 const rebuilt =
2267 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2268 ? await this.deployProduction(project, app.commit_sha, "g1t")
The statement is a month at a time, a line per kind of charge2269 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2270 ? await this.deployPreview(project, app.number, "g1t", true)
The statement is a month at a time, a line per kind of charge2271 : null;
2272 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2273 }
2274 }
2275 }
2276
2277 /**
2278 * Scripts in the namespace that no app holds, such as ones renamed. An
2279 * old address that redirects to its app's new one is held until its
2280 * redirect expires, then removed with the rest.
2281 */
2282 private async removeOrphans(apps: AppRow[]): Promise<void> {
2283 const cloudflare = this.cloudflare;
2284 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2285 // Their entries in `DOMAINS` expire on their own, at the same time.
The statement is a month at a time, a line per kind of charge2286 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2287 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2288 const held = new Set([...apps.map((app) => app.script), ...redirects.results.map((r) => r.script)]);
2289 const building = await this.db
2290 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2291 .all<{ script: string }>();
2292 for (const row of building.results) held.add(row.script);
2293 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2294 for (const script of await cloudflare.listScripts()) {
2295 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2296 }
2297 }
2298
2299 /** Counts this month's requests and CPU time per workspace, from analytics. */
2300 private async count(apps: AppRow[]): Promise<void> {
2301 const cloudflare = this.cloudflare;
2302 if (!cloudflare || apps.length === 0) return;
2303 const start = `${month()}-01T00:00:00Z`;
2304 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2305 // Analytics only counts apps that are up; the meter keeps what earlier
2306 // apps used by never going down.
2307 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2308 for (const app of apps) {
2309 const used = totals.get(app.script);
2310 if (!used) continue;
2311 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
2312 sum.requests += used.requests;
2313 sum.cpuMs += used.cpuMs;
2314 perWorkspace.set(app.workspace, sum);
2315 }
2316 const at = now();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2317 // Written only when the count moves the meter: most sweeps it does not.
2318 const stored = new Map(
2319 (
2320 await this.db
2321 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2322 .bind(month())
2323 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2324 ).results.map((row) => [row.namespace, row]),
2325 );
The statement is a month at a time, a line per kind of charge2326 for (const [workspace, used] of perWorkspace) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2327 if (!movesMeter(stored.get(workspace), used)) continue;
The statement is a month at a time, a line per kind of charge2328 await this.db
2329 .prepare(
2330 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2331 ON CONFLICT (namespace, month) DO UPDATE SET
2332 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2333 )
2334 .bind(workspace, month(), used.requests, used.cpuMs, at)
2335 .run();
2336 }
2337 // When each preview last answered anyone, for the idle sweep.
2338 const recent = await cloudflare.usage(
2339 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2340 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2341 at,
2342 );
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2343 // At an hour's resolution: previews idle for days are what it finds.
2344 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2345 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
The statement is a month at a time, a line per kind of charge2346 for (const [script, used] of recent) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2347 const last = lastRequest.get(script);
2348 if (used.requests > 0 && (!last || last < hourAgo)) {
The statement is a month at a time, a line per kind of charge2349 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2350 }
2351 }
2352 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2353 // What this month's traffic and custom domains will cost, from the
2354 // first request and the first domain, so the workspace's limit counts
2355 // it now rather than when the month closes, and its Billing page shows it.
The statement is a month at a time, a line per kind of charge2356 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2357 const billing = billingClient(this.env.BILLING);
2358 const meters = await this.db
2359 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2360 .bind(month())
2361 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2362 for (const meter of meters.results) {
2363 const cost = monthCost(meter, costs);
2364 await billing
2365 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
The statement is a month at a time, a line per kind of charge2366 .catch((error) => console.error("could not note pending usage", error));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2367 if ((meter.peak_domains ?? 0) > 0) {
2368 await billing
2369 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2370 .catch((error) => console.error("could not note pending usage", error));
2371 }
The statement is a month at a time, a line per kind of charge2372 }
2373 }
2374
2375 /** Previews no one has visited in their project's idle days. */
2376 private async takeDownIdle(): Promise<void> {
2377 const idle = await this.db
2378 .prepare(
2379 `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 member2380 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
The statement is a month at a time, a line per kind of charge2381 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2382 )
2383 .all<{ script: string }>();
2384 for (const { script } of idle.results) await this.removeApp(script);
2385 }
2386
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2387 /** Charges each month that is over for its requests, CPU time and custom domains, from the first, once. */
The statement is a month at a time, a line per kind of charge2388 private async chargeMonths(): Promise<void> {
2389 const due = await this.db
2390 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2391 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2392 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
The statement is a month at a time, a line per kind of charge2393 const costs = await this.costs();
2394 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2395 const cost = monthCost(meter, costs);
2396 if (cost.micros > 0) {
The statement is a month at a time, a line per kind of charge2397 const charged = await billingClient(this.env.BILLING).chargeFeature({
2398 workspace: meter.namespace,
2399 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2400 costMicros: cost.micros,
2401 description: `Deployments in ${meter.month}: ${cost.description}`,
The statement is a month at a time, a line per kind of charge2402 reference: `deployments/${meter.namespace}/${meter.month}`,
2403 });
2404 if (!charged.ok) continue;
2405 }
2406 await this.db
2407 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2408 .bind(now(), meter.namespace, meter.month)
2409 .run();
2410 }
2411 }
2412}
2413
2414/** `POST /rpc/<method>`: the arguments are the body. */
2415async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
2416 switch (method) {
2417 case "settings":
2418 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2419 case "is_enabled":
2420 return service.isEnabled(args);
The statement is a month at a time, a line per kind of charge2421 case "update_settings":
2422 return service.updateSettings(args);
2423 case "list":
2424 return service.list(args);
2425 case "get":
2426 return service.get(args);
2427 case "redeploy":
2428 return service.redeploy(args);
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.2429 // For the API: a g1t Actions job opening a build it runs itself.
2430 case "start_from_workflow":
2431 return service.startFromWorkflow(args);
The statement is a month at a time, a line per kind of charge2432 case "take_down":
2433 return service.takeDown(args);
2434 case "overview":
2435 return service.overview(args);
2436 case "usage":
2437 return service.usage(args);
2438 case "domains":
2439 return service.listDomains(args);
2440 case "add_domain":
2441 return service.addDomain(args);
2442 case "remove_domain":
2443 return service.removeDomain(args);
2444 case "refresh_domain":
2445 return service.refreshDomain(args);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972446 // A repository's deployments wherever they run (repo-deployments.ts).
2447 case "list_deployments":
2448 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2449 case "get_deployment":
2450 return service.repoDeployments.get(args);
2451 case "list_deployment_statuses":
2452 return service.repoDeployments.statuses(args);
2453 case "list_environments":
2454 return service.repoDeployments.environments(args);
2455 case "get_environment":
2456 return service.repoDeployments.environment(args);
2457 case "create_deployment":
2458 return service.repoDeployments.create(args);
2459 case "create_deployment_status":
2460 return service.repoDeployments.createStatus(args);
2461 // For the actions service: a run's deployment to one environment.
2462 case "actions_deployment":
2463 return service.repoDeployments.fromActions(args);
The statement is a month at a time, a line per kind of charge2464 default:
2465 return undefined;
2466 }
2467}
2468
2469export default {
2470 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2471 const { pathname } = new URL(request.url);
2472 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2473 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2474 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2475 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2476 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2477 const opened = openD1(env.DB, request);
2478 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
The statement is a month at a time, a line per kind of charge2479 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2480 return opened.finish(result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result));
The statement is a month at a time, a line per kind of charge2481 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2482 const service = new Deployments(env);
The statement is a month at a time, a line per kind of charge2483 // A build's reports, forwarded by the API.
2484 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2485 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2486 return new Response("Not found\n", { status: 404 });
2487 },
2488
2489 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2490 const service = new Deployments(env);
2491 for (const message of batch.messages) {
2492 try {
2493 await service.onEvent(message.body, message.attempts);
2494 message.ack();
2495 } catch (error) {
2496 console.error("deployments could not handle", message.body.type, error);
2497 message.retry();
2498 }
2499 }
2500 },
2501
2502 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2503 await new Deployments(env).sweep();
2504 },
2505} satisfies ExportedHandler<Env, G1tEvent>;

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