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