Commit

Deployments: a preview for every pull request, production on g1t.page

Every pull request gets a live preview at pr-<n>--<repo>--<owner>.g1t.page, shown on it as the check `g1t / deploy`, and the default branch deploys to <repo>--<owner>.g1t.page on each push. Apps are Workers in a Workers for Platforms namespace, so one no one visits runs nothing and costs nothing. A paid feature: the workspace turns on the Deployments plan, then each repository turns deployments on; one click turns either off. - services/deployments (new): hears pull requests opened, ready, pushed to, closed and merged, and pushes to the default branch; checks the plan with billing's `has_feature`; starts a build on the runner; opens Cloudflare's asset upload for the build's files and puts the app in the namespace; sets the commit status with the preview's address. A sweep every 10 minutes fails builds that died, counts requests and CPU time from Workers analytics, takes down previews idle past the repository's idle days and every app of a workspace whose plan ended, and charges a month that is over past its allowance, once. - services/pages (new): the dispatcher on *.g1t.page. The hostname's first label is the app's script name, so it needs no lookup. Previews are noindex; a missing app gets a page saying why. - Runner: MODE=deploy builds a Workers project (wrangler deploy --dry-run) or a static site (its build script, then dist, build, out, public, _site or .output/public), uploads the files with a key that can upload only them, and sends the bundle to g1t. No Cloudflare credential is ever in the sandbox. `start_deploy` on the runner Worker. - API: POST /deployments/jobs/<id>/<step> passes a build's reports, with its own token, to the deployments service. - Billing: builds are charged by the second at the container price plus 20% ($0.0015 a minute); prices under a cent show their digits. - Site: a repository's Deployments page (turn on, what is up, recent builds, redeploy, take down, settings, turn off) and each build's page with its log; the Billing plan card shows this month's use against the allowance. - Docs: guides/deployments (what deploys, previews, production, when apps come down, settings, costs, turning it off, how it works, troubleshooting, self-hosting); Plans in usage-and-billing; docs home and llms.txt.

syntaqxcommitted Parent6afb0c2Browse files
42 files+2911−110/42 viewed
+60−0
10061006 "serde_json",
10071007 "serde_yaml",
10081008 "sha2 0.10.9",
1009+ "toml",
10091010 "ureq",
10101011 ]
10111012
25892590 ]
25902591
25912592 [[package]]
2593+name = "serde_spanned"
2594+version = "0.6.9"
2595+source = "registry+https://github.com/rust-lang/crates.io-index"
2596+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
2597+dependencies = [
2598+ "serde",
2599+]
2600+
2601+[[package]]
25922602 name = "serde_urlencoded"
25932603 version = "0.7.1"
25942604 source = "registry+https://github.com/rust-lang/crates.io-index"
30253035 ]
30263036
30273037 [[package]]
3038+name = "toml"
3039+version = "0.8.23"
3040+source = "registry+https://github.com/rust-lang/crates.io-index"
3041+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
3042+dependencies = [
3043+ "serde",
3044+ "serde_spanned",
3045+ "toml_datetime",
3046+ "toml_edit",
3047+]
3048+
3049+[[package]]
3050+name = "toml_datetime"
3051+version = "0.6.11"
3052+source = "registry+https://github.com/rust-lang/crates.io-index"
3053+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
3054+dependencies = [
3055+ "serde",
3056+]
3057+
3058+[[package]]
3059+name = "toml_edit"
3060+version = "0.22.27"
3061+source = "registry+https://github.com/rust-lang/crates.io-index"
3062+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
3063+dependencies = [
3064+ "indexmap",
3065+ "serde",
3066+ "serde_spanned",
3067+ "toml_datetime",
3068+ "toml_write",
3069+ "winnow",
3070+]
3071+
3072+[[package]]
3073+name = "toml_write"
3074+version = "0.1.2"
3075+source = "registry+https://github.com/rust-lang/crates.io-index"
3076+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
3077+
3078+[[package]]
30283079 name = "tower"
30293080 version = "0.5.3"
30303081 source = "registry+https://github.com/rust-lang/crates.io-index"
35693620 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
35703621
35713622 [[package]]
3623+name = "winnow"
3624+version = "0.7.15"
3625+source = "registry+https://github.com/rust-lang/crates.io-index"
3626+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
3627+dependencies = [
3628+ "memchr",
3629+]
3630+
3631+[[package]]
35723632 name = "wnaf"
35733633 version = "0.14.1"
35743634 source = "registry+https://github.com/rust-lang/crates.io-index"
+20−0
347347 return receive_hook(&mut request, &services, id).await;
348348 }
349349
350+ // A sandbox building a deployment, reporting with its build's token,
351+ // which is not a g1t token. The body goes through as it is: it can
352+ // carry a Worker's bundled code.
353+ if method == "POST" && !on_mcp
354+ && let Some(rest) = path.strip_prefix("/deployments/jobs/")
355+ {
356+ let target = format!("https://deployments/jobs/{rest}");
357+ let body = request.bytes().await?;
358+ let headers = worker::Headers::new();
359+ headers.set("content-type", "application/json")?;
360+ let mut init = worker::RequestInit::new();
361+ init.with_method(Method::Post)
362+ .with_headers(headers)
363+ .with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into()));
364+ return env
365+ .service("DEPLOYMENTS")?
366+ .fetch_request(Request::new_with_init(&target, &init)?)
367+ .await;
368+ }
369+
350370 // A sandbox's artifacts and cache, with its job's token, which is not a
351371 // g1t token either.
352372 if !on_mcp
+3−1
2121 { "binding": "BILLING", "service": "g1t-billing" },
2222 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
2323 { "binding": "WEBHOOKS", "service": "g1t-webhooks" },
24− { "binding": "ACTIONS", "service": "g1t-actions" }
24+ { "binding": "ACTIONS", "service": "g1t-actions" },
25+ // Builds of deployments report through here.
26+ { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }
2527 ],
2628 // GitHub Actions artifacts and cache, in chunks, with KV's own expiry.
2729 // (Moves to R2 once R2 is enabled on the account.)
+1−0
9090 label: 'Landing changes',
9191 items: [
9292 { label: 'The merge queue', slug: 'guides/merge-queue' },
93+ { label: 'Deployments', slug: 'guides/deployments' },
9394 { label: 'Sessions and why-blame', slug: 'guides/why-blame' },
9495 { label: 'Forks and branches', slug: 'concepts/forks' },
9596 ],
+232−0
1+---
2+title: Deployments
3+description: A live preview for every pull request and production for every push, on g1t.page. Scales to zero, and billed to the workspace.
4+---
5+
6+Deployments put your repository on the web. Every pull request gets its own
7+live preview, linked on the pull request, and the default branch goes to
8+production on every push. Reviewers, and the agents reviewing for you,
9+click through the change instead of reading a diff.
10+
11+Apps run on Cloudflare Workers, on `g1t.page`:
12+
13+| | Address |
14+| --- | --- |
15+| Production | `https://<repo>--<workspace>.g1t.page` |
16+| Preview of pull request 12 | `https://pr-12--<repo>--<workspace>.g1t.page` |
17+
18+An app runs only while it answers a request. One nobody visits runs
19+nothing and costs nothing, and the next visit wakes it in milliseconds.
20+
21+Deployments are a paid feature, turned on per workspace with a monthly
22+plan, and then per repository. Nothing deploys until you turn it on, and
23+one click turns it off again.
24+
25+## Turn on deployments
26+
27+1. **Turn on the plan for the workspace.** An owner opens
28+ **Settings → Billing**, `g1t.sh/<workspace>/-/billing`, and under
29+ **Plans** chooses **Turn on Deployments**, then pays on the card page.
30+ Back on Billing, the plan says **On** with its renewal date.
31+2. **Turn on deployments for a repository.** Any member opens the
32+ repository's **Deployments** page, `g1t.sh/<workspace>/<repo>/deployments`,
33+ and chooses **Turn on deployments**.
34+
35+Production starts building at once from the default branch. Every pull
36+request opened or pushed to from then on gets a preview.
37+
38+While payments on g1t are in test mode, no real card is charged: use the
39+test card `4242 4242 4242 4242` with any future date and any code.
40+
41+## What deploys
42+
43+g1t looks at the repository and builds it the way it is meant to be built.
44+You do not configure anything for the common cases.
45+
46+| The repository has | g1t |
47+| --- | --- |
48+| `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` | Builds it as a **Workers project**: installs dependencies, runs `wrangler deploy --dry-run` to bundle it (which runs the config's own `build` command), and deploys the bundle with the config's static assets, `compatibility_date`, `compatibility_flags` and `vars`. |
49+| A `build` script in `package.json` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. |
50+| An `index.html` and nothing to build | Serves the repository as it is. |
51+
52+Dependencies are installed by the lockfile that is there: `npm ci`,
53+`pnpm install --frozen-lockfile`, `yarn install` or `bun install`, and
54+`npm install` with no lockfile.
55+
56+A static site is served from the first of these that exists after the
57+build: `dist`, `build`, `out`, `public`, `_site`, `.output/public`. Set
58+**Output directory** to choose another.
59+
60+### Static sites
61+
62+- A site without a `404.html` is treated as a single-page app: an address
63+ that matches no file serves `index.html`. With a `404.html`, that page
64+ is served instead, with status 404.
65+- `_headers` and `_redirects` files in the output are honored, in
66+ [Cloudflare's format](https://developers.cloudflare.com/workers/static-assets/headers/).
67+- Up to 20,000 files, and 25 MiB per file: Cloudflare's limits.
68+
69+### Workers projects
70+
71+- Your Worker's `fetch` handler runs as written, and its static assets
72+ are served under the binding name your config gives them. Cron triggers
73+ in the config are not scheduled.
74+- `vars` are deployed as plain-text bindings (or JSON, for objects).
75+- **Not provisioned yet:** D1, KV, R2, Durable Objects, Queues, service
76+ bindings, Vectorize, Hyperdrive, Workers AI and Workflows. A project that
77+ declares any of them still deploys, without them, and its deployment
78+ lists each one it left out. Code that needs them should check that the
79+ binding is there.
80+
81+## Previews on pull requests
82+
83+A preview is built when a pull request is opened, when it is marked ready,
84+and on every push to it, including an agent's. Pull requests from forks,
85+which is how g1t's agents work, are built from the fork.
86+
87+The pull request shows the deployment as a check named **g1t / deploy**:
88+
89+| State | Means |
90+| --- | --- |
91+| Pending, "Building" | The build is running. The link opens its log. |
92+| Passed, "Preview is live" | The link opens the preview. |
93+| Failed, "Deployment failed" | The link opens the build log and the reason. |
94+
95+A newer push replaces a build that is still running for the same pull
96+request. Previews are marked `noindex`, so search engines leave them alone.
97+
98+## Production
99+
100+Each push to the default branch, which is each merge on a protected
101+branch, builds and replaces production. The **Deployments** page shows the
102+live address, the commit it runs and when it went up.
103+
104+## When apps come down
105+
106+Nothing keeps running unasked. An app comes down, and stops costing
107+anything, when:
108+
109+| | |
110+| --- | --- |
111+| Its pull request is merged or closed | That preview, at once. |
112+| No one visits a preview for the repository's **idle days** | That preview, at the next sweep (every 10 minutes). The default is 7 days. |
113+| You choose **Take down** on the Deployments page | That app, at once. |
114+| You turn off previews or production | All of that kind, at once. |
115+| You choose **Turn off deployments** | Every app of the repository, at once, and no more builds. |
116+| The workspace's plan ends or its payment fails | Every app of the workspace, at the next sweep. |
117+
118+A preview that came down comes back with the next push to its pull
119+request, or **Redeploy** on the Deployments page.
120+
121+## Settings
122+
123+On the repository's **Deployments** page, under **Settings**:
124+
125+| Setting | Default | |
126+| --- | --- | --- |
127+| Production | On | Deploy the default branch on every push. |
128+| Previews | On | A preview for every open pull request. |
129+| Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. |
130+| Output directory | Found by itself | What a static site serves. |
131+| Idle days | 7 | 1 to 90. A preview no one visits this long comes down. |
132+| Build variables | None | `KEY=value` lines the build runs with, such as `NODE_ENV=production`. Up to 50. They are shown to every member, so keep keys and tokens out of them. |
133+
134+## What it costs
135+
136+Deployments are never free, including while the rest of g1t is.
137+
138+**The plan:** $5 a month per workspace, charged by card, renewing monthly.
139+It includes, each calendar month (UTC):
140+
141+| Included | |
142+| --- | --- |
143+| 10 apps | The most apps up at once: production and previews together, across the workspace's repositories. |
144+| 1 million requests | To all of the workspace's apps. |
145+| 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. |
146+
147+**Usage past that,** and **every build**, come out of the workspace's
148+[credit](/guides/usage-and-billing/#add-credit) at Cloudflare's price plus
149+20%:
150+
151+| | Price |
152+| --- | --- |
153+| A build | $0.0015 a minute, by the second, whether it succeeds or fails. Not part of the plan. |
154+| Each app past 10 | $0.024 a month |
155+| Each million requests past 1 million | $0.36 |
156+| Each million CPU milliseconds past 3 million | $0.024 |
157+
158+Builds are charged when they finish. Usage past the allowance is charged
159+once, on the first sweep after the month ends, as one line: *Deployments in
160+2026-10 past the plan*.
161+
162+An app that no one visits costs nothing beyond counting toward the 10. That
163+is why previews come down when their pull request closes and after their
164+idle days.
165+
166+### Seeing what you use
167+
168+- **Billing**, under the Deployments plan, shows this month's apps,
169+ requests and CPU time against what the plan includes, and what builds
170+ have cost. Requests and CPU time are counted from Cloudflare's analytics
171+ every 10 minutes.
172+- The **statement** on Billing lists every build (*Building acme/web to
173+ production (48 s)*) and every month's usage past the plan.
174+- Each build's page shows how long it ran.
175+
176+## Turn it off
177+
178+- **For a repository:** **Turn off deployments** at the bottom of its
179+ Deployments page. Every app comes down at once. Turning it on again
180+ rebuilds production.
181+- **For the workspace:** an owner chooses **Turn off at the end of the
182+ period** under the plan on Billing. Deployments keep working until the
183+ date shown; then every app comes down and nothing more is charged.
184+ **Keep Deployments** takes it back before then.
185+
186+Usage from the month that is under way is still charged once it ends.
187+
188+## How it works
189+
190+1. A pull request opens, or someone pushes. The deployments service hears
191+ of it, and checks that the workspace's plan is on.
192+2. It starts a build in a sandbox of its own, the same machines that run
193+ [GitHub Actions](/guides/actions/) and agents. The sandbox checks out
194+ the commit with a read-only token that expires in 30 minutes.
195+3. The sandbox builds, then lists its files. g1t opens an upload with
196+ Cloudflare for exactly those files and hands the sandbox a key that can
197+ upload them and nothing else. Files Cloudflare already has are skipped.
198+4. The sandbox sends the Worker's code to g1t, which puts the app in
199+ g1t's [Workers for Platforms](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/)
200+ namespace, under the name in its address.
201+5. A request to `*.g1t.page` reaches g1t's dispatcher, which runs the app
202+ by the name in the hostname. Nothing else is looked up.
203+
204+Your code never holds a Cloudflare credential, and apps are served from
205+`g1t.page`, not `g1t.sh`, so they share no cookies or origin with the site
206+you sign in to.
207+
208+## Troubleshooting
209+
210+| You see | Do |
211+| --- | --- |
212+| "Deployments is a paid feature, and it is not on" | An owner turns on the plan under Billing. |
213+| "found nothing to serve" | Add a `build` script, an `index.html`, or a Workers config; or set **Output directory**. |
214+| "the output directory `x` does not exist after the build" | The build wrote elsewhere: check its log, then fix **Output directory**. |
215+| "`d1_databases` is not provisioned on g1t.page yet" | The app deployed without that binding. See [Workers projects](#workers-projects). |
216+| A preview page says "This preview is not up" | It came down (see [When apps come down](#when-apps-come-down)). Push, or choose **Redeploy**. |
217+| "The build did not finish in 45 minutes" | Builds are stopped after 45 minutes. Make the build faster, or build less for previews with **Build command**. |
218+
219+## Running your own g1t
220+
221+Deployments need a Workers for Platforms namespace and a zone for apps:
222+
223+1. Create the namespace: `npx wrangler dispatch-namespace create g1t-deployments`.
224+2. Add a proxied wildcard DNS record (`*`, `AAAA`, `100::`) on the apps'
225+ zone, and set the zone in `services/pages/wrangler.jsonc`.
226+3. Create an API token with **Workers Scripts: Edit** and **Account
227+ Analytics: Read**, and store it:
228+ `npx wrangler secret put CLOUDFLARE_API_TOKEN` in `services/deployments`.
229+4. Deploy `services/deployments`, `services/pages`, and the runner.
230+
231+Without a card processor configured, every feature is on and nothing is
232+charged.
+31−1
1313 Hosting repositories, issues, pull requests, review and your own agent cost
1414 nothing on g1t. What costs money is g1t's own agents: each run uses a
1515 model, and a workspace pays for the runs on its repositories from credit it
16−buys in advance. There is no subscription and no seat price.
16+buys in advance. There is no seat price.
17+
18+Some features are paid for with a monthly plan the workspace turns on, and
19+are never free, including while the rest of g1t is. See
20+[Plans](#plans).
21+
22+## Plans
23+
24+A plan turns on one paid feature for the whole workspace, the way
25+Cloudflare's or Vercel's paid plans do: a monthly price that includes an
26+allowance, and usage past it charged from credit at cost plus 20%.
27+
28+| Plan | Price | Includes each month |
29+| --- | --- | --- |
30+| [Deployments](/guides/deployments/) | $5 a month | 10 apps up at once, 1 million requests, 3 million CPU milliseconds. Builds are charged by the second. |
31+
32+Only an owner can turn a plan on or off.
33+
34+1. Open **Settings → Billing**, `g1t.sh/<workspace>/-/billing`.
35+2. Under **Plans**, choose **Turn on Deployments**, and pay on the card
36+ page you are sent to.
37+
38+Back on Billing, the plan says **On** and when it renews; the card is kept
39+and charged each month. **Turn off at the end of the period** ends the plan
40+on its renewal date, with nothing more charged after; **Keep Deployments**
41+takes that back. If a renewal payment fails, the plan says **Payment
42+failed** and the feature stops until it is paid.
43+
44+Plan usage past the allowance and builds are drawn from the workspace's
45+credit, so a workspace with a plan can add credit while agents are free.
1746
1847 ## The free allowance
1948
4271 | Planning an [outcome](/guides/outcomes/) | Yes |
4372 | Acceptance checks | No |
4473 | The [merge queue](/guides/merge-queue/) | No |
74+| [Deployments](/guides/deployments/) | The plan, and builds and usage past it. Never free. |
4575 | Repositories, git, issues, pull requests, the API and MCP | No |
4676
4777 Each run is charged when it finishes: what the model provider charged for
+4−0
4545 Workflows run from `.g1t/workflows` [as GitHub runs them](/guides/actions/),
4646 and failing ones send the agent back to fix the cause.
4747 </Card>
48+ <Card title="A preview for every change" icon="seti:html">
49+ Every pull request gets a [live preview on g1t.page](/guides/deployments/), and
50+ the default branch goes to production. Apps cost nothing while no one visits.
51+ </Card>
4852 <Card title="Your models, your tools" icon="puzzle">
4953 Use g1t's models or [your own providers](/guides/models/), and pull context
5054 from [Sentry, Jira and Linear](/guides/integrations/).
+7−0
2727 Users,
2828 Webhook,
2929 PlayCircle,
30+ Rocket,
3031 X,
3132 } from "lucide-react";
3233 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
387388 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
388389 Actions
389390 </SidebarLink>
391+ {repo.member && (
392+ <SidebarLink to={`${base}/deployments`} icon={<Rocket size={15} />}>
393+ Deployments
394+ </SidebarLink>
395+ )}
390396 </SidebarGroup>
391397 {repo.member && (
392398 <SidebarGroup title="Repository">
607613 commits: "Commits",
608614 plans: "Plan",
609615 actions: "Actions",
616+ deployments: "Deployments",
610617 secrets: "Secrets and variables",
611618 settings: "Settings",
612619 people: "Members",
+2−0
33 import {
44 actionsClient,
55 billingClient,
6+ deploymentsClient,
67 eventsClient,
78 identityClient,
89 integrationsClient,
1920 export const integrations = integrationsClient(env.INTEGRATIONS);
2021 export const webhooks = webhooksClient(env.WEBHOOKS);
2122 export const actions = actionsClient(env.ACTIONS);
23+export const deployments = deploymentsClient(env.DEPLOYMENTS);
+2−0
4646 route("actions/runs/:id", "routes/repo/actions-run.tsx"),
4747 route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"),
4848 route("actions/jobs/:job/log", "routes/repo/actions-log.ts"),
49+ route("deployments", "routes/repo/deployments.tsx"),
50+ route("deployments/:id", "routes/repo/deployment.tsx"),
4951 route("plans", "routes/repo/plans.tsx"),
5052 route("plans/:id", "routes/repo/plan.tsx"),
5153 route("settings", "routes/repo/settings.tsx"),
+75−0
1+import { ArrowLeft, ExternalLink } from "lucide-react";
2+import { Link, data } from "react-router";
3+
4+import type { Route } from "./+types/deployment";
5+import { TimeAgo } from "../../components/ui";
6+import { deployments } from "../../lib/services.server";
7+import { getViewer, roleIn } from "../../lib/session.server";
8+
9+export function meta({ params }: Route.MetaArgs) {
10+ return [{ title: `Deployment · ${params.owner}/${params.repo} · g1t` }];
11+}
12+
13+export async function loader({ params, context }: Route.LoaderArgs) {
14+ const viewer = getViewer(context);
15+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
16+ const found = await deployments.get({ namespace: params.owner, name: params.repo }, params.id, viewer);
17+ if (!found.ok) throw data(null, { status: 404 });
18+ return { build: found.value };
19+}
20+
21+const WORDS = {
22+ queued: "Waiting for a sandbox",
23+ building: "Building",
24+ ready: "Live",
25+ failed: "Failed",
26+ skipped: "Skipped",
27+} as const;
28+
29+export default function DeploymentPage({ loaderData, params }: Route.ComponentProps) {
30+ const { build } = loaderData;
31+ const base = `/${params.owner}/${params.repo}`;
32+ return (
33+ <div className="mx-auto max-w-5xl">
34+ <Link to={`${base}/deployments`} className="inline-flex items-center gap-1 text-sm text-muted hover:text-fg">
35+ <ArrowLeft size={14} />
36+ Deployments
37+ </Link>
38+ <h1 className="mt-3 text-xl font-semibold tracking-tight">
39+ {build.kind === "production" ? "Production" : (
40+ <>
41+ Preview of{" "}
42+ <Link to={`${base}/pull/${build.number}`} className="hover:underline">
43+ #{build.number}
44+ </Link>
45+ </>
46+ )}
47+ <span className="ml-3 font-mono text-sm font-normal text-faint">{build.commit.slice(0, 12)}</span>
48+ </h1>
49+ <p className="mt-1 text-sm text-muted">
50+ {WORDS[build.status]} · started <TimeAgo at={build.createdAt} /> by {build.createdBy}
51+ {build.buildSeconds != null && ` · built in ${build.buildSeconds} s`}
52+ </p>
53+ {build.status === "ready" && (
54+ <a href={build.url} className="mt-3 inline-flex items-center gap-1.5 font-mono text-sm text-accent hover:underline">
55+ {build.url.replace("https://", "")}
56+ <ExternalLink size={12} />
57+ </a>
58+ )}
59+ {build.error && (
60+ <p className="mt-4 rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm">{build.error}</p>
61+ )}
62+ {build.warnings.length > 0 && (
63+ <ul className="mt-4 space-y-1 rounded-lg border border-warn/30 bg-warn/5 px-4 py-3 text-sm">
64+ {build.warnings.map((warning) => (
65+ <li key={warning}>{warning}</li>
66+ ))}
67+ </ul>
68+ )}
69+ <h2 className="mt-8 text-sm font-medium text-muted">Build log</h2>
70+ <pre className="mt-3 max-h-[70vh] overflow-auto rounded-xl border border-line bg-bg p-4 font-mono text-xs leading-relaxed text-muted">
71+ {build.log || (build.status === "queued" || build.status === "building" ? "The log appears when the build finishes." : "No log.")}
72+ </pre>
73+ </div>
74+ );
75+}
+374−0
1+import { ExternalLink, Globe, Rocket, RotateCw, Trash2 } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Form, Link, data, useNavigation } from "react-router";
4+
5+import type { Deployment, DeployStatus, FeatureState } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/deployments";
8+import { Button, ButtonLink, EmptyState, ErrorText, Field, Input, Textarea, TimeAgo } from "../../components/ui";
9+import { billing, deployments } from "../../lib/services.server";
10+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
11+
12+export function meta({ params }: Route.MetaArgs) {
13+ return [{ title: `Deployments · ${params.owner}/${params.repo} · g1t` }];
14+}
15+
16+export async function loader({ params, context }: Route.LoaderArgs) {
17+ const viewer = getViewer(context);
18+ const role = roleIn(viewer, params.owner);
19+ // Members only; to anyone else the page does not exist.
20+ if (!role) throw data(null, { status: 404 });
21+ const path = { namespace: params.owner, name: params.repo };
22+ const [settings, list, features] = await Promise.all([
23+ deployments.settings(path, viewer),
24+ deployments.list(path, viewer),
25+ billing.features(params.owner, viewer),
26+ ]);
27+ const plan = unwrap(features).find((state) => state.plan.feature === "deployments") ?? null;
28+ return { role, settings: unwrap(settings), ...unwrap(list), plan };
29+}
30+
31+/** `KEY=value` lines as an object; blank lines and `#` comments are skipped. */
32+function parseEnv(text: string): Record<string, string> {
33+ const vars: Record<string, string> = {};
34+ for (const line of text.split(/\r?\n/)) {
35+ const trimmed = line.trim();
36+ if (!trimmed || trimmed.startsWith("#")) continue;
37+ const at = trimmed.indexOf("=");
38+ if (at > 0) vars[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1).trim();
39+ }
40+ return vars;
41+}
42+
43+export async function action({ request, params, context }: Route.ActionArgs) {
44+ assertSameOrigin(request);
45+ const user = requireUser(context, request);
46+ const form = await request.formData();
47+ const path = { namespace: params.owner, name: params.repo };
48+ const intent = form.get("intent");
49+ const number = form.get("number") ? Number(form.get("number")) : null;
50+ if (intent === "redeploy") {
51+ const started = await deployments.redeploy(user, path, number);
52+ return started.ok ? { notice: "Build started." } : { error: started.error.message };
53+ }
54+ if (intent === "take-down") {
55+ const done = await deployments.takeDown(user, path, number);
56+ return done.ok ? { notice: "Taken down." } : { error: done.error.message };
57+ }
58+ if (intent === "enable" || intent === "disable") {
59+ const saved = await deployments.updateSettings(user, path, { enabled: intent === "enable" });
60+ return saved.ok
61+ ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
62+ : { error: saved.error.message };
63+ }
64+ const on = (name: string) => form.get(name) === "on";
65+ const saved = await deployments.updateSettings(user, path, {
66+ previews: on("previews"),
67+ production: on("production"),
68+ buildCommand: String(form.get("buildCommand") ?? ""),
69+ outputDir: String(form.get("outputDir") ?? ""),
70+ buildEnv: parseEnv(String(form.get("buildEnv") ?? "")),
71+ idleDays: Number(form.get("idleDays")),
72+ });
73+ return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
74+}
75+
76+const STATUS: Record<DeployStatus, { label: string; tone: string }> = {
77+ queued: { label: "Queued", tone: "text-muted" },
78+ building: { label: "Building", tone: "text-warn" },
79+ ready: { label: "Live", tone: "text-accent" },
80+ failed: { label: "Failed", tone: "text-danger" },
81+ skipped: { label: "Skipped", tone: "text-faint" },
82+};
83+
84+function StatusDot({ status }: { status: DeployStatus }) {
85+ const { label, tone } = STATUS[status];
86+ return (
87+ <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${tone}`}>
88+ <span className={`size-1.5 rounded-full bg-current ${status === "building" ? "animate-pulse" : ""}`} />
89+ {label}
90+ </span>
91+ );
92+}
93+
94+export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) {
95+ const { settings, deployments: builds, live, plan } = loaderData;
96+ const busy = useNavigation().state === "submitting";
97+ const base = `/${params.owner}/${params.repo}`;
98+ const production = live.find((app) => app.kind === "production");
99+ const previews = live.filter((app) => app.kind === "preview");
100+
101+ return (
102+ <div className="mx-auto max-w-5xl">
103+ <header className="flex flex-wrap items-start justify-between gap-4">
104+ <div>
105+ <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
106+ <Rocket size={18} className="text-accent" />
107+ Deployments
108+ </h1>
109+ <p className="mt-1 max-w-2xl text-sm text-muted">
110+ Every pull request gets a live preview on g1t.page, and the default branch goes to production on each push.
111+ Apps run on Cloudflare only while someone visits them.{" "}
112+ <a href="https://docs.g1t.sh/guides/deployments/" className="text-fg hover:underline">
113+ How it works
114+ </a>
115+ </p>
116+ </div>
117+ {settings.enabled && (
118+ <a
119+ href={settings.productionUrl}
120+ className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 font-mono text-xs text-muted hover:border-line-strong hover:text-fg"
121+ >
122+ <Globe size={13} />
123+ {settings.productionUrl.replace("https://", "")}
124+ </a>
125+ )}
126+ </header>
127+
128+ <div className="mt-4 min-h-6">
129+ {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
130+ <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
131+ </div>
132+
133+ {!plan?.on ? (
134+ <PlanNeeded plan={plan} owner={params.owner} />
135+ ) : !settings.enabled ? (
136+ <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
137+ <h2 className="font-medium">Deploy {params.repo}</h2>
138+ <p className="mt-1 max-w-2xl text-sm text-muted">
139+ Production goes up from <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span>{" "}
140+ as soon as you turn this on, and every open pull request gets its own preview. Workers projects (a{" "}
141+ <code className="font-mono text-fg">wrangler.jsonc</code>) and static sites deploy without configuration.
142+ </p>
143+ <Form method="post" className="mt-4">
144+ <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}>
145+ <Rocket size={14} />
146+ Turn on deployments
147+ </Button>
148+ </Form>
149+ </section>
150+ ) : (
151+ <>
152+ <section className="mt-2 grid gap-4 md:grid-cols-2">
153+ <LiveCard
154+ title="Production"
155+ hint="The default branch, on every push."
156+ app={production}
157+ off={!settings.production}
158+ actions={
159+ <AppActions number={null} up={!!production} busy={busy} />
160+ }
161+ />
162+ <div className="rounded-xl border border-line bg-surface p-5">
163+ <h2 className="text-sm font-medium">Previews</h2>
164+ <p className="mt-0.5 text-xs text-faint">
165+ {settings.previews
166+ ? `One per open pull request; down when it closes or after ${settings.idleDays} days without a visit.`
167+ : "Off for this repository."}
168+ </p>
169+ {previews.length === 0 ? (
170+ <p className="mt-4 text-sm text-muted">No previews are up.</p>
171+ ) : (
172+ <ul className="mt-3 divide-y divide-line">
173+ {previews.map((app) => (
174+ <li key={app.url} className="flex items-center gap-3 py-2 text-sm">
175+ <Link to={`${base}/pull/${app.number}`} className="font-medium hover:underline">
176+ #{app.number}
177+ </Link>
178+ <a href={app.url} className="min-w-0 truncate font-mono text-xs text-muted hover:text-fg">
179+ {app.url.replace("https://", "")}
180+ </a>
181+ <span className="ml-auto shrink-0">
182+ <AppActions number={app.number} up busy={busy} compact />
183+ </span>
184+ </li>
185+ ))}
186+ </ul>
187+ )}
188+ </div>
189+ </section>
190+
191+ <h2 className="mt-10 text-sm font-medium text-muted">Recent builds</h2>
192+ <div className="mt-3">
193+ {builds.length === 0 ? (
194+ <EmptyState title="No builds yet">Push to the default branch or open a pull request.</EmptyState>
195+ ) : (
196+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
197+ {builds.map((build) => (
198+ <BuildRow key={build.id} build={build} base={base} />
199+ ))}
200+ </ul>
201+ )}
202+ </div>
203+
204+ <SettingsForm settings={settings} busy={busy} />
205+
206+ <section className="mt-10 rounded-xl border border-danger/30 p-5">
207+ <h2 className="text-sm font-medium">Turn off deployments</h2>
208+ <p className="mt-1 text-sm text-muted">
209+ Takes production and every preview down now, and stops building. Nothing of this repository's keeps
210+ running or costing anything. The workspace's plan stays on; turn it off under Billing.
211+ </p>
212+ <Form method="post" className="mt-3">
213+ <Button variant="quiet" type="submit" name="intent" value="disable" disabled={busy}>
214+ Turn off deployments
215+ </Button>
216+ </Form>
217+ </section>
218+ </>
219+ )}
220+ </div>
221+ );
222+}
223+
224+function PlanNeeded({ plan, owner }: { plan: FeatureState | null; owner: string }) {
225+ return (
226+ <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
227+ <h2 className="font-medium">Deployments are part of a paid plan</h2>
228+ <p className="mt-1 max-w-2xl text-sm text-muted">
229+ Turn on Deployments for the {owner} workspace and every repository in it can have previews for each pull
230+ request and production on g1t.page.
231+ {plan && ` $${(plan.plan.monthlyCents / 100).toFixed(0)} a month, including:`}
232+ </p>
233+ {plan && (
234+ <ul className="mt-3 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
235+ {plan.plan.includes.map((line) => (
236+ <li key={line} className="flex gap-2">
237+ <span className="text-accent">✓</span>
238+ {line}
239+ </li>
240+ ))}
241+ </ul>
242+ )}
243+ {plan && <p className="mt-3 text-xs text-faint">{plan.plan.overage}</p>}
244+ <div className="mt-4">
245+ <ButtonLink variant="accent" to={`/${owner}/-/billing`}>
246+ See the plan under Billing
247+ </ButtonLink>
248+ </div>
249+ </section>
250+ );
251+}
252+
253+function LiveCard({
254+ title,
255+ hint,
256+ app,
257+ off,
258+ actions,
259+}: {
260+ title: string;
261+ hint: string;
262+ app: { url: string; commit: string; deployedAt: string } | undefined;
263+ off: boolean;
264+ actions: ReactNode;
265+}) {
266+ return (
267+ <div className="rounded-xl border border-line bg-surface p-5">
268+ <h2 className="text-sm font-medium">{title}</h2>
269+ <p className="mt-0.5 text-xs text-faint">{off ? "Off for this repository." : hint}</p>
270+ {app ? (
271+ <>
272+ <a href={app.url} className="mt-3 flex items-center gap-1.5 font-mono text-sm text-accent hover:underline">
273+ {app.url.replace("https://", "")}
274+ <ExternalLink size={12} />
275+ </a>
276+ <p className="mt-1 text-xs text-faint">
277+ <span className="font-mono">{app.commit.slice(0, 8)}</span> · deployed <TimeAgo at={app.deployedAt} />
278+ </p>
279+ </>
280+ ) : (
281+ <p className="mt-4 text-sm text-muted">Not up.</p>
282+ )}
283+ {!off && <div className="mt-4">{actions}</div>}
284+ </div>
285+ );
286+}
287+
288+function AppActions({ number, up, busy, compact }: { number: number | null; up: boolean; busy: boolean; compact?: boolean }) {
289+ return (
290+ <Form method="post" className="flex items-center gap-2">
291+ {number != null && <input type="hidden" name="number" value={number} />}
292+ <Button variant="quiet" type="submit" name="intent" value="redeploy" disabled={busy} title="Build again from the current head">
293+ <RotateCw size={13} />
294+ {!compact && "Redeploy"}
295+ </Button>
296+ {up && (
297+ <Button variant="quiet" type="submit" name="intent" value="take-down" disabled={busy} title="Take it down now">
298+ <Trash2 size={13} />
299+ {!compact && "Take down"}
300+ </Button>
301+ )}
302+ </Form>
303+ );
304+}
305+
306+function BuildRow({ build, base }: { build: Deployment; base: string }) {
307+ return (
308+ <li>
309+ <Link to={`${base}/deployments/${build.id}`} className="flex items-center gap-4 px-4 py-3 text-sm hover:bg-surface">
310+ <span className="w-20 shrink-0">
311+ <StatusDot status={build.status} />
312+ </span>
313+ <span className="min-w-0 grow">
314+ <span className="block truncate font-medium">
315+ {build.kind === "production" ? "Production" : `Preview of #${build.number}`}
316+ <span className="ml-2 font-mono text-xs font-normal text-faint">{build.commit.slice(0, 8)}</span>
317+ </span>
318+ {build.error && <span className="mt-0.5 block truncate text-xs text-muted">{build.error}</span>}
319+ </span>
320+ <span className="shrink-0 text-xs text-faint">
321+ {build.buildSeconds != null && `${build.buildSeconds} s · `}
322+ <TimeAgo at={build.createdAt} />
323+ </span>
324+ </Link>
325+ </li>
326+ );
327+}
328+
329+function SettingsForm({ settings, busy }: { settings: Route.ComponentProps["loaderData"]["settings"]; busy: boolean }) {
330+ const env = Object.entries(settings.buildEnv)
331+ .map(([name, value]) => `${name}=${value}`)
332+ .join("\n");
333+ return (
334+ <Form method="post" className="mt-10 space-y-5">
335+ <h2 className="text-sm font-medium text-muted">Settings</h2>
336+ <div className="grid gap-3 md:grid-cols-2">
337+ <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
338+ <input type="checkbox" name="production" defaultChecked={settings.production} className="mt-1 accent-accent" />
339+ <span>
340+ <span className="block text-sm font-medium">Production</span>
341+ <span className="mt-1 block text-sm text-muted">Deploy the default branch on every push.</span>
342+ </span>
343+ </label>
344+ <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
345+ <input type="checkbox" name="previews" defaultChecked={settings.previews} className="mt-1 accent-accent" />
346+ <span>
347+ <span className="block text-sm font-medium">Previews</span>
348+ <span className="mt-1 block text-sm text-muted">A preview for every open pull request, linked on it.</span>
349+ </span>
350+ </label>
351+ </div>
352+ <div className="grid gap-4 md:grid-cols-3">
353+ <Field label="Build command" hint="Instead of the project's own build script.">
354+ <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
355+ </Field>
356+ <Field label="Output directory" hint="For a static site; found by itself when empty.">
357+ <Input name="outputDir" defaultValue={settings.outputDir ?? ""} placeholder="dist" />
358+ </Field>
359+ <Field label="Idle days" hint="A preview no one visits for this long comes down.">
360+ <Input name="idleDays" type="number" min={1} max={90} defaultValue={settings.idleDays} />
361+ </Field>
362+ </div>
363+ <Field
364+ label="Build variables"
365+ hint="KEY=value, one per line. The build runs with them; they are not secret, so keep keys in Secrets."
366+ >
367+ <Textarea name="buildEnv" rows={4} defaultValue={env} className="font-mono" placeholder="NODE_ENV=production" />
368+ </Field>
369+ <Button type="submit" disabled={busy}>
370+ Save settings
371+ </Button>
372+ </Form>
373+ );
374+}
+53−4
11 import { CreditCard, Rocket } from "lucide-react";
22 import { Form, Link, data, redirect, useNavigation } from "react-router";
33
4−import { MICROS_PER_DOLLAR, type Feature, type FeatureState } from "@g1t/contracts";
4+import {
5+ DEPLOYMENTS_ALLOWANCE,
6+ MICROS_PER_DOLLAR,
7+ type DeployUsage,
8+ type Feature,
9+ type FeatureState,
10+} from "@g1t/contracts";
511
612 import type { Route } from "./+types/billing";
713 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8−import { billing } from "../../lib/services.server";
14+import { billing, deployments } from "../../lib/services.server";
915 import {
1016 assertSameOrigin,
1117 getViewer,
4046 await billing.confirm(slug, viewer, session);
4147 throw redirect(`/${slug}/-/billing?added=1`);
4248 }
43− const [account, ledger, features] = await Promise.all([
49+ const [account, ledger, features, deployUsage] = await Promise.all([
4450 billing.account(slug, viewer),
4551 billing.ledger(slug, viewer),
4652 billing.features(slug, viewer),
53+ deployments.usage(slug, viewer),
4754 ]);
4855 return {
4956 slug,
5158 account: unwrap(account),
5259 ledger: unwrap(ledger),
5360 features: unwrap(features),
61+ deployUsage: deployUsage.ok ? deployUsage.value : null,
5462 added: url.searchParams.has("added"),
5563 subscribed: url.searchParams.has("subscribed"),
5664 };
9199 }
92100
93101 export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
94− const { slug, role, account, ledger, features, added, subscribed } = loaderData;
102+ const { slug, role, account, ledger, features, deployUsage, added, subscribed } = loaderData;
95103 const { status } = account;
96104 const paying = useNavigation().state === "submitting";
97105 const empty = account.balanceMicros <= 0;
124132 enabled={account.status.enabled}
125133 live={account.status.live}
126134 busy={paying}
135+ usage={state.plan.feature === "deployments" ? deployUsage : null}
127136 />
128137 ))}
129138 </div>
278287 enabled,
279288 live,
280289 busy,
290+ usage,
281291 }: {
282292 state: FeatureState;
283293 owner: boolean;
284294 enabled: boolean;
285295 live: boolean;
286296 busy: boolean;
297+ usage: DeployUsage | null;
287298 }) {
288299 const { plan, subscription } = state;
289300 const ending = subscription?.status === "canceling";
336347 ))}
337348 </ul>
338349 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
350+ {state.on && usage && <DeployMeter usage={usage} />}
339351 {!enabled ? (
340352 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
341353 ) : !owner ? (
365377 </section>
366378 );
367379 }
380+
381+/** This month's use of the Deployments plan against what it includes. */
382+function DeployMeter({ usage }: { usage: DeployUsage }) {
383+ const a = DEPLOYMENTS_ALLOWANCE;
384+ const rows: [string, number, number, (n: number) => string][] = [
385+ ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
386+ ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
387+ ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
388+ ];
389+ return (
390+ <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
391+ <p className="text-xs font-medium text-muted">This month ({usage.month})</p>
392+ <ul className="mt-2 space-y-2.5">
393+ {rows.map(([label, used, included, show]) => (
394+ <li key={label} className="text-sm">
395+ <div className="flex justify-between gap-4">
396+ <span className="text-muted">{label}</span>
397+ <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
398+ {show(used)} <span className="text-faint">of {show(included)}</span>
399+ </span>
400+ </div>
401+ <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
402+ <div
403+ className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`}
404+ style={{ width: `${Math.min(100, (used / included) * 100)}%` }}
405+ />
406+ </div>
407+ </li>
408+ ))}
409+ </ul>
410+ <p className="mt-3 text-xs text-faint">
411+ Builds: {Math.ceil(usage.buildSeconds / 60)} min, {dollars(usage.buildMicros, 4)} at cost.
412+ {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
413+ </p>
414+ </div>
415+ );
416+}
+14−0
243243 holds the merge, failure refuses it and sends a g1t agent back to fix it.
244244 Secrets and variables: `{repo}/actions/secrets`, `{repo}/actions/variables`.
245245
246+## Deployments
247+
248+A paid feature: an owner turns on the Deployments plan under the
249+workspace's Billing ($5 a month: 10 apps, 1M requests, 3M CPU ms; builds
250+and usage past that from credit at cost + 20%; never free). Then a member
251+turns deployments on from the repository's Deployments page
252+(`g1t.sh/<owner>/<repo>/deployments`). Every pull request gets a preview at
253+`https://pr-<n>--<repo>--<owner>.g1t.page`, shown on it as the check
254+`g1t / deploy`; the default branch deploys to
255+`https://<repo>--<owner>.g1t.page` on each push. Workers projects
256+(`wrangler.jsonc`) and static sites build without configuration. Previews
257+come down when the pull request closes and after idle days. There is no API
258+for deployments yet. Guide: https://docs.g1t.sh/guides/deployments/
259+
246260 ## Facts
247261
248262 - API base: `https://api.g1t.sh`. `GET /` lists every URL as a template.
+1−0
1313 INTEGRATIONS: ServiceBinding;
1414 WEBHOOKS: ServiceBinding;
1515 ACTIONS: ServiceBinding;
16+ DEPLOYMENTS: ServiceBinding;
1617 BLOBS: KVNamespace;
1718 }
1819 }
+2−1
2020 { "binding": "EVENTS", "service": "g1t-events" },
2121 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
2222 { "binding": "WEBHOOKS", "service": "g1t-webhooks" },
23− { "binding": "ACTIONS", "service": "g1t-actions" }
23+ { "binding": "ACTIONS", "service": "g1t-actions" },
24+ { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }
2425 ],
2526 "observability": { "enabled": true },
2627 "upload_source_maps": true
+5−0
300300 pub const MICROS_PER_APP_MONTH: i64 = 20_000;
301301 pub const MICROS_PER_MILLION_REQUESTS: i64 = 300_000;
302302 pub const MICROS_PER_MILLION_CPU_MS: i64 = 20_000;
303+ /// What one second of a build's sandbox costs g1t (Cloudflare
304+ /// Containers, standard-1: half a vCPU, 4 GiB, 8 GB disk), rounded up.
305+ /// Builds are not in the allowance: each is charged at this plus the
306+ /// margin.
307+ pub const MICROS_PER_BUILD_SECOND: i64 = 21;
303308 }
304309
305310 /// What a feature's plan costs and includes.
+1−0
99 g1t-actions = { path = "../actions" }
1010 sha2 = "0.10"
1111 serde_yaml = "0.9"
12+toml = "0.8"
1213 hex = "0.4"
1314 anyhow = "1"
1415 base64 = "0.22"
+7−0
3939 duration_ms: u64,
4040 }
4141
42+impl CheckResult {
43+ pub(crate) fn output_text(&self) -> &str {
44+ &self.output
45+ }
46+}
47+
4248 /// The last `limit` characters of `text`, saying so if any were dropped.
4349 fn tail(text: &str, limit: usize) -> String {
4450 let length = text.chars().count();
7076 .current_dir(workdir)
7177 .env_remove("G1T_TOKEN")
7278 .env_remove("CHECK_TOKEN")
79+ .env_remove("DEPLOY_TOKEN")
7380 .stdin(Stdio::null())
7481 .output();
7582 let duration_ms = started.elapsed().as_millis() as u64;
+667−0
1+//! Builds one commit of a repository and hands the result to Cloudflare, as
2+//! a preview of a pull request or as the repository's production.
3+//!
4+//! The sandbox never holds a Cloudflare credential that could touch anything
5+//! else. The deployments service opens an upload for exactly the files
6+//! this build produced and gives back a key that can only upload those;
7+//! the sandbox uploads them with it, and sends the Worker's code to the
8+//! service, which puts the app in place.
9+//!
10+//! What gets built:
11+//!
12+//! - A Workers project (a `wrangler.jsonc`, `wrangler.json` or
13+//! `wrangler.toml`): bundled by `wrangler deploy --dry-run`, with its
14+//! static assets, compatibility settings and `vars`. Other bindings (D1,
15+//! KV, R2, Durable Objects…) are not provisioned yet; the deployment says
16+//! which were left out.
17+//! - Anything else: a static site. Its `build` script runs, and the first
18+//! of `dist`, `build`, `out`, `public`, `_site` or `.output/public` that
19+//! exists is served, or the repository itself if it has an `index.html`.
20+//!
21+//! Configuration comes from the environment:
22+//!
23+//! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report.
24+//! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out.
25+//! - `BUILD_COMMAND`, `OUTPUT_DIR`: the repository's own choices, if any.
26+//! - `BUILD_ENV`: a JSON object of variables the build runs with.
27+
28+use std::collections::BTreeMap;
29+use std::path::{Path, PathBuf};
30+use std::time::Instant;
31+
32+use anyhow::{Context, Result, bail};
33+use base64::Engine;
34+use base64::engine::general_purpose::STANDARD;
35+use serde::{Deserialize, Serialize};
36+use serde_json::{Value, json};
37+use sha2::{Digest, Sha256};
38+
39+use crate::checks::{redact, run_command};
40+use crate::{WORKDIR, auth_option, env, git};
41+
42+/// Where `wrangler deploy --dry-run` writes the bundle.
43+const BUNDLE_DIR: &str = "/work/g1t-bundle";
44+/// Cloudflare's limits on a Worker's static assets.
45+const MAX_FILES: usize = 20_000;
46+const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;
47+/// How much of the build's output is kept for the deployment's log.
48+const MAX_LOG_CHARS: usize = 20_000;
49+/// Directories a static build usually writes to, in the order they are tried.
50+const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"];
51+/// Bindings a Workers project may declare that are not provisioned yet.
52+const UNSUPPORTED_BINDINGS: [&str; 10] = [
53+ "kv_namespaces",
54+ "d1_databases",
55+ "r2_buckets",
56+ "durable_objects",
57+ "services",
58+ "queues",
59+ "vectorize",
60+ "hyperdrive",
61+ "ai",
62+ "workflows",
63+];
64+
65+struct Reporter {
66+ base: String,
67+ token: String,
68+}
69+
70+impl Reporter {
71+ fn send(&self, step: &str, mut body: Value) -> Result<Value> {
72+ body["token"] = self.token.clone().into();
73+ let response = ureq::post(&format!("{}/{step}", self.base))
74+ .send_json(body)
75+ .with_context(|| format!("could not report `{step}` to g1t"))?;
76+ Ok(response.into_json().unwrap_or(Value::Null))
77+ }
78+}
79+
80+/// The build's log, kept to its end.
81+#[derive(Default)]
82+struct Log {
83+ text: String,
84+}
85+
86+impl Log {
87+ fn line(&mut self, line: &str) {
88+ self.text.push_str(line);
89+ self.text.push('\n');
90+ }
91+
92+ fn tail(&self) -> String {
93+ let length = self.text.chars().count();
94+ if length <= MAX_LOG_CHARS {
95+ return self.text.clone();
96+ }
97+ let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect();
98+ format!("… (earlier output not shown)\n{kept}")
99+ }
100+}
101+
102+/// Runs a command in the checkout, logging it; fails if it fails.
103+fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> {
104+ log.line(&format!("$ {command}"));
105+ let result = run_command(command, Path::new(WORKDIR), secrets);
106+ if !result.output_text().is_empty() {
107+ log.line(result.output_text());
108+ }
109+ if !result.passed {
110+ bail!("`{command}` failed");
111+ }
112+ Ok(())
113+}
114+
115+/// A Workers project's settings, from whichever config file it has.
116+#[derive(Debug, Default, Deserialize)]
117+struct WranglerConfig {
118+ main: Option<String>,
119+ compatibility_date: Option<String>,
120+ #[serde(default)]
121+ compatibility_flags: Vec<String>,
122+ assets: Option<AssetsConfig>,
123+ #[serde(default)]
124+ vars: BTreeMap<String, Value>,
125+ #[serde(flatten)]
126+ rest: BTreeMap<String, Value>,
127+}
128+
129+#[derive(Debug, Default, Deserialize)]
130+struct AssetsConfig {
131+ directory: Option<String>,
132+ binding: Option<String>,
133+ html_handling: Option<String>,
134+ not_found_handling: Option<String>,
135+}
136+
137+/// JSON with comments and trailing commas, as `wrangler.jsonc` allows.
138+fn strip_jsonc(text: &str) -> String {
139+ let mut out = String::with_capacity(text.len());
140+ let mut chars = text.chars().peekable();
141+ let mut in_string = false;
142+ while let Some(c) = chars.next() {
143+ if in_string {
144+ out.push(c);
145+ if c == '\\' {
146+ if let Some(next) = chars.next() {
147+ out.push(next);
148+ }
149+ } else if c == '"' {
150+ in_string = false;
151+ }
152+ continue;
153+ }
154+ match (c, chars.peek()) {
155+ ('"', _) => {
156+ in_string = true;
157+ out.push(c);
158+ }
159+ ('/', Some('/')) => {
160+ for c in chars.by_ref() {
161+ if c == '\n' {
162+ out.push('\n');
163+ break;
164+ }
165+ }
166+ }
167+ ('/', Some('*')) => {
168+ chars.next();
169+ let mut last = ' ';
170+ for c in chars.by_ref() {
171+ if last == '*' && c == '/' {
172+ break;
173+ }
174+ last = c;
175+ }
176+ }
177+ _ => out.push(c),
178+ }
179+ }
180+ // Trailing commas before a closing bracket.
181+ let mut cleaned = String::with_capacity(out.len());
182+ let chars: Vec<char> = out.chars().collect();
183+ let mut in_string = false;
184+ let mut i = 0;
185+ while i < chars.len() {
186+ let c = chars[i];
187+ if c == '"' && (i == 0 || chars[i - 1] != '\\') {
188+ in_string = !in_string;
189+ }
190+ if c == ',' && !in_string {
191+ let next = chars[i + 1..].iter().find(|c| !c.is_whitespace());
192+ if matches!(next, Some('}') | Some(']')) {
193+ i += 1;
194+ continue;
195+ }
196+ }
197+ cleaned.push(c);
198+ i += 1;
199+ }
200+ cleaned
201+}
202+
203+fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> {
204+ for name in ["wrangler.jsonc", "wrangler.json"] {
205+ let path = dir.join(name);
206+ if path.exists() {
207+ let text = std::fs::read_to_string(&path)?;
208+ return Ok(Some(
209+ serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?,
210+ ));
211+ }
212+ }
213+ let path = dir.join("wrangler.toml");
214+ if path.exists() {
215+ let text = std::fs::read_to_string(&path)?;
216+ return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?));
217+ }
218+ Ok(None)
219+}
220+
221+/// How to install the project's dependencies, judged by its lockfile.
222+fn install_command(dir: &Path) -> Option<&'static str> {
223+ if !dir.join("package.json").exists() {
224+ return None;
225+ }
226+ Some(if dir.join("pnpm-lock.yaml").exists() {
227+ "corepack enable && pnpm install --frozen-lockfile"
228+ } else if dir.join("yarn.lock").exists() {
229+ "corepack enable && yarn install"
230+ } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
231+ "npx --yes bun install"
232+ } else if dir.join("package-lock.json").exists() {
233+ "npm ci"
234+ } else {
235+ "npm install"
236+ })
237+}
238+
239+fn has_build_script(dir: &Path) -> bool {
240+ std::fs::read_to_string(dir.join("package.json"))
241+ .ok()
242+ .and_then(|text| serde_json::from_str::<Value>(&text).ok())
243+ .is_some_and(|package| package["scripts"]["build"].is_string())
244+}
245+
246+/// One file of the site, as Cloudflare's asset upload names it.
247+struct Asset {
248+ path: String,
249+ hash: String,
250+ size: u64,
251+ file: PathBuf,
252+}
253+
254+fn content_type(path: &str) -> &'static str {
255+ let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
256+ match extension.as_str() {
257+ "html" | "htm" => "text/html",
258+ "css" => "text/css",
259+ "js" | "mjs" => "application/javascript",
260+ "json" | "map" => "application/json",
261+ "svg" => "image/svg+xml",
262+ "png" => "image/png",
263+ "jpg" | "jpeg" => "image/jpeg",
264+ "gif" => "image/gif",
265+ "webp" => "image/webp",
266+ "avif" => "image/avif",
267+ "ico" => "image/x-icon",
268+ "woff" => "font/woff",
269+ "woff2" => "font/woff2",
270+ "ttf" => "font/ttf",
271+ "txt" => "text/plain",
272+ "xml" => "application/xml",
273+ "wasm" => "application/wasm",
274+ "pdf" => "application/pdf",
275+ "mp4" => "video/mp4",
276+ "webm" => "video/webm",
277+ _ => "application/octet-stream",
278+ }
279+}
280+
281+/// Every file under `root`, but for what never belongs in a site.
282+fn collect(root: &Path, dir: &Path, skip_project: bool, out: &mut Vec<Asset>) -> Result<()> {
283+ for entry in std::fs::read_dir(dir)? {
284+ let entry = entry?;
285+ let name = entry.file_name().to_string_lossy().into_owned();
286+ let path = entry.path();
287+ let kind = entry.file_type()?;
288+ if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) {
289+ continue;
290+ }
291+ if kind.is_dir() {
292+ collect(root, &path, skip_project, out)?;
293+ continue;
294+ }
295+ if !kind.is_file() || name == "_headers" || name == "_redirects" {
296+ continue;
297+ }
298+ let size = entry.metadata()?.len();
299+ let relative = path
300+ .strip_prefix(root)?
301+ .to_string_lossy()
302+ .replace('\\', "/");
303+ if size > MAX_FILE_BYTES {
304+ bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file");
305+ }
306+ let bytes = std::fs::read(&path)?;
307+ let digest = hex::encode(Sha256::digest(&bytes));
308+ out.push(Asset {
309+ path: format!("/{relative}"),
310+ hash: digest[..32].to_owned(),
311+ size,
312+ file: path,
313+ });
314+ if out.len() > MAX_FILES {
315+ bail!("the site has more than {MAX_FILES} files, Cloudflare's limit");
316+ }
317+ }
318+ Ok(())
319+}
320+
321+#[derive(Deserialize)]
322+#[serde(rename_all = "camelCase")]
323+struct UploadSession {
324+ jwt: String,
325+ #[serde(default)]
326+ buckets: Vec<Vec<String>>,
327+ upload_url: String,
328+}
329+
330+/// Sends one bucket of files with the upload's key. The last bucket's
331+/// answer carries the key that completes the upload.
332+fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> {
333+ let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned());
334+ let mut body: Vec<u8> = Vec::new();
335+ for hash in bucket {
336+ let asset = by_hash
337+ .get(hash.as_str())
338+ .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?;
339+ let bytes = std::fs::read(&asset.file)?;
340+ body.extend_from_slice(
341+ format!(
342+ "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n",
343+ content_type(&asset.path)
344+ )
345+ .as_bytes(),
346+ );
347+ body.extend_from_slice(STANDARD.encode(bytes).as_bytes());
348+ body.extend_from_slice(b"\r\n");
349+ }
350+ body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
351+ let response = ureq::post(&session.upload_url)
352+ .set("authorization", &format!("Bearer {}", session.jwt))
353+ .set("content-type", &format!("multipart/form-data; boundary={boundary}"))
354+ .send_bytes(&body);
355+ let response = match response {
356+ Ok(response) => response,
357+ Err(ureq::Error::Status(code, response)) => {
358+ bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default())
359+ }
360+ Err(error) => bail!("could not upload to Cloudflare: {error}"),
361+ };
362+ let answer: Value = response.into_json().unwrap_or(Value::Null);
363+ Ok(answer["result"]["jwt"].as_str().map(str::to_owned))
364+}
365+
366+#[derive(Serialize)]
367+#[serde(rename_all = "camelCase")]
368+struct Module {
369+ name: String,
370+ content_base64: String,
371+ content_type: String,
372+}
373+
374+/// The bundle `wrangler deploy --dry-run` wrote, main module first.
375+fn bundle_modules(main: &str) -> Result<(String, Vec<Module>)> {
376+ let stem = Path::new(main)
377+ .file_stem()
378+ .map(|stem| stem.to_string_lossy().into_owned())
379+ .unwrap_or_else(|| "index".to_owned());
380+ let mut modules = Vec::new();
381+ let mut files = Vec::new();
382+ collect_files(Path::new(BUNDLE_DIR), &mut files)?;
383+ for file in files {
384+ let name = file
385+ .strip_prefix(BUNDLE_DIR)?
386+ .to_string_lossy()
387+ .replace('\\', "/");
388+ let kind = match name.rsplit('.').next().unwrap_or("") {
389+ "js" | "mjs" => "application/javascript+module",
390+ "wasm" => "application/wasm",
391+ "map" | "md" => continue,
392+ _ => "text/plain",
393+ };
394+ modules.push(Module {
395+ content_base64: STANDARD.encode(std::fs::read(&file)?),
396+ content_type: kind.to_owned(),
397+ name,
398+ });
399+ }
400+ let main_name = modules
401+ .iter()
402+ .map(|module| module.name.clone())
403+ .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs"))
404+ .or_else(|| {
405+ modules
406+ .iter()
407+ .find(|module| module.content_type == "application/javascript+module")
408+ .map(|module| module.name.clone())
409+ })
410+ .context("wrangler wrote no JavaScript module")?;
411+ Ok((main_name, modules))
412+}
413+
414+fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
415+ for entry in std::fs::read_dir(dir)? {
416+ let entry = entry?;
417+ if entry.file_type()?.is_dir() {
418+ collect_files(&entry.path(), out)?;
419+ } else {
420+ out.push(entry.path());
421+ }
422+ }
423+ Ok(())
424+}
425+
426+/// What was built: the Worker's code and settings, and where its site is.
427+struct Built {
428+ worker: Value,
429+ assets_dir: Option<PathBuf>,
430+ warnings: Vec<String>,
431+}
432+
433+fn build(log: &mut Log, secrets: &[String]) -> Result<Built> {
434+ let dir = Path::new(WORKDIR);
435+ let config = read_wrangler(dir)?;
436+ let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty());
437+ if let Some(install) = install_command(dir) {
438+ step(log, install, secrets)?;
439+ }
440+ let mut warnings = Vec::new();
441+ match config {
442+ Some(config) => {
443+ if let Some(command) = &custom_build {
444+ step(log, command, secrets)?;
445+ }
446+ for binding in UNSUPPORTED_BINDINGS {
447+ if config.rest.get(binding).is_some_and(|value| !value.is_null()) {
448+ warnings.push(format!(
449+ "`{binding}` is not provisioned on g1t.page yet, so the app runs without it."
450+ ));
451+ }
452+ }
453+ let mut worker = json!({
454+ "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| "2026-09-26".to_owned()),
455+ "compatibilityFlags": config.compatibility_flags,
456+ "vars": config.vars,
457+ });
458+ if let Some(main) = &config.main {
459+ // `--dry-run` runs the project's own build and bundles it,
460+ // without deploying anywhere.
461+ step(
462+ log,
463+ &format!("npx --yes wrangler@4 deploy --dry-run --outdir {BUNDLE_DIR}"),
464+ secrets,
465+ )?;
466+ let (main_module, modules) = bundle_modules(main)?;
467+ worker["mainModule"] = main_module.into();
468+ worker["modules"] = serde_json::to_value(modules)?;
469+ }
470+ let assets = config.assets.unwrap_or_default();
471+ let assets_dir = assets.directory.as_ref().map(|directory| dir.join(directory));
472+ worker["assetsBinding"] = assets.binding.into();
473+ worker["htmlHandling"] = assets.html_handling.into();
474+ worker["notFoundHandling"] = assets.not_found_handling.into();
475+ Ok(Built {
476+ worker,
477+ assets_dir,
478+ warnings,
479+ })
480+ }
481+ None => {
482+ if let Some(command) = &custom_build {
483+ step(log, command, secrets)?;
484+ } else if has_build_script(dir) {
485+ step(log, "npm run build", secrets)?;
486+ }
487+ let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
488+ let assets_dir = match chosen {
489+ Some(chosen) => {
490+ let path = dir.join(chosen.trim_matches('/'));
491+ if !path.is_dir() {
492+ bail!("the output directory `{chosen}` does not exist after the build");
493+ }
494+ path
495+ }
496+ None => OUTPUT_DIRS
497+ .iter()
498+ .map(|name| dir.join(name))
499+ .find(|path| path.join("index.html").exists() || (path.is_dir() && path != &dir.join("public")))
500+ .or_else(|| dir.join("index.html").exists().then(|| dir.to_path_buf()))
501+ .context(
502+ "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public",
503+ )?,
504+ };
505+ let spa = !assets_dir.join("404.html").exists();
506+ Ok(Built {
507+ worker: json!({
508+ "compatibilityDate": "2026-09-26",
509+ "compatibilityFlags": [],
510+ "vars": {},
511+ "notFoundHandling": if spa { "single-page-application" } else { "404-page" },
512+ }),
513+ assets_dir: Some(assets_dir),
514+ warnings,
515+ })
516+ }
517+ }
518+}
519+
520+fn check_out(secrets: &[String]) -> Result<()> {
521+ let remote = env("GIT_REMOTE")?;
522+ let commit = env("GIT_COMMIT")?;
523+ let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
524+ std::fs::create_dir_all("/work")?;
525+ let cloned = git(Path::new("/work"), &["-c", &auth, "clone", "--quiet", &remote, WORKDIR]).and_then(|_| {
526+ git(
527+ Path::new(WORKDIR),
528+ &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit],
529+ )
530+ });
531+ if let Err(error) = cloned {
532+ bail!("{}", redact(&format!("{error:#}"), secrets));
533+ }
534+ Ok(())
535+}
536+
537+fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
538+ check_out(secrets).context("the commit could not be checked out")?;
539+ // What the repository's settings ask the build to run with.
540+ if let Ok(vars) = std::env::var("BUILD_ENV")
541+ && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
542+ {
543+ for (name, value) in vars {
544+ if let Some(value) = value.as_str() {
545+ // SAFETY: single-threaded; set before any command runs.
546+ unsafe { std::env::set_var(name, value) };
547+ }
548+ }
549+ }
550+ let built = build(log, secrets)?;
551+ let mut finish = json!({
552+ "worker": built.worker,
553+ "warnings": built.warnings,
554+ });
555+ if let Some(dir) = &built.assets_dir {
556+ let skip_project = dir == Path::new(WORKDIR);
557+ let mut assets = Vec::new();
558+ collect(dir, dir, skip_project, &mut assets)?;
559+ if assets.is_empty() {
560+ bail!("the site to serve is empty");
561+ }
562+ log.line(&format!("Uploading {} files.", assets.len()));
563+ for special in ["_headers", "_redirects"] {
564+ if let Ok(text) = std::fs::read_to_string(dir.join(special)) {
565+ finish["worker"][special] = text.into();
566+ }
567+ }
568+ let manifest: BTreeMap<&str, Value> = assets
569+ .iter()
570+ .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size })))
571+ .collect();
572+ let answer = reporter.send("session", json!({ "manifest": manifest }))?;
573+ if answer["ok"] == false {
574+ bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload"));
575+ }
576+ let session: UploadSession =
577+ serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?;
578+ let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect();
579+ let mut completion = session.jwt.clone();
580+ for bucket in &session.buckets {
581+ if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? {
582+ completion = jwt;
583+ }
584+ }
585+ finish["completionJwt"] = completion.into();
586+ }
587+ Ok(finish)
588+}
589+
590+pub fn main() -> i32 {
591+ let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
592+ (Ok(api), Ok(id), Ok(token)) => Reporter {
593+ base: format!("{api}/deployments/jobs/{id}"),
594+ token,
595+ },
596+ _ => {
597+ eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set");
598+ return 2;
599+ }
600+ };
601+ let secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
602+ .iter()
603+ .filter_map(|name| std::env::var(name).ok())
604+ .filter(|secret| !secret.is_empty())
605+ .collect();
606+ if let Err(error) = reporter.send("started", json!({})) {
607+ eprintln!("g1t-runner: {error:#}");
608+ return 1;
609+ }
610+ let started = Instant::now();
611+ let mut log = Log::default();
612+ let outcome = deploy(&reporter, &mut log, &secrets);
613+ let seconds = started.elapsed().as_secs();
614+ let sent = match outcome {
615+ Ok(mut finish) => {
616+ finish["log"] = redact(&log.tail(), &secrets).into();
617+ finish["buildSeconds"] = seconds.into();
618+ reporter.send("finish", finish)
619+ }
620+ Err(error) => {
621+ let message = redact(&format!("{error:#}"), &secrets);
622+ log.line(&format!("The build failed: {message}"));
623+ reporter.send(
624+ "fail",
625+ json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
626+ )
627+ }
628+ };
629+ match sent {
630+ Ok(_) => 0,
631+ Err(error) => {
632+ eprintln!("g1t-runner: {error:#}");
633+ 1
634+ }
635+ }
636+}
637+
638+#[cfg(test)]
639+mod tests {
640+ use super::*;
641+
642+ #[test]
643+ fn jsonc_comments_and_trailing_commas_are_dropped() {
644+ let text = r#"{
645+ // a comment
646+ "main": "src/index.ts", /* another */
647+ "vars": { "URL": "https://x.dev//not-a-comment", },
648+ }"#;
649+ let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap();
650+ assert_eq!(config.main.as_deref(), Some("src/index.ts"));
651+ assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment");
652+ }
653+
654+ #[test]
655+ fn unsupported_bindings_are_noticed() {
656+ let config: WranglerConfig =
657+ serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap();
658+ assert!(config.rest.contains_key("d1_databases"));
659+ }
660+
661+ #[test]
662+ fn files_are_typed_by_extension() {
663+ assert_eq!(content_type("/index.HTML"), "text/html");
664+ assert_eq!(content_type("/a/b.woff2"), "font/woff2");
665+ assert_eq!(content_type("/LICENSE"), "application/octet-stream");
666+ }
667+}
+2−0
2424
2525 mod actions;
2626 mod checks;
27+mod deploy;
2728 mod harness;
2829 mod plan;
2930 mod queue;
182183 match std::env::var("MODE").as_deref() {
183184 Ok("actions") => std::process::exit(actions::main()),
184185 Ok("checks") => std::process::exit(checks::main()),
186+ Ok("deploy") => std::process::exit(deploy::main()),
185187 Ok("update") => std::process::exit(update::main()),
186188 Ok("review") => std::process::exit(review::main()),
187189 Ok("revise") => std::process::exit(revise::main()),
+21−0
15801580 "resolved": "packages/contracts",
15811581 "link": true
15821582 },
1583+ "node_modules/@g1t/deployments": {
1584+ "resolved": "services/deployments",
1585+ "link": true
1586+ },
15831587 "node_modules/@g1t/docs": {
15841588 "resolved": "apps/docs",
15851589 "link": true
15881592 "resolved": "services/models",
15891593 "link": true
15901594 },
1595+ "node_modules/@g1t/pages": {
1596+ "resolved": "services/pages",
1597+ "link": true
1598+ },
15911599 "node_modules/@g1t/runner": {
15921600 "resolved": "services/runner",
15931601 "link": true
99439951 "version": "0.1.0",
99449952 "license": "MIT"
99459953 },
9954+ "services/deployments": {
9955+ "name": "@g1t/deployments",
9956+ "version": "0.1.0",
9957+ "license": "MIT",
9958+ "dependencies": {
9959+ "@g1t/contracts": "*"
9960+ }
9961+ },
99469962 "services/models": {
99479963 "name": "@g1t/models",
99489964 "version": "0.1.0",
99519967 "@g1t/contracts": "*"
99529968 }
99539969 },
9970+ "services/pages": {
9971+ "name": "@g1t/pages",
9972+ "version": "0.1.0",
9973+ "license": "MIT"
9974+ },
99549975 "services/runner": {
99559976 "name": "@g1t/runner",
99569977 "version": "0.1.0",
+2−0
9898 microsPerAppMonth: 20_000,
9999 microsPerMillionRequests: 300_000,
100100 microsPerMillionCpuMs: 20_000,
101+ /** One second of a build's sandbox; builds are charged, not included. */
102+ microsPerBuildSecond: 21,
101103 } as const;
102104
103105 export type FeaturePlan = {
+14−0
11 import type { ActionsApi } from "./actions";
22 import type { BillingApi } from "./billing";
3+import type { DeploymentsApi } from "./deployments";
34 import type { EventsApi } from "./events";
45 import type { IdentityApi } from "./identity";
56 import type { IntegrationsApi } from "./integrations";
260261 };
261262 }
262263
264+
265+export function deploymentsClient(service: ServiceBinding): DeploymentsApi {
266+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
267+ return {
268+ settings: (repo, viewer) => call("settings", { repo, viewer }),
269+ updateSettings: (actor, repo, changes) => call("update_settings", { actor, repo, changes }),
270+ list: (repo, viewer) => call("list", { repo, viewer }),
271+ get: (repo, id, viewer) => call("get", { repo, id, viewer }),
272+ redeploy: (actor, repo, number) => call("redeploy", { actor, repo, number }),
273+ takeDown: (actor, repo, number) => call("take_down", { actor, repo, number }),
274+ usage: (workspace, viewer) => call("usage", { workspace, viewer }),
275+ };
276+}
+109−0
1+import type { User, Viewer } from "./identity";
2+import type { RepoPath } from "./repos";
3+import type { Result } from "./result";
4+
5+/**
6+ * Deployments: every pull request gets a live preview on g1t.page, and the
7+ * default branch goes to production on each push. Apps run as Workers in a
8+ * Workers for Platforms namespace, so an app no one visits costs nothing.
9+ * A paid feature: the workspace turns it on with a monthly plan (see
10+ * `Feature` in `./billing`), and each repository then chooses for itself.
11+ */
12+
13+/** The domain apps are served on. Never g1t.sh, so they share no cookies with it. */
14+export const DEPLOYMENTS_DOMAIN = "g1t.page";
15+
16+/** A repository's deployment settings. */
17+export type DeploySettings = {
18+ /** Whether this repository deploys at all. Off until someone turns it on. */
19+ enabled: boolean;
20+ /** A preview for every open pull request. */
21+ previews: boolean;
22+ /** The default branch deployed to production on every push. */
23+ production: boolean;
24+ /** Runs instead of the project's own `build` script. */
25+ buildCommand: string | null;
26+ /** What to serve, for a static site; found by itself when null. */
27+ outputDir: string | null;
28+ /** Variables the build runs with. Not secret: shown to members. */
29+ buildEnv: Record<string, string>;
30+ /** A preview no one has visited in this many days is taken down. */
31+ idleDays: number;
32+ /** Where production is served. */
33+ productionUrl: string;
34+};
35+
36+export type DeployKind = "preview" | "production";
37+
38+export type DeployStatus =
39+ /** Waiting for a sandbox. */
40+ | "queued"
41+ | "building"
42+ | "ready"
43+ | "failed"
44+ /** Not built: the workspace's plan is off, or the build was replaced. */
45+ | "skipped";
46+
47+/** One build of one commit, and where it went. */
48+export type Deployment = {
49+ id: string;
50+ kind: DeployKind;
51+ /** For a preview: the pull request. */
52+ number: number | null;
53+ commit: string;
54+ status: DeployStatus;
55+ url: string;
56+ /** Why it failed or was skipped. */
57+ error: string | null;
58+ /** What the build could not provide, such as bindings not provisioned yet. */
59+ warnings: string[];
60+ /** How long the build ran, in seconds; charged at the container price. */
61+ buildSeconds: number | null;
62+ createdBy: string;
63+ /** RFC 3339. */
64+ createdAt: string;
65+ finishedAt: string | null;
66+};
67+
68+/** An app that is up: production, or one pull request's preview. */
69+export type LiveApp = {
70+ kind: DeployKind;
71+ number: number | null;
72+ url: string;
73+ commit: string;
74+ /** RFC 3339: when it was last deployed. */
75+ deployedAt: string;
76+};
77+
78+/** What a workspace's apps used this month against its plan. */
79+export type DeployUsage = {
80+ /** `YYYY-MM`. */
81+ month: string;
82+ requests: number;
83+ cpuMs: number;
84+ /** Apps up now, and the most at once this month. */
85+ apps: number;
86+ peakApps: number;
87+ buildSeconds: number;
88+ /** Charged so far this month for builds, in millionths of a dollar. */
89+ buildMicros: number;
90+ /** RFC 3339: when requests and CPU time were last counted. */
91+ countedAt: string | null;
92+};
93+
94+export interface DeploymentsApi {
95+ /** Members of the workspace only. */
96+ settings(repo: RepoPath, viewer: Viewer): Promise<Result<DeploySettings>>;
97+ /** Members of the workspace only. Turning deployments on needs the plan. */
98+ updateSettings(actor: User, repo: RepoPath, changes: Partial<DeploySettings>): Promise<Result<DeploySettings>>;
99+ /** The newest deployments first, and what is up now. */
100+ list(repo: RepoPath, viewer: Viewer): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>>;
101+ /** One deployment, with its build log. */
102+ get(repo: RepoPath, id: string, viewer: Viewer): Promise<Result<Deployment & { log: string | null }>>;
103+ /** Builds production, or a pull request's preview, again from its current head. */
104+ redeploy(actor: User, repo: RepoPath, number: number | null): Promise<Result<Deployment>>;
105+ /** Takes production, or a pull request's preview, down now. */
106+ takeDown(actor: User, repo: RepoPath, number: number | null): Promise<Result<true>>;
107+ /** What the workspace's apps used this month. Members only. */
108+ usage(workspace: string, viewer: Viewer): Promise<Result<DeployUsage>>;
109+}
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
3−export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt";
3+export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+1−0
11 export * from "./actions";
22 export * from "./billing";
33 export * from "./clients";
4+export * from "./deployments";
45 export * from "./events";
56 export * from "./identity";
67 export * from "./ids";
+25−3
5959 }
6060 }
6161
62+/// Dollars to the cent, or finer for prices under a cent, so that a
63+/// build minute's $0.0015 does not read as nothing.
6264 fn dollars(micros: i64) -> String {
63− format!("${:.2}", micros as f64 / MICROS_PER_DOLLAR as f64)
65+ let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64);
66+ let (whole, fraction) = text.split_once('.').unwrap_or((&text, ""));
67+ let fraction = fraction.trim_end_matches('0');
68+ format!("${whole}.{fraction:0<2}")
6469 }
6570
6671 impl SubscriptionRow {
9297 "Previews that cost nothing while no one visits them".to_owned(),
9398 ],
9499 overage: format!(
95− "Past that, from credit: {} per extra app a month, {} per million requests and {} per million CPU milliseconds (Cloudflare's price plus {}%).",
100+ "Builds, and usage past that, come from credit at Cloudflare's price plus {3}%: {4} per build minute, {0} per extra app a month, {1} per million requests and {2} per million CPU milliseconds.",
96101 dollars(crate::charge_micros(
97102 allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64,
98103 self.margin_percent
105110 allowance::MICROS_PER_MILLION_CPU_MS as f64 / MICROS_PER_DOLLAR as f64,
106111 self.margin_percent
107112 )),
108− self.margin_percent
113+ self.margin_percent,
114+ dollars(crate::charge_micros(
115+ (allowance::MICROS_PER_BUILD_SECOND * 60) as f64 / MICROS_PER_DOLLAR as f64,
116+ self.margin_percent
117+ )),
109118 ),
110119 },
111120 }
400409 Ok(Outcome::Ok(true))
401410 }
402411 }
412+
413+#[cfg(test)]
414+mod tests {
415+ use super::*;
416+
417+ #[test]
418+ fn prices_under_a_cent_keep_their_digits() {
419+ assert_eq!(dollars(1512), "$0.0015");
420+ assert_eq!(dollars(24_000), "$0.024");
421+ assert_eq!(dollars(360_000), "$0.36");
422+ assert_eq!(dollars(5_000_000), "$5.00");
423+ }
424+}
+84−0
1+-- Deployments: previews per pull request and production, on g1t.page.
2+-- Every timestamp is RFC 3339 UTC.
3+
4+-- Each repository's choices. A repository not listed does not deploy.
5+CREATE TABLE settings (
6+ repo_id TEXT PRIMARY KEY,
7+ -- The workspace's slug and the repository's name.
8+ namespace TEXT NOT NULL,
9+ name TEXT NOT NULL,
10+ enabled INTEGER NOT NULL DEFAULT 0,
11+ previews INTEGER NOT NULL DEFAULT 1,
12+ production INTEGER NOT NULL DEFAULT 1,
13+ build_command TEXT,
14+ output_dir TEXT,
15+ -- A JSON object of variables the build runs with.
16+ build_env TEXT NOT NULL DEFAULT '{}',
17+ idle_days INTEGER NOT NULL DEFAULT 7,
18+ updated_by TEXT,
19+ updated_at TEXT NOT NULL
20+);
21+
22+-- Apps that are up, one per script in the dispatch namespace. The script's
23+-- name is the hostname's first label on g1t.page.
24+CREATE TABLE apps (
25+ script TEXT PRIMARY KEY,
26+ repo_id TEXT NOT NULL,
27+ namespace TEXT NOT NULL,
28+ name TEXT NOT NULL,
29+ -- preview or production.
30+ kind TEXT NOT NULL,
31+ -- For a preview: the pull request.
32+ number INTEGER,
33+ commit_sha TEXT NOT NULL,
34+ deployed_at TEXT NOT NULL,
35+ created_at TEXT NOT NULL,
36+ -- When it last answered a request, as of the last count.
37+ last_request_at TEXT
38+);
39+CREATE INDEX apps_by_repo ON apps (repo_id, kind, number);
40+CREATE INDEX apps_by_workspace ON apps (namespace);
41+
42+-- Every build, and where it went.
43+CREATE TABLE deployments (
44+ id TEXT PRIMARY KEY,
45+ repo_id TEXT NOT NULL,
46+ namespace TEXT NOT NULL,
47+ name TEXT NOT NULL,
48+ kind TEXT NOT NULL,
49+ number INTEGER,
50+ commit_sha TEXT NOT NULL,
51+ script TEXT NOT NULL,
52+ -- queued, building, ready, failed or skipped.
53+ status TEXT NOT NULL,
54+ error TEXT,
55+ -- A JSON array.
56+ warnings TEXT NOT NULL DEFAULT '[]',
57+ log TEXT,
58+ -- SHA-256 of the token the sandbox reports with.
59+ token_hash TEXT,
60+ build_seconds INTEGER,
61+ created_by TEXT NOT NULL,
62+ created_at TEXT NOT NULL,
63+ started_at TEXT,
64+ finished_at TEXT
65+);
66+CREATE INDEX deployments_by_repo ON deployments (repo_id, id);
67+CREATE INDEX deployments_by_status ON deployments (status, created_at);
68+
69+-- What each workspace's apps used, per month: counted from Cloudflare's
70+-- analytics, and charged past the plan's allowance once the month is over.
71+CREATE TABLE meters (
72+ namespace TEXT NOT NULL,
73+ -- YYYY-MM.
74+ month TEXT NOT NULL,
75+ requests INTEGER NOT NULL DEFAULT 0,
76+ cpu_ms INTEGER NOT NULL DEFAULT 0,
77+ peak_apps INTEGER NOT NULL DEFAULT 0,
78+ build_seconds INTEGER NOT NULL DEFAULT 0,
79+ build_micros INTEGER NOT NULL DEFAULT 0,
80+ counted_at TEXT,
81+ -- When the month's usage past the allowance was charged.
82+ charged_at TEXT,
83+ PRIMARY KEY (namespace, month)
84+);
+15−0
1+{
2+ "name": "@g1t/deployments",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "license": "MIT",
7+ "scripts": {
8+ "test": "node --test src/*.test.ts",
9+ "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
10+ "deploy": "wrangler deploy"
11+ },
12+ "dependencies": {
13+ "@g1t/contracts": "*"
14+ }
15+}
+195−0
1+/**
2+ * Cloudflare's API, behind the calls deployments need: open an upload of
3+ * an app's files, put the app in the dispatch namespace, take it down, and
4+ * count what each app used.
5+ *
6+ * The token is the service's own, scoped to Workers scripts and analytics on
7+ * g1t's account. It never leaves this Worker: a sandbox only ever gets an
8+ * upload session's key, which can upload one manifest's files and nothing
9+ * else.
10+ */
11+
12+const API = "https://api.cloudflare.com/client/v4";
13+
14+export type Manifest = Record<string, { hash: string; size: number }>;
15+
16+/** A module of a Worker, as the sandbox sends it. */
17+export type Module = { name: string; contentBase64: string; contentType: string };
18+
19+/** What the sandbox built: the Worker's code and settings. */
20+export type BuiltWorker = {
21+ mainModule?: string;
22+ modules?: Module[];
23+ compatibilityDate?: string;
24+ compatibilityFlags?: string[];
25+ vars?: Record<string, unknown>;
26+ assetsBinding?: string | null;
27+ htmlHandling?: string | null;
28+ notFoundHandling?: string | null;
29+ _headers?: string;
30+ _redirects?: string;
31+};
32+
33+/** Serves the site's files, for an app that brings no code of its own. */
34+const ASSETS_ONLY = `export default { fetch(request, env) { return env.ASSETS.fetch(request); } };\n`;
35+
36+const HTML_HANDLING = ["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"];
37+const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"];
38+
39+export class Cloudflare {
40+ constructor(
41+ private readonly token: string,
42+ private readonly account: string,
43+ readonly namespace: string,
44+ ) {}
45+
46+ private async call<T>(method: string, path: string, body?: BodyInit, contentType?: string): Promise<T> {
47+ const headers: Record<string, string> = { authorization: `Bearer ${this.token}` };
48+ if (contentType) headers["content-type"] = contentType;
49+ const response = await fetch(`${API}${path}`, { method, headers, body });
50+ const answer = (await response.json().catch(() => null)) as {
51+ success?: boolean;
52+ result?: T;
53+ errors?: { code: number; message: string }[];
54+ } | null;
55+ if (!response.ok || !answer?.success) {
56+ const why = answer?.errors?.map((error) => `${error.message} (${error.code})`).join("; ");
57+ throw new Error(`Cloudflare answered ${response.status}: ${why || "no reason given"}`);
58+ }
59+ return answer.result as T;
60+ }
61+
62+ private scriptPath(script: string): string {
63+ return `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts/${encodeURIComponent(script)}`;
64+ }
65+
66+ /** Where a sandbox sends the files an upload session asks for. */
67+ get uploadUrl(): string {
68+ return `${API}/accounts/${this.account}/workers/assets/upload?base64=true`;
69+ }
70+
71+ /**
72+ * Opens an upload of exactly these files. Cloudflare answers with a key
73+ * that can upload only them, and the files it does not already have, in
74+ * buckets; with no buckets, the key itself completes the upload.
75+ */
76+ async openUpload(script: string, manifest: Manifest): Promise<{ jwt: string; buckets: string[][] }> {
77+ const result = await this.call<{ jwt: string; buckets?: string[][] }>(
78+ "POST",
79+ `${this.scriptPath(script)}/assets-upload-session`,
80+ JSON.stringify({ manifest }),
81+ "application/json",
82+ );
83+ return { jwt: result.jwt, buckets: result.buckets ?? [] };
84+ }
85+
86+ /** Puts an app in the namespace, replacing what was there. */
87+ async putScript(
88+ script: string,
89+ worker: BuiltWorker,
90+ completionJwt: string | null,
91+ tags: string[],
92+ ): Promise<void> {
93+ const form = new FormData();
94+ const modules = worker.modules?.length ? worker.modules : null;
95+ const assetsBinding = worker.assetsBinding || "ASSETS";
96+ const bindings: object[] = Object.entries(worker.vars ?? {}).map(([name, value]) =>
97+ typeof value === "string"
98+ ? { type: "plain_text", name, text: value }
99+ : { type: "json", name, json: value },
100+ );
101+ if (completionJwt) bindings.push({ type: "assets", name: assetsBinding });
102+ const assetsConfig: Record<string, string> = {};
103+ if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) {
104+ assetsConfig.html_handling = worker.htmlHandling;
105+ }
106+ if (worker.notFoundHandling && NOT_FOUND_HANDLING.includes(worker.notFoundHandling)) {
107+ assetsConfig.not_found_handling = worker.notFoundHandling;
108+ }
109+ if (worker._headers) assetsConfig._headers = worker._headers;
110+ if (worker._redirects) assetsConfig._redirects = worker._redirects;
111+ const mainModule = modules ? worker.mainModule ?? modules[0].name : "index.js";
112+ form.append(
113+ "metadata",
114+ JSON.stringify({
115+ main_module: mainModule,
116+ compatibility_date: worker.compatibilityDate ?? "2026-09-26",
117+ compatibility_flags: worker.compatibilityFlags ?? [],
118+ bindings,
119+ tags,
120+ ...(completionJwt ? { assets: { jwt: completionJwt, config: assetsConfig } } : {}),
121+ }),
122+ );
123+ if (modules) {
124+ for (const module of modules) {
125+ const bytes = Uint8Array.from(atob(module.contentBase64), (c) => c.charCodeAt(0));
126+ form.append(module.name, new File([bytes], module.name, { type: module.contentType }));
127+ }
128+ } else {
129+ if (!completionJwt) throw new Error("The build produced neither code nor files to serve.");
130+ form.append(
131+ "index.js",
132+ new File([ASSETS_ONLY], "index.js", { type: "application/javascript+module" }),
133+ );
134+ }
135+ await this.call("PUT", this.scriptPath(script), form);
136+ }
137+
138+ /** Takes an app down. Already gone is fine. */
139+ async deleteScript(script: string): Promise<void> {
140+ try {
141+ await this.call("DELETE", `${this.scriptPath(script)}?force=true`);
142+ } catch (error) {
143+ if (!/404|not found|10007/i.test(String(error))) throw error;
144+ }
145+ }
146+
147+ /**
148+ * Requests and CPU time per app over a period, from Workers analytics.
149+ * Apps with no traffic are absent.
150+ */
151+ async usage(
152+ scripts: string[],
153+ since: string,
154+ until: string,
155+ ): Promise<Map<string, { requests: number; cpuMs: number }>> {
156+ const totals = new Map<string, { requests: number; cpuMs: number }>();
157+ if (scripts.length === 0) return totals;
158+ const query = (withCpu: boolean) => `query ($account: string!, $since: Time!, $until: Time!, $scripts: [string!]) {
159+ viewer { accounts(filter: { accountTag: $account }) {
160+ workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $since, datetime_lt: $until, scriptName_in: $scripts }) {
161+ sum { requests${withCpu ? " cpuTimeUs" : ""} }
162+ dimensions { scriptName }
163+ }
164+ } }
165+ }`;
166+ type Row = { sum: { requests: number; cpuTimeUs?: number }; dimensions: { scriptName: string } };
167+ const ask = async (withCpu: boolean) => {
168+ const response = await fetch(`${API}/graphql`, {
169+ method: "POST",
170+ headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
171+ body: JSON.stringify({
172+ query: query(withCpu),
173+ variables: { account: this.account, since, until, scripts },
174+ }),
175+ });
176+ return (await response.json()) as {
177+ data?: { viewer: { accounts: { workersInvocationsAdaptive: Row[] }[] } };
178+ errors?: { message: string }[] | null;
179+ };
180+ };
181+ let answer = await ask(true);
182+ // CPU time is counted where analytics offers it; requests always.
183+ if (answer.errors?.length) answer = await ask(false);
184+ if (answer.errors?.length || !answer.data) {
185+ throw new Error(`Workers analytics refused: ${answer.errors?.map((e) => e.message).join("; ")}`);
186+ }
187+ for (const row of answer.data.viewer.accounts[0]?.workersInvocationsAdaptive ?? []) {
188+ const seen = totals.get(row.dimensions.scriptName) ?? { requests: 0, cpuMs: 0 };
189+ seen.requests += row.sum.requests;
190+ seen.cpuMs += Math.ceil((row.sum.cpuTimeUs ?? 0) / 1000);
191+ totals.set(row.dimensions.scriptName, seen);
192+ }
193+ return totals;
194+ }
195+}
+881−0
1+/**
2+ * The deployments service: every pull request gets a live preview on
3+ * g1t.page, and the default branch goes to production on every push.
4+ *
5+ * It reacts to events (a pull request opened, ready, pushed to, closed or
6+ * merged; a push to the default branch), asks billing whether the
7+ * workspace pays for Deployments, and asks the runner to build the commit
8+ * in a sandbox. The sandbox reports back through the API with a token for
9+ * that build alone; this service opens the upload of its files and puts
10+ * the finished app in the Workers for Platforms namespace, where the
11+ * `*.g1t.page` dispatcher finds it by hostname.
12+ *
13+ * Nothing here is free. A build is charged by the second; requests, CPU
14+ * time and apps past the plan's allowance are charged once the month is
15+ * over. A Worker runs only while it answers a request, so an app no one
16+ * visits costs nothing, and a preview is taken down when its pull request
17+ * closes or after its repository's idle days.
18+ *
19+ * Reached through service bindings (`POST /rpc/<method>`) and, for a
20+ * build's reports, through the API (`POST /jobs/<id>/<step>`).
21+ */
22+
23+import {
24+ DEPLOYMENTS_ALLOWANCE,
25+ billingClient,
26+ fail,
27+ identityClient,
28+ newId,
29+ ok,
30+ reposClient,
31+ workClient,
32+ type DeployKind,
33+ type DeploySettings,
34+ type DeployStatus,
35+ type DeployUsage,
36+ type Deployment,
37+ type G1tEvent,
38+ type LiveApp,
39+ type RepoPath,
40+ type Result,
41+ type ServiceBinding,
42+ type User,
43+ type Viewer,
44+} from "@g1t/contracts";
45+
46+import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
47+import { appUrl, scriptName } from "./names";
48+
49+type Env = {
50+ DB: D1Database;
51+ REPOS: ServiceBinding;
52+ WORK: ServiceBinding;
53+ IDENTITY: ServiceBinding;
54+ BILLING: ServiceBinding;
55+ RUNNER: ServiceBinding;
56+ /** Secret: scoped to Workers scripts and analytics on g1t's account. */
57+ CLOUDFLARE_API_TOKEN?: string;
58+ CLOUDFLARE_ACCOUNT_ID: string;
59+ DISPATCH_NAMESPACE: string;
60+ SITE: string;
61+};
62+
63+/** A build that has not reported in this long has died. */
64+const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
65+const LIST_LIMIT = 50;
66+const MAX_ENV_VARS = 50;
67+const STATUS_CONTEXT = "g1t / deploy";
68+
69+const now = () => new Date().toISOString();
70+const month = (at = new Date()) => at.toISOString().slice(0, 7);
71+
72+async function sha256(text: string): Promise<string> {
73+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
74+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
75+}
76+
77+function randomToken(): string {
78+ return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
79+}
80+
81+function isMember(viewer: Viewer, slug: string): boolean {
82+ return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
83+}
84+
85+type SettingsRow = {
86+ repo_id: string;
87+ namespace: string;
88+ name: string;
89+ enabled: number;
90+ previews: number;
91+ production: number;
92+ build_command: string | null;
93+ output_dir: string | null;
94+ build_env: string;
95+ idle_days: number;
96+};
97+
98+type DeploymentRow = {
99+ id: string;
100+ repo_id: string;
101+ namespace: string;
102+ name: string;
103+ kind: DeployKind;
104+ number: number | null;
105+ commit_sha: string;
106+ script: string;
107+ status: DeployStatus;
108+ error: string | null;
109+ warnings: string;
110+ log: string | null;
111+ token_hash: string | null;
112+ build_seconds: number | null;
113+ created_by: string;
114+ created_at: string;
115+ finished_at: string | null;
116+};
117+
118+type AppRow = {
119+ script: string;
120+ repo_id: string;
121+ namespace: string;
122+ name: string;
123+ kind: DeployKind;
124+ number: number | null;
125+ commit_sha: string;
126+ deployed_at: string;
127+ created_at: string;
128+ last_request_at: string | null;
129+};
130+
131+function toDeployment(row: DeploymentRow): Deployment {
132+ return {
133+ id: row.id,
134+ kind: row.kind,
135+ number: row.number,
136+ commit: row.commit_sha,
137+ status: row.status,
138+ url: appUrl(row.script),
139+ error: row.error,
140+ warnings: JSON.parse(row.warnings || "[]") as string[],
141+ buildSeconds: row.build_seconds,
142+ createdBy: row.created_by,
143+ createdAt: row.created_at,
144+ finishedAt: row.finished_at,
145+ };
146+}
147+
148+class Deployments {
149+ constructor(private readonly env: Env) {}
150+
151+ private get cloudflare(): Cloudflare | null {
152+ const token = this.env.CLOUDFLARE_API_TOKEN;
153+ return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
154+ }
155+
156+ private get db() {
157+ return this.env.DB;
158+ }
159+
160+ /** The workspace itself, as the service acts for it. */
161+ private async workspaceActor(slug: string): Promise<User | null> {
162+ const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
163+ if (!workspace) return null;
164+ return {
165+ id: workspace.id,
166+ username: workspace.slug,
167+ kind: "workspace",
168+ verified: true,
169+ workspaces: [{ slug: workspace.slug, role: "member" }],
170+ };
171+ }
172+
173+ private async pathById(id: string): Promise<RepoPath | null> {
174+ const response = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
175+ method: "POST",
176+ headers: { "content-type": "application/json" },
177+ body: JSON.stringify({ id }),
178+ });
179+ return response.ok ? ((await response.json()) as RepoPath | null) : null;
180+ }
181+
182+ private async settingsRow(repoId: string): Promise<SettingsRow | null> {
183+ return this.db.prepare("SELECT * FROM settings WHERE repo_id = ?").bind(repoId).first<SettingsRow>();
184+ }
185+
186+ private async toSettings(repo: RepoPath, row: SettingsRow | null): Promise<DeploySettings> {
187+ return {
188+ enabled: !!row?.enabled,
189+ previews: row ? !!row.previews : true,
190+ production: row ? !!row.production : true,
191+ buildCommand: row?.build_command ?? null,
192+ outputDir: row?.output_dir ?? null,
193+ buildEnv: JSON.parse(row?.build_env ?? "{}") as Record<string, string>,
194+ idleDays: row?.idle_days ?? 7,
195+ productionUrl: appUrl(await scriptName(repo, null)),
196+ };
197+ }
198+
199+ /** The repository, if `viewer` belongs to its workspace and it is not a fork. */
200+ private async memberRepo(repo: RepoPath, viewer: Viewer) {
201+ if (!isMember(viewer, repo.namespace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
202+ const found = await reposClient(this.env.REPOS).get(repo, viewer);
203+ if (!found.ok) return found;
204+ if (found.value.forkOf) return fail("invalid", "A pull request's working copy does not deploy on its own.");
205+ return found;
206+ }
207+
208+ // ---- Methods for the site and the API ------------------------------
209+
210+ async settings(a: { repo: RepoPath; viewer: Viewer }): Promise<Result<DeploySettings>> {
211+ const repo = await this.memberRepo(a.repo, a.viewer);
212+ if (!repo.ok) return repo;
213+ return ok(await this.toSettings(a.repo, await this.settingsRow(repo.value.id)));
214+ }
215+
216+ async updateSettings(a: {
217+ actor: User;
218+ repo: RepoPath;
219+ changes: Partial<DeploySettings>;
220+ }): Promise<Result<DeploySettings>> {
221+ const repo = await this.memberRepo(a.repo, a.actor);
222+ if (!repo.ok) return repo;
223+ const before = await this.toSettings(a.repo, await this.settingsRow(repo.value.id));
224+ const next = { ...before, ...a.changes };
225+ if (next.enabled && !before.enabled) {
226+ // Turning it on starts paid work: only with the workspace's plan.
227+ const plan = await billingClient(this.env.BILLING).hasFeature(a.repo.namespace, "deployments");
228+ if (!plan.ok) return plan;
229+ }
230+ const env = Object.entries(next.buildEnv ?? {});
231+ if (env.length > MAX_ENV_VARS) return fail("invalid", `At most ${MAX_ENV_VARS} build variables.`);
232+ if (env.some(([name]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) {
233+ return fail("invalid", "A variable's name is letters, digits and underscores, not starting with a digit.");
234+ }
235+ const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
236+ const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
237+ await this.db
238+ .prepare(
239+ `INSERT INTO settings (repo_id, namespace, name, enabled, previews, production, build_command, output_dir,
240+ build_env, idle_days, updated_by, updated_at)
241+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
242+ ON CONFLICT (repo_id) DO UPDATE SET namespace = ?2, name = ?3, enabled = ?4, previews = ?5, production = ?6,
243+ build_command = ?7, output_dir = ?8, build_env = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
244+ )
245+ .bind(
246+ repo.value.id,
247+ repo.value.namespace,
248+ repo.value.name,
249+ next.enabled ? 1 : 0,
250+ next.previews ? 1 : 0,
251+ next.production ? 1 : 0,
252+ clip(next.buildCommand),
253+ clip(next.outputDir),
254+ JSON.stringify(Object.fromEntries(env.map(([k, v]) => [k, String(v).slice(0, 2000)]))),
255+ idleDays,
256+ a.actor.username,
257+ now(),
258+ )
259+ .run();
260+ // What was turned off comes down now; nothing keeps running unasked.
261+ if (!next.enabled) await this.takeDownWhere(repo.value.id, null);
262+ else {
263+ if (!next.previews) await this.takeDownWhere(repo.value.id, "preview");
264+ if (!next.production) await this.takeDownWhere(repo.value.id, "production");
265+ }
266+ // Turned on: production goes up from the default branch at once.
267+ if (next.enabled && next.production && (!before.enabled || !before.production)) {
268+ await this.deployProduction(repo.value.id, a.repo, repo.value.defaultBranch, null, a.actor.username);
269+ }
270+ return ok(await this.toSettings(a.repo, await this.settingsRow(repo.value.id)));
271+ }
272+
273+ async list(a: { repo: RepoPath; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
274+ const repo = await this.memberRepo(a.repo, a.viewer);
275+ if (!repo.ok) return repo;
276+ const [deployments, apps] = await Promise.all([
277+ this.db
278+ .prepare("SELECT * FROM deployments WHERE repo_id = ? ORDER BY id DESC LIMIT ?")
279+ .bind(repo.value.id, LIST_LIMIT)
280+ .all<DeploymentRow>(),
281+ this.db
282+ .prepare("SELECT * FROM apps WHERE repo_id = ? ORDER BY kind DESC, number DESC")
283+ .bind(repo.value.id)
284+ .all<AppRow>(),
285+ ]);
286+ return ok({
287+ deployments: deployments.results.map(toDeployment),
288+ live: apps.results.map((app) => ({
289+ kind: app.kind,
290+ number: app.number,
291+ url: appUrl(app.script),
292+ commit: app.commit_sha,
293+ deployedAt: app.deployed_at,
294+ })),
295+ });
296+ }
297+
298+ async get(a: { repo: RepoPath; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
299+ const repo = await this.memberRepo(a.repo, a.viewer);
300+ if (!repo.ok) return repo;
301+ const row = await this.db
302+ .prepare("SELECT * FROM deployments WHERE id = ? AND repo_id = ?")
303+ .bind(a.id, repo.value.id)
304+ .first<DeploymentRow>();
305+ if (!row) return fail("not_found", "No such deployment.");
306+ return ok({ ...toDeployment(row), log: row.log });
307+ }
308+
309+ async redeploy(a: { actor: User; repo: RepoPath; number: number | null }): Promise<Result<Deployment>> {
310+ const repo = await this.memberRepo(a.repo, a.actor);
311+ if (!repo.ok) return repo;
312+ const settings = await this.settingsRow(repo.value.id);
313+ if (!settings?.enabled) return fail("conflict", "Deployments are off for this repository.");
314+ const started =
315+ a.number == null
316+ ? await this.deployProduction(repo.value.id, a.repo, repo.value.defaultBranch, null, a.actor.username)
317+ : await this.deployPreview(repo.value.id, a.repo, a.number, a.actor.username, true);
318+ return started ?? fail("conflict", "There was nothing to deploy.");
319+ }
320+
321+ async takeDown(a: { actor: User; repo: RepoPath; number: number | null }): Promise<Result<true>> {
322+ const repo = await this.memberRepo(a.repo, a.actor);
323+ if (!repo.ok) return repo;
324+ const script = await scriptName(a.repo, a.number);
325+ await this.removeApp(script);
326+ return ok(true);
327+ }
328+
329+ async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
330+ const slug = a.workspace.toLowerCase();
331+ if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
332+ const [meter, apps] = await Promise.all([
333+ this.db
334+ .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
335+ .bind(slug, month())
336+ .first<{
337+ requests: number;
338+ cpu_ms: number;
339+ peak_apps: number;
340+ build_seconds: number;
341+ build_micros: number;
342+ counted_at: string | null;
343+ }>(),
344+ this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE namespace = ?").bind(slug).first<{ n: number }>(),
345+ ]);
346+ return ok({
347+ month: month(),
348+ requests: meter?.requests ?? 0,
349+ cpuMs: meter?.cpu_ms ?? 0,
350+ apps: apps?.n ?? 0,
351+ peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
352+ buildSeconds: meter?.build_seconds ?? 0,
353+ buildMicros: meter?.build_micros ?? 0,
354+ countedAt: meter?.counted_at ?? null,
355+ });
356+ }
357+
358+ // ---- Starting builds -----------------------------------------------
359+
360+ /**
361+ * Opens a deployment and starts its build. Skipped, with the reason
362+ * recorded, when the workspace's plan is off.
363+ */
364+ private async start(input: {
365+ repoId: string;
366+ repo: RepoPath;
367+ kind: DeployKind;
368+ number: number | null;
369+ commit: string;
370+ source: RepoPath;
371+ reader: User;
372+ createdBy: string;
373+ settings: SettingsRow;
374+ }): Promise<Result<Deployment>> {
375+ const script = await scriptName(input.repo, input.number);
376+ const id = newId("dpl");
377+ const token = randomToken();
378+ const plan = await billingClient(this.env.BILLING).hasFeature(input.repo.namespace, "deployments");
379+ const cloudflare = this.cloudflare;
380+ const refused = !plan.ok
381+ ? plan.error.message
382+ : !cloudflare
383+ ? "Deployments are not set up on this g1t: it has no Cloudflare token."
384+ : null;
385+ await this.db
386+ .prepare(
387+ `INSERT INTO deployments (id, repo_id, namespace, name, kind, number, commit_sha, script, status, error,
388+ token_hash, created_by, created_at, finished_at)
389+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
390+ )
391+ .bind(
392+ id,
393+ input.repoId,
394+ input.repo.namespace,
395+ input.repo.name,
396+ input.kind,
397+ input.number,
398+ input.commit,
399+ script,
400+ refused ? "skipped" : "queued",
401+ refused,
402+ refused ? null : await sha256(token),
403+ input.createdBy,
404+ now(),
405+ refused ? now() : null,
406+ )
407+ .run();
408+ if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
409+ // Older builds of the same app are replaced by this one.
410+ await this.db
411+ .prepare(
412+ `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
413+ WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
414+ )
415+ .bind(now(), script, id)
416+ .run();
417+ await this.status(input.repoId, input.commit, "pending", "Building", `${this.env.SITE}/${input.repo.namespace}/${input.repo.name}/deployments/${id}`);
418+ const env = JSON.parse(input.settings.build_env || "{}") as Record<string, string>;
419+ const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
420+ method: "POST",
421+ headers: { "content-type": "application/json" },
422+ body: JSON.stringify({
423+ deployId: id,
424+ token,
425+ actor: input.reader,
426+ source: input.source,
427+ commit: input.commit,
428+ buildCommand: input.settings.build_command,
429+ outputDir: input.settings.output_dir,
430+ buildEnv: env,
431+ }),
432+ });
433+ const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
434+ if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
435+ return ok(toDeployment((await this.deploymentRow(id))!));
436+ }
437+
438+ private async deployProduction(
439+ repoId: string,
440+ repo: RepoPath,
441+ branch: string,
442+ commit: string | null,
443+ createdBy: string,
444+ ): Promise<Result<Deployment> | null> {
445+ const settings = await this.settingsRow(repoId);
446+ if (!settings?.enabled || !settings.production) return null;
447+ const actor = await this.workspaceActor(repo.namespace);
448+ if (!actor) return null;
449+ let head = commit;
450+ if (!head) {
451+ const branches = await reposClient(this.env.REPOS).branches(repo, actor);
452+ head = branches.ok ? (branches.value.find((b) => b.name === branch)?.hash ?? null) : null;
453+ }
454+ if (!head) return null;
455+ return this.start({
456+ repoId,
457+ repo,
458+ kind: "production",
459+ number: null,
460+ commit: head,
461+ source: repo,
462+ reader: actor,
463+ createdBy,
464+ settings,
465+ });
466+ }
467+
468+ private async deployPreview(
469+ repoId: string,
470+ repo: RepoPath,
471+ number: number,
472+ createdBy: string,
473+ force = false,
474+ ): Promise<Result<Deployment> | null> {
475+ const settings = await this.settingsRow(repoId);
476+ if (!settings?.enabled || !settings.previews) return null;
477+ const actor = await this.workspaceActor(repo.namespace);
478+ if (!actor) return null;
479+ const detail = await workClient(this.env.WORK).getPull(repo, number, actor);
480+ if (!detail.ok) return null;
481+ const { pull } = detail.value;
482+ if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
483+ if (!force) {
484+ // Already built, or being built, at this commit.
485+ const same = await this.db
486+ .prepare(
487+ `SELECT id FROM deployments WHERE repo_id = ? AND kind = 'preview' AND number = ? AND commit_sha = ?
488+ AND status IN ('queued', 'building', 'ready')`,
489+ )
490+ .bind(repoId, number, pull.headCommit)
491+ .first();
492+ if (same) return null;
493+ }
494+ return this.start({
495+ repoId,
496+ repo,
497+ kind: "preview",
498+ number,
499+ commit: pull.headCommit,
500+ source: pull.fork ?? repo,
501+ // The pull request's fork may be private: read it as its author.
502+ reader: pull.author,
503+ createdBy,
504+ settings,
505+ });
506+ }
507+
508+ // ---- A build's reports ---------------------------------------------
509+
510+ private async deploymentRow(id: string): Promise<DeploymentRow | null> {
511+ return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
512+ }
513+
514+ /** The build, if `token` is its own and it is still under way. */
515+ private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
516+ const row = await this.deploymentRow(id);
517+ if (!row?.token_hash || typeof token !== "string") return null;
518+ if (row.token_hash !== (await sha256(token))) return null;
519+ return row.status === "queued" || row.status === "building" ? row : null;
520+ }
521+
522+ async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
523+ const row = await this.building(id, body.token);
524+ if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
525+ const cloudflare = this.cloudflare;
526+ if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
527+ switch (step) {
528+ case "started":
529+ await this.db
530+ .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
531+ .bind(now(), id)
532+ .run();
533+ return Response.json(ok(true));
534+ case "session": {
535+ const manifest = body.manifest as Manifest | undefined;
536+ if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
537+ const session = await cloudflare.openUpload(row.script, manifest);
538+ return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
539+ }
540+ case "finish": {
541+ const worker = (body.worker ?? {}) as BuiltWorker;
542+ const seconds = Number(body.buildSeconds) || 0;
543+ try {
544+ await cloudflare.putScript(
545+ row.script,
546+ worker,
547+ typeof body.completionJwt === "string" ? body.completionJwt : null,
548+ [`workspace:${row.namespace}`, `repo:${row.namespace}/${row.name}`, row.kind],
549+ );
550+ } catch (error) {
551+ await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
552+ return Response.json(ok(false));
553+ }
554+ const at = now();
555+ await this.db.batch([
556+ this.db
557+ .prepare(
558+ `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
559+ WHERE id = ?`,
560+ )
561+ .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
562+ this.db
563+ .prepare(
564+ `INSERT INTO apps (script, repo_id, namespace, name, kind, number, commit_sha, deployed_at, created_at)
565+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)
566+ ON CONFLICT (script) DO UPDATE SET commit_sha = ?7, deployed_at = ?8`,
567+ )
568+ .bind(row.script, row.repo_id, row.namespace, row.name, row.kind, row.number, row.commit_sha, at),
569+ ]);
570+ await this.chargeBuild(row, seconds);
571+ await this.notePeak(row.namespace);
572+ await this.status(
573+ row.repo_id,
574+ row.commit_sha,
575+ "success",
576+ row.kind === "preview" ? "Preview is live" : "Production is live",
577+ appUrl(row.script),
578+ );
579+ return Response.json(ok(true));
580+ }
581+ case "fail":
582+ await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
583+ return Response.json(ok(true));
584+ default:
585+ return Response.json(fail("not_found", "No such step."), { status: 404 });
586+ }
587+ }
588+
589+ private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
590+ const row = await this.deploymentRow(id);
591+ if (!row || (row.status !== "queued" && row.status !== "building")) return;
592+ await this.db
593+ .prepare(
594+ `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
595+ WHERE id = ?`,
596+ )
597+ .bind(message.slice(0, 2000), log, seconds, now(), id)
598+ .run();
599+ // A failed build still used its sandbox.
600+ if (seconds) await this.chargeBuild(row, seconds);
601+ await this.status(
602+ row.repo_id,
603+ row.commit_sha,
604+ "failure",
605+ "Deployment failed",
606+ `${this.env.SITE}/${row.namespace}/${row.name}/deployments/${id}`,
607+ );
608+ }
609+
610+ /** Each build is charged by the second at the container price plus the margin. */
611+ private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
612+ const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond;
613+ if (cost <= 0) return;
614+ const what = row.kind === "preview" ? `the preview of ${row.namespace}/${row.name}#${row.number}` : `${row.namespace}/${row.name} to production`;
615+ await billingClient(this.env.BILLING).chargeFeature({
616+ workspace: row.namespace,
617+ feature: "deployments",
618+ costMicros: cost,
619+ description: `Building ${what} (${Math.ceil(seconds)} s)`,
620+ repo: `${row.namespace}/${row.name}`,
621+ reference: `deploy/${row.id}`,
622+ });
623+ await this.db
624+ .prepare(
625+ `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
626+ ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
627+ )
628+ .bind(row.namespace, month(), Math.ceil(seconds), cost)
629+ .run();
630+ }
631+
632+ /** Remembers the most apps the workspace had up at once this month. */
633+ private async notePeak(namespace: string): Promise<void> {
634+ await this.db
635+ .prepare(
636+ `INSERT INTO meters (namespace, month, peak_apps)
637+ VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE namespace = ?1))
638+ ON CONFLICT (namespace, month) DO UPDATE SET
639+ peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE namespace = ?1))`,
640+ )
641+ .bind(namespace, month())
642+ .run();
643+ }
644+
645+ private async status(repoId: string, sha: string, state: string, description: string, targetUrl: string): Promise<void> {
646+ await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
647+ method: "POST",
648+ headers: { "content-type": "application/json" },
649+ body: JSON.stringify({ repoId, sha, context: STATUS_CONTEXT, state, description, targetUrl }),
650+ }).catch(() => undefined);
651+ }
652+
653+ // ---- Taking apps down ----------------------------------------------
654+
655+ private async removeApp(script: string): Promise<void> {
656+ await this.cloudflare?.deleteScript(script);
657+ await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
658+ }
659+
660+ private async takeDownWhere(repoId: string, kind: DeployKind | null, number?: number): Promise<void> {
661+ const apps = await this.db
662+ .prepare(
663+ `SELECT script FROM apps WHERE repo_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR number = ?3)`,
664+ )
665+ .bind(repoId, kind, number ?? null)
666+ .all<{ script: string }>();
667+ for (const app of apps.results) await this.removeApp(app.script);
668+ }
669+
670+ // ---- Events --------------------------------------------------------
671+
672+ async onEvent(event: G1tEvent): Promise<void> {
673+ switch (event.type) {
674+ case "pull.opened":
675+ case "pull.ready":
676+ case "pull.updated": {
677+ const repo = await this.pathById(event.data.repoId);
678+ if (repo) await this.deployPreview(event.data.repoId, repo, event.data.number, "g1t");
679+ break;
680+ }
681+ case "pull.closed":
682+ case "pull.merged":
683+ await this.takeDownWhere(event.data.repoId, "preview", event.data.number);
684+ break;
685+ case "git.push": {
686+ if (!event.data.defaultBranch) break;
687+ const repo = await this.pathById(event.data.repoId);
688+ if (!repo) break;
689+ await this.deployProduction(
690+ event.data.repoId,
691+ repo,
692+ event.data.ref.replace(/^refs\/heads\//, ""),
693+ event.data.after,
694+ event.actor ?? "g1t",
695+ );
696+ break;
697+ }
698+ }
699+ }
700+
701+ // ---- The sweep -----------------------------------------------------
702+
703+ /**
704+ * Every few minutes: builds that died are failed; usage is counted; idle
705+ * previews and the apps of workspaces whose plan ended come down; and a
706+ * month that is over is charged past its allowance.
707+ */
708+ async sweep(): Promise<void> {
709+ const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
710+ const stuck = await this.db
711+ .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
712+ .bind(cutoff)
713+ .all<{ id: string }>();
714+ for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
715+
716+ const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
717+ const workspaces = [...new Set(apps.map((app) => app.namespace))];
718+
719+ // Apps of workspaces whose plan has ended come down.
720+ const billing = billingClient(this.env.BILLING);
721+ for (const workspace of workspaces) {
722+ const plan = await billing.hasFeature(workspace, "deployments");
723+ if (!plan.ok && plan.error.code === "payment_required") {
724+ for (const app of apps.filter((a) => a.namespace === workspace)) await this.removeApp(app.script);
725+ }
726+ }
727+
728+ await this.count(apps).catch((error) => console.error("could not count usage", error));
729+ await this.takeDownIdle();
730+ await this.chargeMonths();
731+ }
732+
733+ /** Counts this month's requests and CPU time per workspace, from analytics. */
734+ private async count(apps: AppRow[]): Promise<void> {
735+ const cloudflare = this.cloudflare;
736+ if (!cloudflare || apps.length === 0) return;
737+ const start = `${month()}-01T00:00:00Z`;
738+ const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
739+ // Analytics only counts apps that are up; the meter keeps what earlier
740+ // apps used by never going down.
741+ const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
742+ for (const app of apps) {
743+ const used = totals.get(app.script);
744+ if (!used) continue;
745+ const sum = perWorkspace.get(app.namespace) ?? { requests: 0, cpuMs: 0 };
746+ sum.requests += used.requests;
747+ sum.cpuMs += used.cpuMs;
748+ perWorkspace.set(app.namespace, sum);
749+ }
750+ const at = now();
751+ for (const [namespace, used] of perWorkspace) {
752+ await this.db
753+ .prepare(
754+ `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
755+ ON CONFLICT (namespace, month) DO UPDATE SET
756+ requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
757+ )
758+ .bind(namespace, month(), used.requests, used.cpuMs, at)
759+ .run();
760+ }
761+ // When each preview last answered anyone, for the idle sweep.
762+ const recent = await cloudflare.usage(
763+ apps.filter((app) => app.kind === "preview").map((app) => app.script),
764+ new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
765+ at,
766+ );
767+ for (const [script, used] of recent) {
768+ if (used.requests > 0) {
769+ await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
770+ }
771+ }
772+ for (const namespace of new Set(apps.map((app) => app.namespace))) await this.notePeak(namespace);
773+ }
774+
775+ /** Previews no one has visited in their repository's idle days. */
776+ private async takeDownIdle(): Promise<void> {
777+ const idle = await this.db
778+ .prepare(
779+ `SELECT apps.script FROM apps JOIN settings ON settings.repo_id = apps.repo_id
780+ WHERE apps.kind = 'preview'
781+ AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
782+ )
783+ .all<{ script: string }>();
784+ for (const { script } of idle.results) await this.removeApp(script);
785+ }
786+
787+ /** Charges each month that is over for what it used past the allowance, once. */
788+ private async chargeMonths(): Promise<void> {
789+ const due = await this.db
790+ .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
791+ .bind(month())
792+ .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
793+ const a = DEPLOYMENTS_ALLOWANCE;
794+ for (const meter of due.results) {
795+ const extraRequests = Math.max(0, meter.requests - a.requests);
796+ const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
797+ const extraApps = Math.max(0, meter.peak_apps - a.apps);
798+ const cost = Math.ceil(
799+ (extraRequests / 1_000_000) * a.microsPerMillionRequests +
800+ (extraCpu / 1_000_000) * a.microsPerMillionCpuMs +
801+ extraApps * a.microsPerAppMonth,
802+ );
803+ if (cost > 0) {
804+ const parts = [
805+ extraApps && `${extraApps} extra apps`,
806+ extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
807+ extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
808+ ].filter(Boolean);
809+ const charged = await billingClient(this.env.BILLING).chargeFeature({
810+ workspace: meter.namespace,
811+ feature: "deployments",
812+ costMicros: cost,
813+ description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
814+ reference: `deployments/${meter.namespace}/${meter.month}`,
815+ });
816+ if (!charged.ok) continue;
817+ }
818+ await this.db
819+ .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
820+ .bind(now(), meter.namespace, meter.month)
821+ .run();
822+ }
823+ }
824+}
825+
826+/** `POST /rpc/<method>`: the arguments are the body. */
827+async function rpc(service: Deployments, method: string, args: any): Promise<unknown> {
828+ switch (method) {
829+ case "settings":
830+ return service.settings(args);
831+ case "update_settings":
832+ return service.updateSettings(args);
833+ case "list":
834+ return service.list(args);
835+ case "get":
836+ return service.get(args);
837+ case "redeploy":
838+ return service.redeploy(args);
839+ case "take_down":
840+ return service.takeDown(args);
841+ case "usage":
842+ return service.usage(args);
843+ default:
844+ return undefined;
845+ }
846+}
847+
848+export default {
849+ async fetch(request: Request, env: Env): Promise<Response> {
850+ const { pathname } = new URL(request.url);
851+ if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
852+ const service = new Deployments(env);
853+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
854+ const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
855+ if (rpcMatch) {
856+ const result = await rpc(service, rpcMatch[1], body);
857+ return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
858+ }
859+ // A build's reports, forwarded by the API.
860+ const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
861+ if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
862+ return new Response("Not found\n", { status: 404 });
863+ },
864+
865+ async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
866+ const service = new Deployments(env);
867+ for (const message of batch.messages) {
868+ try {
869+ await service.onEvent(message.body);
870+ message.ack();
871+ } catch (error) {
872+ console.error("deployments could not handle", message.body.type, error);
873+ message.retry();
874+ }
875+ }
876+ },
877+
878+ async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
879+ await new Deployments(env).sweep();
880+ },
881+} satisfies ExportedHandler<Env, G1tEvent>;
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.