Mackie
note

PR previews with Alchemy, Cloudflare, and GitHub Actions

Revised

Want pull-request previews without making production deployment a separate system? Define one Alchemy stack, give every PR a stage, and let GitHub Actions select the stage.

Setup

  • Add the Alchemy and Cloudflare secrets to GitHub Actions.
  • Replace example.com/* with the production route.
  • Keep pnpm run build and pnpm run astro dev aligned with the app’s normal build and dev commands.

1. Stage the resource

apps/web/alchemy.run.ts
Effect.gen(function* () {
const stage = yield* Alchemy.Stage;
const website = yield* Cloudflare.Website.StaticSite("website", {
name: `website-${stage}`,
command: "pnpm run build",
outdir: "dist",
dev: { command: "pnpm run astro dev" },
routes: stage === "prod" ? [{ pattern: "example.com/*" }] : undefined,
});
return { url: website.url };
});

Use pr-<number> for previews and prod for production. For previews, comment website.url back to the pull request.

apps/web/alchemy.run.ts
Effect.gen(function* () {
if (stage.startsWith("pr-") && process.env.PULL_REQUEST) {
yield* GitHub.Comment("preview-comment", {
owner: "octo-org",
repository: "site",
issueNumber: Number(process.env.PULL_REQUEST),
body: `## Preview deployed
**Website:** ${website.url}`,
});
}
});
.github/workflows/verify.yml
name: Verify
on:
pull_request:
types: [opened, reopened, synchronize]
workflow_call:
concurrency:
group: verify-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
astro-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v4
with:
version: 11.18.0
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --dir apps/web exec astro check
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v4
with:
version: 11.18.0
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --dir apps/web exec astro sync
- run: pnpm exec tsc --project apps/web/tsconfig.json --noEmit

workflow_call lets production reuse the same gate. The concurrency key drops outdated PR checks.

.github/workflows/deploy-preview.yml
on:
pull_request:
types: [opened, reopened, synchronize, closed]
concurrency:
group: deploy-preview-${{ github.event.pull_request.number }}
cancel-in-progress: false
env:
STAGE: pr-${{ github.event.number }}
ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }}
ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_COMPAT: "1"
.github/workflows/deploy-preview.yml
jobs:
deploy-preview:
if: >-
github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
steps:
# repeat the checkout, pnpm setup, and install steps from verify.yml
- run: pnpm run deploy -- --yes --stage ${{ env.STAGE }}
cleanup-preview:
if: >-
github.event.action == 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
steps:
# repeat the checkout, pnpm setup, and install steps from verify.yml
- run: pnpm run destroy -- --yes --stage ${{ env.STAGE }}

The repository guard keeps deployment secrets out of forked PRs. Leave preview runs uncancelled: a deploy and destroy for the same stage must not overlap.

.github/workflows/deploy-prod.yml
on:
push:
branches: [main]
env:
STAGE: prod
jobs:
verify:
uses: ./.github/workflows/verify.yml
deploy-prod:
needs: verify
steps:
# checkout, set up pnpm, install from the lockfile
- run: pnpm run deploy -- --yes --stage ${{ env.STAGE }}