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.
| 1006 | 1006 | "serde_json", | |
| 1007 | 1007 | "serde_yaml", | |
| 1008 | 1008 | "sha2 0.10.9", | |
| 1009 | + | "toml", | |
| 1009 | 1010 | "ureq", | |
| 1010 | 1011 | ] | |
| 1011 | 1012 | ||
| 2589 | 2590 | ] | |
| 2590 | 2591 | ||
| 2591 | 2592 | [[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]] | |
| 2592 | 2602 | name = "serde_urlencoded" | |
| 2593 | 2603 | version = "0.7.1" | |
| 2594 | 2604 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3025 | 3035 | ] | |
| 3026 | 3036 | ||
| 3027 | 3037 | [[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]] | |
| 3028 | 3079 | name = "tower" | |
| 3029 | 3080 | version = "0.5.3" | |
| 3030 | 3081 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3569 | 3620 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | |
| 3570 | 3621 | ||
| 3571 | 3622 | [[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]] | |
| 3572 | 3632 | name = "wnaf" | |
| 3573 | 3633 | version = "0.14.1" | |
| 3574 | 3634 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 347 | 347 | return receive_hook(&mut request, &services, id).await; | |
| 348 | 348 | } | |
| 349 | 349 | ||
| 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 | + | ||
| 350 | 370 | // A sandbox's artifacts and cache, with its job's token, which is not a | |
| 351 | 371 | // g1t token either. | |
| 352 | 372 | if !on_mcp |
| 21 | 21 | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 22 | 22 | { "binding": "INTEGRATIONS", "service": "g1t-integrations" }, | |
| 23 | 23 | { "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" } | |
| 25 | 27 | ], | |
| 26 | 28 | // GitHub Actions artifacts and cache, in chunks, with KV's own expiry. | |
| 27 | 29 | // (Moves to R2 once R2 is enabled on the account.) |
| 90 | 90 | label: 'Landing changes', | |
| 91 | 91 | items: [ | |
| 92 | 92 | { label: 'The merge queue', slug: 'guides/merge-queue' }, | |
| 93 | + | { label: 'Deployments', slug: 'guides/deployments' }, | |
| 93 | 94 | { label: 'Sessions and why-blame', slug: 'guides/why-blame' }, | |
| 94 | 95 | { label: 'Forks and branches', slug: 'concepts/forks' }, | |
| 95 | 96 | ], |
| 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. |
| 13 | 13 | Hosting repositories, issues, pull requests, review and your own agent cost | |
| 14 | 14 | nothing on g1t. What costs money is g1t's own agents: each run uses a | |
| 15 | 15 | 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. | |
| 17 | 46 | ||
| 18 | 47 | ## The free allowance | |
| 19 | 48 | ||
| 42 | 71 | | Planning an [outcome](/guides/outcomes/) | Yes | | |
| 43 | 72 | | Acceptance checks | No | | |
| 44 | 73 | | The [merge queue](/guides/merge-queue/) | No | | |
| 74 | + | | [Deployments](/guides/deployments/) | The plan, and builds and usage past it. Never free. | | |
| 45 | 75 | | Repositories, git, issues, pull requests, the API and MCP | No | | |
| 46 | 76 | ||
| 47 | 77 | Each run is charged when it finishes: what the model provider charged for |
| 45 | 45 | Workflows run from `.g1t/workflows` [as GitHub runs them](/guides/actions/), | |
| 46 | 46 | and failing ones send the agent back to fix the cause. | |
| 47 | 47 | </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> | |
| 48 | 52 | <Card title="Your models, your tools" icon="puzzle"> | |
| 49 | 53 | Use g1t's models or [your own providers](/guides/models/), and pull context | |
| 50 | 54 | from [Sentry, Jira and Linear](/guides/integrations/). |
| 27 | 27 | Users, | |
| 28 | 28 | Webhook, | |
| 29 | 29 | PlayCircle, | |
| 30 | + | Rocket, | |
| 30 | 31 | X, | |
| 31 | 32 | } from "lucide-react"; | |
| 32 | 33 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 387 | 388 | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 388 | 389 | Actions | |
| 389 | 390 | </SidebarLink> | |
| 391 | + | {repo.member && ( | |
| 392 | + | <SidebarLink to={`${base}/deployments`} icon={<Rocket size={15} />}> | |
| 393 | + | Deployments | |
| 394 | + | </SidebarLink> | |
| 395 | + | )} | |
| 390 | 396 | </SidebarGroup> | |
| 391 | 397 | {repo.member && ( | |
| 392 | 398 | <SidebarGroup title="Repository"> | |
| 607 | 613 | commits: "Commits", | |
| 608 | 614 | plans: "Plan", | |
| 609 | 615 | actions: "Actions", | |
| 616 | + | deployments: "Deployments", | |
| 610 | 617 | secrets: "Secrets and variables", | |
| 611 | 618 | settings: "Settings", | |
| 612 | 619 | people: "Members", |
| 3 | 3 | import { | |
| 4 | 4 | actionsClient, | |
| 5 | 5 | billingClient, | |
| 6 | + | deploymentsClient, | |
| 6 | 7 | eventsClient, | |
| 7 | 8 | identityClient, | |
| 8 | 9 | integrationsClient, | |
| 19 | 20 | export const integrations = integrationsClient(env.INTEGRATIONS); | |
| 20 | 21 | export const webhooks = webhooksClient(env.WEBHOOKS); | |
| 21 | 22 | export const actions = actionsClient(env.ACTIONS); | |
| 23 | + | export const deployments = deploymentsClient(env.DEPLOYMENTS); |
| 46 | 46 | route("actions/runs/:id", "routes/repo/actions-run.tsx"), | |
| 47 | 47 | route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"), | |
| 48 | 48 | 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"), | |
| 49 | 51 | route("plans", "routes/repo/plans.tsx"), | |
| 50 | 52 | route("plans/:id", "routes/repo/plan.tsx"), | |
| 51 | 53 | route("settings", "routes/repo/settings.tsx"), |
| 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 | + | } |
| 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 | + | } |
| 1 | 1 | import { CreditCard, Rocket } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 11 | ||
| 6 | 12 | import type { Route } from "./+types/billing"; | |
| 7 | 13 | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 8 | − | import { billing } from "../../lib/services.server"; | |
| 14 | + | import { billing, deployments } from "../../lib/services.server"; | |
| 9 | 15 | import { | |
| 10 | 16 | assertSameOrigin, | |
| 11 | 17 | getViewer, | |
| 40 | 46 | await billing.confirm(slug, viewer, session); | |
| 41 | 47 | throw redirect(`/${slug}/-/billing?added=1`); | |
| 42 | 48 | } | |
| 43 | − | const [account, ledger, features] = await Promise.all([ | |
| 49 | + | const [account, ledger, features, deployUsage] = await Promise.all([ | |
| 44 | 50 | billing.account(slug, viewer), | |
| 45 | 51 | billing.ledger(slug, viewer), | |
| 46 | 52 | billing.features(slug, viewer), | |
| 53 | + | deployments.usage(slug, viewer), | |
| 47 | 54 | ]); | |
| 48 | 55 | return { | |
| 49 | 56 | slug, | |
| 51 | 58 | account: unwrap(account), | |
| 52 | 59 | ledger: unwrap(ledger), | |
| 53 | 60 | features: unwrap(features), | |
| 61 | + | deployUsage: deployUsage.ok ? deployUsage.value : null, | |
| 54 | 62 | added: url.searchParams.has("added"), | |
| 55 | 63 | subscribed: url.searchParams.has("subscribed"), | |
| 56 | 64 | }; | |
| 91 | 99 | } | |
| 92 | 100 | ||
| 93 | 101 | 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; | |
| 95 | 103 | const { status } = account; | |
| 96 | 104 | const paying = useNavigation().state === "submitting"; | |
| 97 | 105 | const empty = account.balanceMicros <= 0; | |
| 124 | 132 | enabled={account.status.enabled} | |
| 125 | 133 | live={account.status.live} | |
| 126 | 134 | busy={paying} | |
| 135 | + | usage={state.plan.feature === "deployments" ? deployUsage : null} | |
| 127 | 136 | /> | |
| 128 | 137 | ))} | |
| 129 | 138 | </div> | |
| 278 | 287 | enabled, | |
| 279 | 288 | live, | |
| 280 | 289 | busy, | |
| 290 | + | usage, | |
| 281 | 291 | }: { | |
| 282 | 292 | state: FeatureState; | |
| 283 | 293 | owner: boolean; | |
| 284 | 294 | enabled: boolean; | |
| 285 | 295 | live: boolean; | |
| 286 | 296 | busy: boolean; | |
| 297 | + | usage: DeployUsage | null; | |
| 287 | 298 | }) { | |
| 288 | 299 | const { plan, subscription } = state; | |
| 289 | 300 | const ending = subscription?.status === "canceling"; | |
| 336 | 347 | ))} | |
| 337 | 348 | </ul> | |
| 338 | 349 | <p className="mt-3 text-xs text-faint">{plan.overage}</p> | |
| 350 | + | {state.on && usage && <DeployMeter usage={usage} />} | |
| 339 | 351 | {!enabled ? ( | |
| 340 | 352 | <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p> | |
| 341 | 353 | ) : !owner ? ( | |
| 365 | 377 | </section> | |
| 366 | 378 | ); | |
| 367 | 379 | } | |
| 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 | + | } |
| 243 | 243 | holds the merge, failure refuses it and sends a g1t agent back to fix it. | |
| 244 | 244 | Secrets and variables: `{repo}/actions/secrets`, `{repo}/actions/variables`. | |
| 245 | 245 | ||
| 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 | + | ||
| 246 | 260 | ## Facts | |
| 247 | 261 | ||
| 248 | 262 | - API base: `https://api.g1t.sh`. `GET /` lists every URL as a template. |
| 13 | 13 | INTEGRATIONS: ServiceBinding; | |
| 14 | 14 | WEBHOOKS: ServiceBinding; | |
| 15 | 15 | ACTIONS: ServiceBinding; | |
| 16 | + | DEPLOYMENTS: ServiceBinding; | |
| 16 | 17 | BLOBS: KVNamespace; | |
| 17 | 18 | } | |
| 18 | 19 | } |
| 20 | 20 | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 21 | 21 | { "binding": "INTEGRATIONS", "service": "g1t-integrations" }, | |
| 22 | 22 | { "binding": "WEBHOOKS", "service": "g1t-webhooks" }, | |
| 23 | − | { "binding": "ACTIONS", "service": "g1t-actions" } | |
| 23 | + | { "binding": "ACTIONS", "service": "g1t-actions" }, | |
| 24 | + | { "binding": "DEPLOYMENTS", "service": "g1t-deployments" } | |
| 24 | 25 | ], | |
| 25 | 26 | "observability": { "enabled": true }, | |
| 26 | 27 | "upload_source_maps": true |
| 300 | 300 | pub const MICROS_PER_APP_MONTH: i64 = 20_000; | |
| 301 | 301 | pub const MICROS_PER_MILLION_REQUESTS: i64 = 300_000; | |
| 302 | 302 | 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; | |
| 303 | 308 | } | |
| 304 | 309 | ||
| 305 | 310 | /// What a feature's plan costs and includes. |
| 9 | 9 | g1t-actions = { path = "../actions" } | |
| 10 | 10 | sha2 = "0.10" | |
| 11 | 11 | serde_yaml = "0.9" | |
| 12 | + | toml = "0.8" | |
| 12 | 13 | hex = "0.4" | |
| 13 | 14 | anyhow = "1" | |
| 14 | 15 | base64 = "0.22" |
| 39 | 39 | duration_ms: u64, | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | impl CheckResult { | |
| 43 | + | pub(crate) fn output_text(&self) -> &str { | |
| 44 | + | &self.output | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 42 | 48 | /// The last `limit` characters of `text`, saying so if any were dropped. | |
| 43 | 49 | fn tail(text: &str, limit: usize) -> String { | |
| 44 | 50 | let length = text.chars().count(); | |
| 70 | 76 | .current_dir(workdir) | |
| 71 | 77 | .env_remove("G1T_TOKEN") | |
| 72 | 78 | .env_remove("CHECK_TOKEN") | |
| 79 | + | .env_remove("DEPLOY_TOKEN") | |
| 73 | 80 | .stdin(Stdio::null()) | |
| 74 | 81 | .output(); | |
| 75 | 82 | let duration_ms = started.elapsed().as_millis() as u64; |
| 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 | + | } |
| 24 | 24 | ||
| 25 | 25 | mod actions; | |
| 26 | 26 | mod checks; | |
| 27 | + | mod deploy; | |
| 27 | 28 | mod harness; | |
| 28 | 29 | mod plan; | |
| 29 | 30 | mod queue; | |
| 182 | 183 | match std::env::var("MODE").as_deref() { | |
| 183 | 184 | Ok("actions") => std::process::exit(actions::main()), | |
| 184 | 185 | Ok("checks") => std::process::exit(checks::main()), | |
| 186 | + | Ok("deploy") => std::process::exit(deploy::main()), | |
| 185 | 187 | Ok("update") => std::process::exit(update::main()), | |
| 186 | 188 | Ok("review") => std::process::exit(review::main()), | |
| 187 | 189 | Ok("revise") => std::process::exit(revise::main()), |
| 1580 | 1580 | "resolved": "packages/contracts", | |
| 1581 | 1581 | "link": true | |
| 1582 | 1582 | }, | |
| 1583 | + | "node_modules/@g1t/deployments": { | |
| 1584 | + | "resolved": "services/deployments", | |
| 1585 | + | "link": true | |
| 1586 | + | }, | |
| 1583 | 1587 | "node_modules/@g1t/docs": { | |
| 1584 | 1588 | "resolved": "apps/docs", | |
| 1585 | 1589 | "link": true | |
| 1588 | 1592 | "resolved": "services/models", | |
| 1589 | 1593 | "link": true | |
| 1590 | 1594 | }, | |
| 1595 | + | "node_modules/@g1t/pages": { | |
| 1596 | + | "resolved": "services/pages", | |
| 1597 | + | "link": true | |
| 1598 | + | }, | |
| 1591 | 1599 | "node_modules/@g1t/runner": { | |
| 1592 | 1600 | "resolved": "services/runner", | |
| 1593 | 1601 | "link": true | |
| 9943 | 9951 | "version": "0.1.0", | |
| 9944 | 9952 | "license": "MIT" | |
| 9945 | 9953 | }, | |
| 9954 | + | "services/deployments": { | |
| 9955 | + | "name": "@g1t/deployments", | |
| 9956 | + | "version": "0.1.0", | |
| 9957 | + | "license": "MIT", | |
| 9958 | + | "dependencies": { | |
| 9959 | + | "@g1t/contracts": "*" | |
| 9960 | + | } | |
| 9961 | + | }, | |
| 9946 | 9962 | "services/models": { | |
| 9947 | 9963 | "name": "@g1t/models", | |
| 9948 | 9964 | "version": "0.1.0", | |
| 9951 | 9967 | "@g1t/contracts": "*" | |
| 9952 | 9968 | } | |
| 9953 | 9969 | }, | |
| 9970 | + | "services/pages": { | |
| 9971 | + | "name": "@g1t/pages", | |
| 9972 | + | "version": "0.1.0", | |
| 9973 | + | "license": "MIT" | |
| 9974 | + | }, | |
| 9954 | 9975 | "services/runner": { | |
| 9955 | 9976 | "name": "@g1t/runner", | |
| 9956 | 9977 | "version": "0.1.0", |
| 98 | 98 | microsPerAppMonth: 20_000, | |
| 99 | 99 | microsPerMillionRequests: 300_000, | |
| 100 | 100 | microsPerMillionCpuMs: 20_000, | |
| 101 | + | /** One second of a build's sandbox; builds are charged, not included. */ | |
| 102 | + | microsPerBuildSecond: 21, | |
| 101 | 103 | } as const; | |
| 102 | 104 | ||
| 103 | 105 | export type FeaturePlan = { |
| 1 | 1 | import type { ActionsApi } from "./actions"; | |
| 2 | 2 | import type { BillingApi } from "./billing"; | |
| 3 | + | import type { DeploymentsApi } from "./deployments"; | |
| 3 | 4 | import type { EventsApi } from "./events"; | |
| 4 | 5 | import type { IdentityApi } from "./identity"; | |
| 5 | 6 | import type { IntegrationsApi } from "./integrations"; | |
| 260 | 261 | }; | |
| 261 | 262 | } | |
| 262 | 263 | ||
| 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 | + | } |
| 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 | const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; | |
| 2 | 2 | ||
| 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"; | |
| 4 | 4 | ||
| 5 | 5 | let lastMs = 0; | |
| 6 | 6 | let lastCounter = 0; |
| 1 | 1 | export * from "./actions"; | |
| 2 | 2 | export * from "./billing"; | |
| 3 | 3 | export * from "./clients"; | |
| 4 | + | export * from "./deployments"; | |
| 4 | 5 | export * from "./events"; | |
| 5 | 6 | export * from "./identity"; | |
| 6 | 7 | export * from "./ids"; |
| 59 | 59 | } | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 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. | |
| 62 | 64 | 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}") | |
| 64 | 69 | } | |
| 65 | 70 | ||
| 66 | 71 | impl SubscriptionRow { | |
| 92 | 97 | "Previews that cost nothing while no one visits them".to_owned(), | |
| 93 | 98 | ], | |
| 94 | 99 | 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.", | |
| 96 | 101 | dollars(crate::charge_micros( | |
| 97 | 102 | allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64, | |
| 98 | 103 | self.margin_percent | |
| 105 | 110 | allowance::MICROS_PER_MILLION_CPU_MS as f64 / MICROS_PER_DOLLAR as f64, | |
| 106 | 111 | self.margin_percent | |
| 107 | 112 | )), | |
| 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 | + | )), | |
| 109 | 118 | ), | |
| 110 | 119 | }, | |
| 111 | 120 | } | |
| 400 | 409 | Ok(Outcome::Ok(true)) | |
| 401 | 410 | } | |
| 402 | 411 | } | |
| 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 | + | } |
| 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 | + | ); |
| 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 | + | } |
| 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 | + | } |
| 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>; |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.