Skip to content
Open
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
108 changes: 49 additions & 59 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,23 @@ on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
types: [opened, reopened, synchronize, closed]

concurrency:
group: deploy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: deploy-${{ github.ref }}
cancel-in-progress: false

jobs:
deploy-production:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2

- uses: pnpm/action-setup@v4.1.0
with:
version: 10.27.0
# The stage name keys an isolated copy of the entire stack (worker + D1 + DOs).
# Pull requests → `pr-<n>`; pushes to main (the only push trigger) → `prod`.
env:
STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'prod' }}

- uses: actions/setup-node@v4.4.0
with:
node-version: 26.2.0
cache: pnpm

- run: pnpm install --frozen-lockfile

# alchemy deploy runs `pnpm build` first (the @phoenix/web `deploy` script),
# bundling the worker + uploading dist/client. `--stage prod` is the shared
# production stage. State store is chosen by `resolveStateMode` (worker/env.ts)
# off the alchemy dev signal — NOT `CI` (ADR 0032; the CI heuristic was a
# footgun: `CI` is set for both deploy and test, so it couldn't tell a real
# deploy from a test run). A real `alchemy deploy` (dev signal unset) always
# resolves to the Cloudflare-hosted store; only `alchemy dev`/Vitest stays local.
- name: Deploy to production
run: pnpm --filter @phoenix/web deploy --stage prod --yes
env:
CI: "true"
ENVIRONMENT: production
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }}

deploy-preview:
if: github.event_name == 'pull_request' && github.event.action != 'closed'
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4.2.2

Expand All @@ -63,31 +35,37 @@ jobs:

- run: pnpm install --frozen-lockfile

# `--stage pr-<n>` deploys a fully isolated copy of the stack — its own
# worker script, D1, and DOs — keyed by the stage name. No second config.
- name: Deploy PR preview
id: deploy
run: |
pnpm --filter @phoenix/web deploy \
--stage pr-${{ github.event.pull_request.number }} --yes
# The SPA is a plain Vite build; the worker uploads `dist/client` via its
# `assets` prop. `alchemy deploy` does not drive Vite for phoenix's shape.
- name: Build SPA
run: pnpm --filter @phoenix/web build

# `exec alchemy` forwards `--stage`/`--yes` to the alchemy CLI. A bare
# `pnpm --filter @phoenix/web deploy --stage …` makes *pnpm* eat the flags
# ("Unknown options: 'stage', 'yes'"). `--yes` skips the interactive plan
# approval (CI would otherwise hang); `--stage` selects the isolated copy.
# Creds + BETTER_AUTH_SECRET come from the secrets `stacks/github.ts` provisions.
- name: Deploy (stage ${{ env.STAGE }})
run: pnpm --filter @phoenix/web exec alchemy deploy --stage "$STAGE" --yes
env:
CI: "true"
ENVIRONMENT: production
# prod closes the dev gates (the admin seed/clear routes, magic-link
# token logging — `worker/config.ts`); previews stay `development` so a
# fresh preview D1 can still be seeded via the dev-only importer routes.
ENVIRONMENT: ${{ env.STAGE == 'prod' && 'production' || 'development' }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }}
# The session-signing secret. `config.ts` reads it via
# `Config.redacted("BETTER_AUTH_SECRET")` → a `secret_text` binding the
# worker reads at runtime; the value must be present at deploy time.
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}

- name: Comment preview stage
uses: marocchino/sticky-pull-request-comment@v2
with:
header: preview-url
message: |
**Preview stage:** `pr-${{ github.event.pull_request.number }}`
Deployed via `alchemy deploy --stage pr-${{ github.event.pull_request.number }}`.

cleanup-preview:
cleanup:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4.2.2

Expand All @@ -102,11 +80,23 @@ jobs:

- run: pnpm install --frozen-lockfile

# Tear down the isolated preview stack (worker + D1 + DOs).
- name: Destroy preview stage
run: pnpm --filter @phoenix/web exec alchemy destroy --stage pr-${{ github.event.pull_request.number }} --yes
# STAGE is only ever `pr-<n>` in this job (it runs on closed PRs), but fail
# loudly rather than ever let a destroy reach the prod stage.
- name: Safety check — never destroy prod
run: |
if [ "$STAGE" = "prod" ]; then
echo "ERROR: refusing to destroy the prod stage in cleanup"
exit 1
fi

# Tears down the isolated preview stack (worker + D1 + DOs). `destroy` loads
# `alchemy.run.ts`, which builds the worker layer → needs BETTER_AUTH_SECRET
# (config.ts reads it `Effect.orDie`) just like the deploy.
- name: Destroy preview stage ${{ env.STAGE }}
run: pnpm --filter @phoenix/web exec alchemy destroy --stage "$STAGE" --yes
env:
CI: "true"
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
81 changes: 81 additions & 0 deletions .patterns/alchemy-ci-cd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# CI/CD — deploy from GitHub Actions

How phoenix ships from CI. Pushes to `main` deploy the `prod` stage; pull requests
get an isolated `pr-<n>` preview with its own worker + D1 + DOs; closing a PR tears
that stage down. Adapted from [alchemy tutorial Part 5](https://v2.alchemy.run/tutorial/part-5/)
to phoenix's stack (pnpm + node, not bun).

The trick — and the reason this isn't just "paste your Cloudflare key into GitHub" —
is that alchemy **provisions its own CI credentials**: a one-shot `stacks/github.ts`
mints a *scoped* Cloudflare API token and writes it (plus the account id and the
state password) into the repo's Actions secrets, all from code.

## The pieces

| File | Role |
|---|---|
| [`apps/web/stacks/github.ts`](../apps/web/stacks/github.ts) | One-shot, run from your laptop under an `admin` profile. Mints the scoped CI token + a stable `BETTER_AUTH_SECRET` and pushes the four repo secrets. |
| [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) | `deploy` job (push→`prod`, PR→`pr-<n>`) + `cleanup` job (PR close→`destroy`). |

## The CI secret set

`stacks/github.ts` provisions exactly what the workflow consumes:

| Secret | Why |
|---|---|
| `CLOUDFLARE_API_TOKEN` | The minted token, scoped to Workers Scripts / KV / D1 / Tail / Account-Settings-Read. Never echoed to your shell — piped from `AccountApiToken.value` straight into `GitHub.Secret`. |
| `CLOUDFLARE_ACCOUNT_ID` | Which account to deploy into. |
| `ALCHEMY_PASSWORD` | Encrypts/decrypts secrets in the Cloudflare-hosted alchemy state store. |
| `BETTER_AUTH_SECRET` | The session-signing secret. The worker reads it at runtime as a `secret_text` binding (`config.ts`: `Config.redacted("BETTER_AUTH_SECRET")`), so `alchemy deploy` needs the value. `stacks/github.ts` mints a stable `Random` (persisted in its state) and pushes it. |

> **`BETTER_AUTH_SECRET` is a deploy-time binding value, not Random-in-the-app-stack.**
> The worker reads it from the runtime env (`config.ts`); `Random` is a deploy-time
> resource with no value in the workerd isolate, so it can't be the runtime source.
> `stacks/github.ts` mints it once (stable across re-runs) and pushes it as a repo
> secret; the deploy passes it through so the worker binds it.

## Bootstrap (run once, by a human)

The app deploy only needs enough Cloudflare permission to ship the worker. Minting a
*new* token needs the elevated `API Tokens > Write` permission — so keep that on a
dedicated profile, not your day-to-day one.

```bash
# 1. Log in with a credential that can mint tokens (Global API Key is simplest) +
# a GitHub credential (gh-cli or a PAT with `repo`).
alchemy login --profile admin

# 2. Deploy the one-shot. It mints the scoped CF token + a stable BETTER_AUTH_SECRET
# and pushes all four repo secrets. ALCHEMY_PASSWORD is the state-encryption
# password; reuse the same value the app stack deploys with.
CLOUDFLARE_ACCOUNT_ID=<account-id> ALCHEMY_PASSWORD=<password> \
pnpm --filter @phoenix/web exec alchemy deploy stacks/github.ts \
--profile admin --yes
```

Check **Settings → Secrets and variables → Actions**: `CLOUDFLARE_API_TOKEN`,
`CLOUDFLARE_ACCOUNT_ID`, `ALCHEMY_PASSWORD`, and `BETTER_AUTH_SECRET` should be
listed. Re-run only to rotate the token or change its scope — the remote
`Cloudflare.state()` tracks the token's id (and the minted secret), so a rescope is
a clean diff, not an orphaned token.

## Gotchas baked into the files

- **`exec alchemy`, not the package script.** `pnpm --filter @phoenix/web deploy --stage X`
makes *pnpm* swallow `--stage`/`--yes` (`Unknown options: 'stage', 'yes'`). The
workflow builds the SPA, then runs `pnpm --filter @phoenix/web exec alchemy deploy
--stage "$STAGE" --yes` so the flags reach the alchemy CLI.
- **`--yes` is required in CI.** Without it, alchemy prompts for plan approval on any
change and the job hangs.
- **`STAGE` regex.** Stage names must match `^[a-z0-9]([-_a-z0-9]*)$` — `pr-12` and
`prod` both pass.
- **`BETTER_AUTH_SECRET` at deploy AND destroy.** `config.ts` reads it `Effect.orDie`,
so both `deploy` and `destroy` (which loads `alchemy.run.ts` to build the worker
layer) need it in env, not just the deploy.
- **Prod safety check.** The `cleanup` job refuses to `destroy` if `STAGE == prod`,
even though it only ever runs on closed PRs.

## See also

- [alchemy-stack-deploy.md](./alchemy-stack-deploy.md) — the stack, stages, dev vs deploy
- [alchemy-overview.md](./alchemy-overview.md) — what alchemy replaces and why
1 change: 1 addition & 0 deletions .patterns/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ The infra layer beneath the domain and fate layers. phoenix runs on [alchemy-eff
| [alchemy-durable-objects.md](./alchemy-durable-objects.md) | The unified `LiveDO` — `.make()`, role dispatch via `resolveRole(state.id.name)`, `LiveDO.from("phoenix")` self-namespace, KV storage, per-subscriber frame.id, the reap alarm | Working on the live DO ([ADR 0037](../.decisions/0037-unified-void-aligned-live-do.md)) |
| [alchemy-drizzle-d1.md](./alchemy-drizzle-d1.md) | `D1Connection.bind` → `raw` → `drizzle(raw,{schema})`; `Drizzle` as a worker-level singleton; migrations generated by `drizzle-kit` out-of-band, applied by alchemy via `D1Database({migrationsDir})` | Wiring the DB or migrations |
| [alchemy-stack-deploy.md](./alchemy-stack-deploy.md) | `alchemy.run.ts` + `Alchemy.Stack`, resource declarations, `wrangler.jsonc`→alchemy map, dev/deploy, stages | Declaring resources or deploying |
| [alchemy-ci-cd.md](./alchemy-ci-cd.md) | The deploy workflow (push→prod, PR→`pr-<n>` preview, close→destroy); `stacks/github.ts` self-provisioning a scoped CI token + repo secrets; the preview `GitHub.Comment`; the pnpm `exec` flag-forwarding gotcha | Wiring or debugging CI deploys, rotating the CI token |
| [alchemy-test-harness.md](./alchemy-test-harness.md) | `alchemy/Test/Core` deploy in `globalSetup` (main-process workaround for the pool-worker LoopbackServer race) + a black-box HTTP harness in the pool | Writing integration tests against the deployed worker |
| [better-auth-with-plugins-on-d1.md](./better-auth-with-plugins-on-d1.md) | Forked `CloudflareD1` Layer on phoenix's existing D1; `Random` for the session secret; threading the resolved `Auth` instance to consumers without leaking `RuntimeContext` | Adding/editing better-auth plugins or wiring an auth consumer |

Expand Down
127 changes: 127 additions & 0 deletions apps/web/stacks/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* The phoenix CI-credential stack — a one-shot you deploy from your laptop under
* a dedicated `admin` profile to provision everything GitHub Actions needs to
* run `alchemy deploy` (Part 5 of the alchemy tutorial, adapted to phoenix:
* pnpm + node, not bun; `kamp-us/phoenix`).
*
* Why a separate stack from `alchemy.run.ts`: the app stack only needs enough
* Cloudflare permission to deploy the worker. THIS stack *mints a brand-new,
* scoped Cloudflare API token* (which requires the elevated `API Tokens > Write`
* permission) and stores it as a GitHub Actions secret — so your personal
* Cloudflare key never touches the repo, and CI runs on a token scoped to
* exactly what the deploy touches.
*
* Run it once, under the elevated profile (see `.patterns/alchemy-ci-cd.md`):
*
* alchemy login --profile admin # Cloudflare Global API Key + a GitHub creds
* CLOUDFLARE_ACCOUNT_ID=<id> ALCHEMY_PASSWORD=<pw> \
* pnpm --filter @phoenix/web exec alchemy deploy stacks/github.ts \
* --profile admin --yes
*
* Re-run only to rotate the token or change its scope. Reusing the remote
* `Cloudflare.state()` means the token's ID is tracked, so a rescope is a clean
* diff rather than an orphaned token.
*
* Provisions the full set of repo secrets the deploy workflow consumes:
* - CLOUDFLARE_API_TOKEN — the minted scoped token (never echoed to your shell)
* - CLOUDFLARE_ACCOUNT_ID — which account to deploy into
* - ALCHEMY_PASSWORD — encrypts/decrypts secrets in the Cloudflare-hosted
* alchemy state store
* - BETTER_AUTH_SECRET — the session-signing secret. The worker reads it at
* runtime as a `secret_text` binding (`config.ts`:
* `Config.redacted("BETTER_AUTH_SECRET")`), so the
* deploy needs the value. Minted here as a stable
* `Random` (persisted in this stack's state) and
* pushed so CI can bind it on every deploy.
*/
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as GitHub from "alchemy/GitHub";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";

const OWNER = "kamp-us";
const REPOSITORY = "phoenix";

export default Alchemy.Stack(
"github",
{
// `provideMerge`, not `mergeAll`: both provider sets bring their own
// `Profile`/`CredentialsStore`, so a parallel `mergeAll` builds the shared
// auth layer twice. Sequencing them shares one (effect TS377035).
providers: Cloudflare.providers().pipe(Layer.provideMerge(GitHub.providers())),
state: Cloudflare.state(),
},
Effect.gen(function* () {
// A missing env on this one-shot is unrecoverable — surface it as a defect
// (the stack body's error channel must be `never`) rather than threading
// `ConfigError` out.
const accountId = yield* Effect.orDie(Config.string("CLOUDFLARE_ACCOUNT_ID"));
const alchemyPassword = yield* Effect.orDie(Config.redacted("ALCHEMY_PASSWORD"));

// Scoped to exactly what `alchemy deploy` touches: the worker (its DOs and
// uploaded `dist/client` assets ride on the script), the alchemy
// state-store worker, the D1 database, KV (state-store metadata), tail
// logs, and read access to account settings. No R2/Queues — phoenix uses
// neither; drop or add groups here if that changes.
const apiToken = yield* Cloudflare.AccountApiToken("phoenix-ci-token", {
name: "phoenix-ci",
accountId,
policies: [
{
effect: "allow",
permissionGroups: [
"Workers Scripts Write",
"Workers KV Storage Write",
"D1 Write",
"Workers Tail Read",
"Account Settings Read",
],
resources: {[`com.cloudflare.api.account.${accountId}`]: "*"},
},
],
});

// Pipe the minted value straight into a GitHub secret — the raw token
// never round-trips through the shell.
yield* GitHub.Secret("cf-api-token", {
owner: OWNER,
repository: REPOSITORY,
name: "CLOUDFLARE_API_TOKEN",
value: apiToken.value,
});

// Not a cryptographic secret, but storing it here keeps all CI config in
// one place. `Redacted.make` gives it the same masking as the token.
yield* GitHub.Secret("cf-account-id", {
owner: OWNER,
repository: REPOSITORY,
name: "CLOUDFLARE_ACCOUNT_ID",
value: Redacted.make(accountId),
});

// The same password used to encrypt this stack's state — propagated so CI
// can read encrypted secrets back out of the shared alchemy state store.
yield* GitHub.Secret("alchemy-password", {
owner: OWNER,
repository: REPOSITORY,
name: "ALCHEMY_PASSWORD",
value: alchemyPassword,
});

// The better-auth session-signing secret. The worker reads it at runtime as
// a `secret_text` binding (`config.ts` `Config.redacted("BETTER_AUTH_SECRET")`),
// so `alchemy deploy` needs the value in its env. Mint a stable random one
// here — persisted in this stack's state, so re-runs keep the same value and
// existing sessions survive — and push it as the repo secret the deploy reads.
const betterAuthSecret = yield* Alchemy.Random("BETTER_AUTH_SECRET");
yield* GitHub.Secret("better-auth-secret", {
owner: OWNER,
repository: REPOSITORY,
name: "BETTER_AUTH_SECRET",
value: betterAuthSecret.text,
});
}),
);
3 changes: 3 additions & 0 deletions apps/web/tsconfig.worker.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"worker",
"src/lib/mutationErrorCodes.ts",
"alchemy.run.ts",
// The CI-credential stack (`stacks/github.ts`) — a Node-ESM alchemy entry
// like `alchemy.run.ts`, so it shares the same `.ts`-import settings.
"stacks",
// The black-box integration suite (Vitest node pool → HTTP against the
// deployed worker) belongs to the worker project: it needs the same `.ts`
// import specifiers + workers-runtime globals (`fetch`/`Response`/
Expand Down
Loading