diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 00000000..049acf93 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,96 @@ +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 + # 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" + + 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 c763be28..776ff3c6 100644 --- a/infra/README.md +++ b/infra/README.md @@ -8,10 +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 + +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. 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 +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, +);