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