From 503652f349d44a81bf4bb14bc38278e664a44062 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sat, 3 Oct 2026 19:28:42 -0700 Subject: [PATCH 1/6] Declare the CI deploy token in infra/stacks/github.ts An account-owned Cloudflare token for GitHub Actions, written straight into the repository's CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID secrets so its value is never shown or copied. Its policies grant only what alchemy.run.ts declares: Workers scripts, R2, the Secrets Store Alchemy's state needs, and DNS and routes on the allthings.dev zone. Minting needs the Global API Key, so a maintainer deploys this stack by hand with a temporary admin profile fed from 1Password (README). --- infra/README.md | 18 ++++++++++ infra/stacks/github.ts | 79 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 infra/stacks/github.ts diff --git a/infra/README.md b/infra/README.md index 7f4c8288..e699109d 100644 --- a/infra/README.md +++ b/infra/README.md @@ -14,3 +14,21 @@ bun run deploy # your own stage (live_$USER) with its own resourc - **Media:** an R2 bucket. Only `prod` gets the `media.allthings.dev` domain, and its bucket is kept even if removed from the stack. - **Upload Worker:** stores and deletes media for the app while it runs on Vercel. The bucket is a binding and callers present a token Alchemy generates. Every deploy proves uploads work by storing and deleting one object. - **Vercel env:** `prod` deploys write `MEDIA_UPLOAD_URL`, `MEDIA_UPLOAD_TOKEN` and `MEDIA_PUBLIC_URL` to every Vercel environment (the token is sensitive except in development, where Vercel doesn't allow it) with the Vercel CLI, so it needs to be signed in (`bunx vercel login`). + +## CI credentials + +[`stacks/github.ts`](stacks/github.ts) mints the account-owned Cloudflare token GitHub Actions deploys with, and writes it and the account ID to the repository's `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` secrets. Its policies grant only what `alchemy.run.ts` declares. Widen them there, in the same PR that declares more. + +Minting tokens takes the account's Global API Key, which OAuth can't provide. A maintainer deploys this stack by hand with a separate `admin` profile whose key comes from 1Password. Remove the key from the profile right after: + +```sh +op read "op://Private/Cloudflare Global API Key/credential" | bun alchemy profile edit --profile admin \ + --add cloudflare --method stored --set apiKey=- \ + --set email="$(op read 'op://Private/Cloudflare Global API Key/username')" \ + --set accountId=1b90995af2e8ed1710a8058226838681 +gh auth token | bun alchemy profile edit --profile admin --add github --method stored --set token=- +bun alchemy deploy --config stacks/github.ts --stage prod --profile admin +bun alchemy profile edit --profile admin --remove cloudflare --remove github +``` + +Rerun it to rotate the token or change its policies. diff --git a/infra/stacks/github.ts b/infra/stacks/github.ts new file mode 100644 index 00000000..8e977a54 --- /dev/null +++ b/infra/stacks/github.ts @@ -0,0 +1,79 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as GitHub from "alchemy/GitHub"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +const owner = "allthingsweb-dev"; +const repository = "allthingsweb"; + +/** + * The credentials GitHub Actions deploys the allthings stack with: an + * account-owned Cloudflare token, written straight into the repository's + * Actions secrets so its value is never shown or copied. + * + * Minting a token takes the account's Global API Key, so this stack is + * deployed by hand with the `admin` profile (see README) and CI never holds + * that key. Grant only what the allthings stack declares; widen the policies + * here, in review, when it declares more. + */ +export default Alchemy.Stack( + "allthings-github", + { + providers: Layer.mergeAll(Cloudflare.providers(), GitHub.providers()), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; + const zoneId = yield* Cloudflare.Zone.resolveZoneId({ + accountId, + zone: "allthings.dev", + hostname: "allthings.dev", + }).pipe(Effect.orDie); + const account = `com.cloudflare.api.account.${accountId}` as const; + + const token = yield* Cloudflare.ApiToken.AccountApiToken("DeployToken", { + name: "allthings deploy (GitHub Actions)", + accountId, + policies: [ + { + effect: "allow", + permissionGroups: [ + // Workers, their workers.dev routes and the media bucket. + "Workers Scripts Write", + "Workers R2 Storage Write", + "Account Settings Read", + // Alchemy's state store keeps its bearer token in the Secrets + // Store and binds it to read it back, which needs Write. + "Secrets Store Write", + ], + resources: { [account]: "*" }, + }, + { + effect: "allow", + // Custom domains on allthings.dev, such as media.allthings.dev. + permissionGroups: ["Zone Read", "DNS Write", "Workers Routes Write"], + resources: { + [account]: { [`com.cloudflare.api.account.zone.${zoneId}`]: "*" }, + }, + }, + ], + }); + + yield* GitHub.Secret("CloudflareApiToken", { + owner, + repository, + name: "CLOUDFLARE_API_TOKEN", + value: token.value, + }); + yield* GitHub.Secret("CloudflareAccountId", { + owner, + repository, + name: "CLOUDFLARE_ACCOUNT_ID", + value: Redacted.make(accountId), + }); + + return { tokenId: token.tokenId }; + }), +); From 5cc973fd8ece62937f3ae0706cb4373e5d0fc4c2 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sat, 3 Oct 2026 19:37:48 -0700 Subject: [PATCH 2/6] Explain that rerunning updates the token and destroying rotates it --- infra/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/README.md b/infra/README.md index e699109d..8e767e9a 100644 --- a/infra/README.md +++ b/infra/README.md @@ -31,4 +31,4 @@ bun alchemy deploy --config stacks/github.ts --stage prod --profile admin bun alchemy profile edit --profile admin --remove cloudflare --remove github ``` -Rerun it to rotate the token or change its policies. +Rerunning the deploy updates the token's policies in place; the token value stays the same. To rotate it, run `bun alchemy destroy --config stacks/github.ts --stage prod --profile admin` first: that deletes the token and its secrets, and the deploy then mints a new token and writes it to `CLOUDFLARE_API_TOKEN`. From b6804efec02fb1b796b50614ce260d7845cb5d43 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sat, 3 Oct 2026 20:03:21 -0700 Subject: [PATCH 3/6] Use Alchemy's exact provider names in the admin profile commands --- infra/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/README.md b/infra/README.md index 8e767e9a..829ff8de 100644 --- a/infra/README.md +++ b/infra/README.md @@ -23,12 +23,12 @@ Minting tokens takes the account's Global API Key, which OAuth can't provide. A ```sh op read "op://Private/Cloudflare Global API Key/credential" | bun alchemy profile edit --profile admin \ - --add cloudflare --method stored --set apiKey=- \ + --add Cloudflare --method stored --set apiKey=- \ --set email="$(op read 'op://Private/Cloudflare Global API Key/username')" \ --set accountId=1b90995af2e8ed1710a8058226838681 -gh auth token | bun alchemy profile edit --profile admin --add github --method stored --set token=- +gh auth token | bun alchemy profile edit --profile admin --add GitHub --method stored --set token=- bun alchemy deploy --config stacks/github.ts --stage prod --profile admin -bun alchemy profile edit --profile admin --remove cloudflare --remove github +bun alchemy profile edit --profile admin --remove Cloudflare --remove GitHub ``` Rerunning the deploy updates the token's policies in place; the token value stays the same. To rotate it, run `bun alchemy destroy --config stacks/github.ts --stage prod --profile admin` first: that deletes the token and its secrets, and the deploy then mints a new token and writes it to `CLOUDFLARE_API_TOKEN`. From dfc49ff4f1ac62fdc01f9e0d78d80016878cc0c6 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sat, 3 Oct 2026 20:15:47 -0700 Subject: [PATCH 4/6] Keep the admin profile connected through destroy when rotating --- infra/README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/infra/README.md b/infra/README.md index 829ff8de..835c0420 100644 --- a/infra/README.md +++ b/infra/README.md @@ -19,16 +19,22 @@ bun run deploy # your own stage (live_$USER) with its own resourc [`stacks/github.ts`](stacks/github.ts) mints the account-owned Cloudflare token GitHub Actions deploys with, and writes it and the account ID to the repository's `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` secrets. Its policies grant only what `alchemy.run.ts` declares. Widen them there, in the same PR that declares more. -Minting tokens takes the account's Global API Key, which OAuth can't provide. A maintainer deploys this stack by hand with a separate `admin` profile whose key comes from 1Password. Remove the key from the profile right after: +Minting tokens takes the account's Global API Key, which OAuth can't provide. A maintainer applies this stack by hand with a separate `admin` profile, connected only for the run and fed from 1Password: ```sh +# 1. Connect the admin profile. op read "op://Private/Cloudflare Global API Key/credential" | bun alchemy profile edit --profile admin \ --add Cloudflare --method stored --set apiKey=- \ --set email="$(op read 'op://Private/Cloudflare Global API Key/username')" \ --set accountId=1b90995af2e8ed1710a8058226838681 gh auth token | bun alchemy profile edit --profile admin --add GitHub --method stored --set token=- + +# 2. Apply. To rotate the token, destroy first: that deletes the token and its +# secrets, and the deploy mints a new one. A plain deploy updates the +# token's policies in place and keeps its value. +bun alchemy destroy --config stacks/github.ts --stage prod --profile admin # rotation only bun alchemy deploy --config stacks/github.ts --stage prod --profile admin + +# 3. Disconnect the admin profile. bun alchemy profile edit --profile admin --remove Cloudflare --remove GitHub ``` - -Rerunning the deploy updates the token's policies in place; the token value stays the same. To rotate it, run `bun alchemy destroy --config stacks/github.ts --stage prod --profile admin` first: that deletes the token and its secrets, and the deploy then mints a new token and writes it to `CLOUDFLARE_API_TOKEN`. From ceb5d2a246d88b6f86a91895ec51d17321ec3c18 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sun, 4 Oct 2026 15:59:35 -0700 Subject: [PATCH 5/6] Deploy PR previews and staging to the allthings Cloudflare account allthings now has its own Cloudflare account, so a deploy credential can only touch allthings resources. Every stage but prod deploys there and runs only the Web Worker; prod keeps media, the upload Worker and the Vercel env in the account that still holds the domain. - deploy.yaml deploys pr- per pull request (destroyed on close) and staging for main. - The CI token's permissions live in infra/ci-token.json, applied by scripts/ci-token.sh with Cloudflare's CLI, whose OAuth sign-in may create account tokens: no Global API Key or token-minting token exists. This replaces stacks/github.ts. --- .github/workflows/deploy.yaml | 95 +++++++++++++++++++++++++++++++++++ infra/README.md | 40 +++++++-------- infra/alchemy.run.ts | 55 ++++++++++---------- infra/ci-token.json | 10 ++++ infra/scripts/ci-token.sh | 74 +++++++++++++++++++++++++++ infra/src/media.ts | 48 +++++++----------- infra/stacks/github.ts | 79 ----------------------------- 7 files changed, 244 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/deploy.yaml create mode 100644 infra/ci-token.json create mode 100755 infra/scripts/ci-token.sh delete mode 100644 infra/stacks/github.ts diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 00000000..30d193b5 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,95 @@ +name: Deploy + +# Deploys the Worker replacing the app to the allthings Cloudflare account: +# a pr- stage per pull request (removed when it closes) and a staging +# stage for main. Prod stays a maintainer deploy until it moves accounts. +on: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, reopened, closed] + +permissions: + contents: read + +concurrency: + group: deploy-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +env: + STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'staging' }} + +jobs: + deploy: + name: ๐Ÿš€ Deploy + # Pull requests from forks don't get the deploy secrets. + if: >- + github.event.action != 'closed' && + (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + environment: + name: ${{ github.event_name == 'pull_request' && 'preview' || 'staging' }} + url: ${{ steps.deploy.outputs.url }} + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: ๐Ÿ“ฅ Download deps + run: | + bun install --frozen-lockfile + (cd infra && bun install --frozen-lockfile) + + - name: ๐Ÿงฑ Build web + working-directory: web + run: bun run build + + - name: ๐Ÿš€ Deploy + id: deploy + working-directory: infra + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + bun alchemy deploy --stage "$STAGE" --yes --no-input | tee deploy.log + url=$(grep -oE "https://[a-z0-9-]+\.allthings\.workers\.dev" deploy.log | tail -1) + test -n "$url" + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "Deployed \`$STAGE\`: $url" >> "$GITHUB_STEP_SUMMARY" + + destroy: + name: ๐Ÿงน Destroy preview + if: >- + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: ๐Ÿ“ฅ Download deps + run: | + bun install --frozen-lockfile + (cd infra && bun install --frozen-lockfile) + + - name: ๐Ÿงน Destroy + working-directory: infra + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + case "$STAGE" in pr-[0-9]*) ;; *) echo "refusing to destroy $STAGE" >&2; exit 1 ;; esac + bun alchemy destroy --stage "$STAGE" --yes --no-input diff --git a/infra/README.md b/infra/README.md index fb26d606..d2483264 100644 --- a/infra/README.md +++ b/infra/README.md @@ -8,34 +8,32 @@ From `infra/`, sign in to Cloudflare once per machine (browser OAuth), then plan bun alchemy profile edit --add Cloudflare bun run plan --stage prod # preview changes to production bun run deploy --stage prod # apply them -bun run deploy # your own stage (live_$USER) with its own resources +bun run deploy --profile allthings # your own stage (live_$USER), Web Worker only ``` -- **Media:** an R2 bucket. Only `prod` gets the `media.allthings.dev` domain, and its bucket is kept even if removed from the stack. -- **Upload Worker:** stores and deletes media for the app while it runs on Vercel. The bucket is a binding and callers present a token Alchemy generates. Every deploy proves uploads work by storing and deleting one object. -- **Web Worker:** `web/` (the public API, the MCP server, the home page and `/brand`), on every stage but `prod` until its Hyperdrive binding lands. Alchemy bundles it with web's dependencies and uploads `web/dist/public` as its static assets, so run `bun install` at the repository root and `bun run build` in `web/` before planning or deploying. +- **Media:** `prod` only. An R2 bucket served on `media.allthings.dev`, kept even if removed from the stack. +- **Upload Worker:** `prod` only. Stores and deletes media for the app while it runs on Vercel. The bucket is a binding and callers present a token Alchemy generates. Every deploy proves uploads work by storing and deleting one object. +- **Web Worker:** `web/` (the public API, the MCP server, the home page and `/brand`), on every stage but `prod` until its Hyperdrive binding lands; previews run nothing else. Alchemy bundles it with web's dependencies and uploads `web/dist/public` as its static assets, so run `bun install` at the repository root and `bun run build` in `web/` before planning or deploying. - **Vercel env:** `prod` deploys write `MEDIA_UPLOAD_URL`, `MEDIA_UPLOAD_TOKEN` and `MEDIA_PUBLIC_URL` to every Vercel environment (the token is sensitive except in development, where Vercel doesn't allow it) with the Vercel CLI, so it needs to be signed in (`bunx vercel login`). +## Accounts and stages + +allthings has its own Cloudflare account (`af627f300cd00c4dca56aacf05bea050`), so a deploy credential can only touch allthings resources. Every stage but `prod` deploys there and runs only the Web Worker: + +- **`pr-`:** one per pull request, deployed by [`deploy.yaml`](../.github/workflows/deploy.yaml) on every push and destroyed when the PR closes. Its URL is in the PR's deployment and the run summary. +- **`staging`:** main, deployed on every merge. +- **Your own stage:** `bun run deploy --profile allthings` from a machine signed in with `bun alchemy profile edit --profile allthings --add Cloudflare`. + +`prod` (media, the upload Worker and the Vercel env) still lives in the account that holds the allthings.dev domain, and a maintainer deploys it with the `default` profile until the domain moves to the allthings account. + ## CI credentials -[`stacks/github.ts`](stacks/github.ts) mints the account-owned Cloudflare token GitHub Actions deploys with, and writes it and the account ID to the repository's `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` secrets. Its policies grant only what `alchemy.run.ts` declares. Widen them there, in the same PR that declares more. +GitHub Actions deploys with an account-owned token that has only the permissions listed in [`ci-token.json`](ci-token.json). [`scripts/ci-token.sh`](scripts/ci-token.sh) applies that file. It creates the token, or updates its policies in place, and writes the value straight into the repository's `CLOUDFLARE_API_TOKEN` secret, so nobody ever sees or copies it. When `alchemy.run.ts` declares more, widen `ci-token.json` in the same PR, then rerun the script after it merges. -Minting tokens takes the account's Global API Key, which OAuth can't provide. A maintainer applies this stack by hand with a separate `admin` profile, connected only for the run and fed from 1Password: +The script runs on a maintainer's machine signed in to the allthings account with Cloudflare's CLI, whose sign-in may create account tokens, so no Global API Key or token-minting token exists anywhere: ```sh -# 1. Connect the admin profile. -op read "op://Private/Cloudflare Global API Key/credential" | bun alchemy profile edit --profile admin \ - --add Cloudflare --method stored --set apiKey=- \ - --set email="$(op read 'op://Private/Cloudflare Global API Key/username')" \ - --set accountId=1b90995af2e8ed1710a8058226838681 -gh auth token | bun alchemy profile edit --profile admin --add GitHub --method stored --set token=- - -# 2. Apply. To rotate the token, destroy first: that deletes the token and its -# secrets, and the deploy mints a new one. A plain deploy updates the -# token's policies in place and keeps its value. -bun alchemy destroy --config stacks/github.ts --stage prod --profile admin # rotation only -bun alchemy deploy --config stacks/github.ts --stage prod --profile admin - -# 3. Disconnect the admin profile. -bun alchemy profile edit --profile admin --remove Cloudflare --remove GitHub +NODE_OPTIONS=--dns-result-order=ipv4first bunx cf@1.0.0-beta.12 auth create allthings --no-device # once per machine +bash infra/scripts/ci-token.sh # create, or update the policies +bash infra/scripts/ci-token.sh --rotate # also roll the value and update the secret ``` diff --git a/infra/alchemy.run.ts b/infra/alchemy.run.ts index 8bcfa14b..292ed436 100644 --- a/infra/alchemy.run.ts +++ b/infra/alchemy.run.ts @@ -2,16 +2,24 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Output from "alchemy/Output"; import * as Effect from "effect/Effect"; -import { Media, PRODUCTION, MEDIA_DOMAIN } from "./src/media.ts"; +import { MEDIA_DOMAIN, Media, isProduction } from "./src/media.ts"; import { MediaUpload, MediaUploadCheck } from "./src/upload-worker.ts"; -import { Web } from "./src/web.ts"; import { VercelEnv } from "./src/vercel-env.ts"; +import { Web } from "./src/web.ts"; export default Alchemy.Stack( "allthings", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { - const media = yield* Media; + // Every stage but prod (PR previews, staging, personal stages) runs only + // the Worker replacing the app, in the allthings account. Prod keeps the + // media the app on Vercel uses until the Worker serves the site. + if (!(yield* isProduction)) { + const web = yield* Web; + return { webUrl: web.url.as() }; + } + + yield* Media; const upload = yield* MediaUpload; const token = yield* Alchemy.makeRandom("MediaUploadToken"); yield* MediaUploadCheck({ @@ -21,34 +29,27 @@ export default Alchemy.Stack( Output.map((hash) => hash?.bundle ?? "unbuilt"), ), }); - // The app on Vercel uploads through the Worker and links to the domain. - const { stage } = yield* Alchemy.Stack; - if (stage === PRODUCTION) { - // Development gets the token too, so admin scripts can upload from a - // maintainer's machine after `vercel env pull`. - const everywhere = ["production", "preview", "development"] as const; - yield* VercelEnv("MEDIA_UPLOAD_URL", upload.url.as(), { - sensitive: false, - targets: everywhere, - }); - yield* VercelEnv("MEDIA_UPLOAD_TOKEN", token, { - sensitive: true, - targets: everywhere, - }); - yield* VercelEnv("MEDIA_PUBLIC_URL", `https://${MEDIA_DOMAIN}`, { - sensitive: false, - targets: everywhere, - }); - } - // The Worker replacing the app runs on every stage but prod until its - // data bindings land, so a prod deploy changes nothing yet. - const web = stage === PRODUCTION ? undefined : yield* Web; + // The app on Vercel uploads through the Worker and links to the domain. + // Development gets the token too, so admin scripts can upload from a + // maintainer's machine after `vercel env pull`. + const everywhere = ["production", "preview", "development"] as const; + yield* VercelEnv("MEDIA_UPLOAD_URL", upload.url.as(), { + sensitive: false, + targets: everywhere, + }); + yield* VercelEnv("MEDIA_UPLOAD_TOKEN", token, { + sensitive: true, + targets: everywhere, + }); + yield* VercelEnv("MEDIA_PUBLIC_URL", `https://${MEDIA_DOMAIN}`, { + sensitive: false, + targets: everywhere, + }); return { - mediaBucket: media.bucketName, + mediaBucket: "allthings-media", mediaUploadUrl: upload.url.as(), - ...(web === undefined ? {} : { webUrl: web.url.as() }), }; }), ); diff --git a/infra/ci-token.json b/infra/ci-token.json new file mode 100644 index 00000000..f020459e --- /dev/null +++ b/infra/ci-token.json @@ -0,0 +1,10 @@ +{ + "$comment": "The Cloudflare token GitHub Actions deploys with. scripts/ci-token.sh applies this file to the allthings account. Grant only what alchemy.run.ts declares, and widen it in the PR that declares more.", + "name": "allthings deploy (GitHub Actions)", + "account": "af627f300cd00c4dca56aacf05bea050", + "permissionGroups": [ + "Workers Scripts Write", + "Account Settings Read", + "Secrets Store Write" + ] +} diff --git a/infra/scripts/ci-token.sh b/infra/scripts/ci-token.sh new file mode 100755 index 00000000..89b72a30 --- /dev/null +++ b/infra/scripts/ci-token.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Applies infra/ci-token.json: creates or updates the account-owned Cloudflare +# token GitHub Actions deploys with, and keeps the repository's +# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID secrets in step. The token's +# value goes straight from Cloudflare into the secret and is never printed. +# +# It runs on a maintainer's machine with the Cloudflare CLI (cf) signed in to +# the allthings account; that sign-in may create account tokens, so no Global +# API Key or token-minting token exists anywhere. Requires gh, jq and bun. +# +# bash infra/scripts/ci-token.sh # create, or update the policies in place +# bash infra/scripts/ci-token.sh --rotate # also roll the value and update the secret +# +# CF_PROFILE names the cf profile to use (default: allthings). +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +spec="$here/../ci-token.json" +repo=allthingsweb-dev/allthingsweb +profile=${CF_PROFILE:-allthings} +rotate=false +[[ ${1:-} == --rotate ]] && rotate=true + +name=$(jq -er .name "$spec") +account=$(jq -er .account "$spec") +export CLOUDFLARE_ACCOUNT_ID=$account +# cf 1.0.0-beta.12 fails to reach Cloudflare over IPv6 on some networks. +export NODE_OPTIONS=--dns-result-order=ipv4first + +# cf prints guidance for agents on stderr; keep stdout as the JSON result and +# show stderr only when a call fails. +cf() { + local err status=0 + err=$(mktemp) + bunx cf@1.0.0-beta.12 --profile "$profile" "$@" 2>"$err" || status=$? + if ((status != 0)); then + grep -vE "AGENT|=== |cli search|Keep cf|Never include|It returns|Run \`|For API|first port" "$err" >&2 + fi + rm -f "$err" + return "$status" +} +result() { jq 'if type == "object" and has("result") then .result else . end'; } + +echo "โ†’ resolving permission groups for $name" +groups=$(cf accounts tokens permission-groups list | result) +policies=$(jq -c --argjson groups "$groups" --arg resource "com.cloudflare.api.account.$account" ' + .permissionGroups as $wanted + | [$wanted[] as $n | ($groups | map(select(.name == $n)) | first) // error("unknown permission group: \($n)")] + | [{effect: "allow", permission_groups: map({id}), resources: {($resource): "*"}}] +' "$spec") + +id=$(cf accounts tokens list | result | jq -r --arg name "$name" 'map(select(.name == $name)) | first | .id // empty') + +set_secret() { + jq -er '.value // empty' | gh secret set CLOUDFLARE_API_TOKEN --repo "$repo" + gh secret set CLOUDFLARE_ACCOUNT_ID --repo "$repo" --body "$account" +} + +if [[ -z $id ]]; then + echo "โ†’ creating the token" + cf accounts tokens create --name "$name" --policies "$policies" | result | set_secret + echo "โœ“ created \"$name\" and set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID on $repo" + exit 0 +fi + +echo "โ†’ updating the token's policies (its value stays the same)" +cf accounts tokens update "$id" --name "$name" --policies "$policies" | result | jq -e '.id' >/dev/null +if $rotate; then + echo "โ†’ rolling the token's value" + cf accounts tokens roll "$id" | result | jq 'if type == "string" then {value: .} else . end' | set_secret + echo "โœ“ rotated \"$name\" and updated CLOUDFLARE_API_TOKEN on $repo" +else + echo "โœ“ \"$name\" now has the policies in ci-token.json" +fi diff --git a/infra/src/media.ts b/infra/src/media.ts index bfdd331f..0620ad8d 100644 --- a/infra/src/media.ts +++ b/infra/src/media.ts @@ -7,39 +7,27 @@ export const PRODUCTION = "prod"; export const MEDIA_DOMAIN = "media.allthings.dev"; /** - * Event covers, speaker photos and host logos. Production serves them on - * media.allthings.dev and keeps the bucket even if this declaration goes away; - * other stages get their own disposable bucket. + * Event covers, speaker photos and host logos, served on media.allthings.dev. + * Production only: the bucket is kept even if this declaration goes away. */ export const Media = Effect.gen(function* () { - const { stage } = yield* Alchemy.Stack; - const production = stage === PRODUCTION; - // The domain's zone must live in the deploying account; resolving it here // fails the plan instead of a half-applied deploy. - const domains = production - ? [ - { - name: MEDIA_DOMAIN, - zone: yield* Cloudflare.Zone.resolveZoneId({ - accountId: (yield* yield* Cloudflare.CloudflareEnvironment) - .accountId, - zone: "allthings.dev", - hostname: MEDIA_DOMAIN, - }).pipe(Effect.orDie), - minTLS: "1.2" as const, - }, - ] - : []; + const zone = yield* Cloudflare.Zone.resolveZoneId({ + accountId: (yield* yield* Cloudflare.CloudflareEnvironment).accountId, + zone: "allthings.dev", + hostname: MEDIA_DOMAIN, + }).pipe(Effect.orDie); - const bucket = Cloudflare.R2.Bucket("Media", { - // R2 bucket names allow only lowercase letters, digits and hyphens; the - // default personal stage is live_$USER. - name: production - ? "allthings-media" - : `allthings-media-${stage.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`, - domains, - forceDestroy: !production, - }); - return yield* production ? bucket.pipe(RemovalPolicy.retain()) : bucket; + return yield* Cloudflare.R2.Bucket("Media", { + name: "allthings-media", + domains: [{ name: MEDIA_DOMAIN, zone, minTLS: "1.2" as const }], + forceDestroy: false, + }).pipe(RemovalPolicy.retain()); }); + +/** Whether the stack is deploying production. */ +export const isProduction = Effect.map( + Alchemy.Stack, + ({ stage }) => stage === PRODUCTION, +); diff --git a/infra/stacks/github.ts b/infra/stacks/github.ts deleted file mode 100644 index 8e977a54..00000000 --- a/infra/stacks/github.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as Alchemy from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import * as GitHub from "alchemy/GitHub"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; - -const owner = "allthingsweb-dev"; -const repository = "allthingsweb"; - -/** - * The credentials GitHub Actions deploys the allthings stack with: an - * account-owned Cloudflare token, written straight into the repository's - * Actions secrets so its value is never shown or copied. - * - * Minting a token takes the account's Global API Key, so this stack is - * deployed by hand with the `admin` profile (see README) and CI never holds - * that key. Grant only what the allthings stack declares; widen the policies - * here, in review, when it declares more. - */ -export default Alchemy.Stack( - "allthings-github", - { - providers: Layer.mergeAll(Cloudflare.providers(), GitHub.providers()), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; - const zoneId = yield* Cloudflare.Zone.resolveZoneId({ - accountId, - zone: "allthings.dev", - hostname: "allthings.dev", - }).pipe(Effect.orDie); - const account = `com.cloudflare.api.account.${accountId}` as const; - - const token = yield* Cloudflare.ApiToken.AccountApiToken("DeployToken", { - name: "allthings deploy (GitHub Actions)", - accountId, - policies: [ - { - effect: "allow", - permissionGroups: [ - // Workers, their workers.dev routes and the media bucket. - "Workers Scripts Write", - "Workers R2 Storage Write", - "Account Settings Read", - // Alchemy's state store keeps its bearer token in the Secrets - // Store and binds it to read it back, which needs Write. - "Secrets Store Write", - ], - resources: { [account]: "*" }, - }, - { - effect: "allow", - // Custom domains on allthings.dev, such as media.allthings.dev. - permissionGroups: ["Zone Read", "DNS Write", "Workers Routes Write"], - resources: { - [account]: { [`com.cloudflare.api.account.zone.${zoneId}`]: "*" }, - }, - }, - ], - }); - - yield* GitHub.Secret("CloudflareApiToken", { - owner, - repository, - name: "CLOUDFLARE_API_TOKEN", - value: token.value, - }); - yield* GitHub.Secret("CloudflareAccountId", { - owner, - repository, - name: "CLOUDFLARE_ACCOUNT_ID", - value: Redacted.make(accountId), - }); - - return { tokenId: token.tokenId }; - }), -); From ca3947c3cc041b7947c8739ba9109bc52f0ebebf Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sun, 4 Oct 2026 16:15:09 -0700 Subject: [PATCH 6/6] Read the preview URL from the stack's webUrl output; say where to run ci-token.sh --- .github/workflows/deploy.yaml | 3 ++- infra/README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 30d193b5..049acf93 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -58,7 +58,8 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | bun alchemy deploy --stage "$STAGE" --yes --no-input | tee deploy.log - url=$(grep -oE "https://[a-z0-9-]+\.allthings\.workers\.dev" deploy.log | tail -1) + # The stack returns the Web Worker's URL as its webUrl output. + url=$(sed -nE "s/^ *webUrl: '(https:[^']+)'.*/\1/p" deploy.log | tail -1) test -n "$url" echo "url=$url" >> "$GITHUB_OUTPUT" echo "Deployed \`$STAGE\`: $url" >> "$GITHUB_STEP_SUMMARY" diff --git a/infra/README.md b/infra/README.md index d2483264..776ff3c6 100644 --- a/infra/README.md +++ b/infra/README.md @@ -30,7 +30,7 @@ allthings has its own Cloudflare account (`af627f300cd00c4dca56aacf05bea050`), s GitHub Actions deploys with an account-owned token that has only the permissions listed in [`ci-token.json`](ci-token.json). [`scripts/ci-token.sh`](scripts/ci-token.sh) applies that file. It creates the token, or updates its policies in place, and writes the value straight into the repository's `CLOUDFLARE_API_TOKEN` secret, so nobody ever sees or copies it. When `alchemy.run.ts` declares more, widen `ci-token.json` in the same PR, then rerun the script after it merges. -The script runs on a maintainer's machine signed in to the allthings account with Cloudflare's CLI, whose sign-in may create account tokens, so no Global API Key or token-minting token exists anywhere: +The script runs on a maintainer's machine signed in to the allthings account with Cloudflare's CLI, whose sign-in may create account tokens, so no Global API Key or token-minting token exists anywhere. Run it from the repository root: ```sh NODE_OPTIONS=--dns-result-order=ipv4first bunx cf@1.0.0-beta.12 auth create allthings --no-device # once per machine