Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Deploy

# Deploys the Worker replacing the app to the allthings Cloudflare account:
# a pr-<number> 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
30 changes: 26 additions & 4 deletions infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<number>`:** 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```
55 changes: 28 additions & 27 deletions infra/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>() };
}

yield* Media;
const upload = yield* MediaUpload;
const token = yield* Alchemy.makeRandom("MediaUploadToken");
yield* MediaUploadCheck({
Expand All @@ -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<string>(), {
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<string>(), {
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<string>(),
...(web === undefined ? {} : { webUrl: web.url.as<string>() }),
};
}),
);
10 changes: 10 additions & 0 deletions infra/ci-token.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
74 changes: 74 additions & 0 deletions infra/scripts/ci-token.sh
Original file line number Diff line number Diff line change
@@ -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
48 changes: 18 additions & 30 deletions infra/src/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Loading