1name: Website
2
3on:
4 # Manual override — deploys unconditionally on whatever branch it's run
5 # from. Toggle `prod` to force a production deploy without a release.
6 workflow_dispatch:
7 inputs:
8 prod:
9 description: "Deploy to production (alchemy.run) instead of the branch stage"
10 type: boolean
11 default: false
12 # Release commits update package manifests, so packages/** keeps production
13 # deploys automatic without redeploying the site for unrelated main commits.
14 push:
15 branches:
16 - main
17 paths:
18 - "submodules/distilled"
19 - "website/**"
20 - "packages/**"
21 - "scripts/generate-api-reference.ts"
22 - "patches/**"
23 - "package.json"
24 - "pnpm-lock.yaml"
25 - "pnpm-workspace.yaml"
26 - "tsconfig.json"
27 - "turbo.json"
28 - ".github/workflows/website.yml"
29 pull_request:
30 # Label changes create and remove previews without needing another push.
31 types:
32 - opened
33 - reopened
34 - synchronize
35 - labeled
36 - unlabeled
37 - closed
38 paths:
39 - "submodules/distilled"
40 - "website/**"
41 - "packages/**"
42 - "scripts/generate-api-reference.ts"
43 - "patches/**"
44 - "package.json"
45 - "pnpm-lock.yaml"
46 - "pnpm-workspace.yaml"
47 - "tsconfig.json"
48 - "turbo.json"
49 - ".github/workflows/website.yml"
50
51concurrency:
52 group: deploy-website-${{ github.event_name == 'pull_request' &&
53 format('pr-{0}', github.event.number) || (inputs.prod ||
54 startsWith(github.event.head_commit.message, 'chore(release):')) &&
55 'prod' || (github.ref == 'refs/heads/main' && 'main' || github.ref_name)
56 }}
57 # Deploy and destroy mutate the same stack and must finish atomically. Each
58 # run checks the PR's current state before mutation, so queued label events
59 # converge to the latest desired state even when GitHub reorders them.
60 cancel-in-progress: false
61
62env:
63 STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}',
64 github.event.number) || (inputs.prod ||
65 startsWith(github.event.head_commit.message, 'chore(release):')) && 'prod' ||
66 (github.ref == 'refs/heads/main' && 'main' || github.ref_name) }}
67
68jobs:
69 deploy:
70 name: Deploy
71 # Deploy every push to main. Release commits (`chore(release): x.y.z`)
72 # and dispatches with `prod` toggled deploy production; other main
73 # commits deploy to main.alchemy.run. On PRs, require BOTH:
74 # - the PR is internal (forks can't access bot/Cloudflare secrets
75 # so `create-github-app-token` would error immediately), AND
76 # - the PR carries the `deploy-website` label (opt-in — most PRs
77 # don't need a website preview, so default to skipping).
78 if: >-
79 (github.event_name != 'pull_request' ||
80 (github.event.pull_request.head.repo.full_name == github.repository &&
81 contains(github.event.pull_request.labels.*.name, 'deploy-website') &&
82 github.event.action != 'closed' &&
83 github.event.action != 'unlabeled' &&
84 (github.event.action != 'labeled' ||
85 github.event.label.name == 'deploy-website')))
86 runs-on: blacksmith-4vcpu-ubuntu-2404
87 permissions:
88 contents: read
89 pull-requests: write
90 steps:
91 - name: Checkout repository
92 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
93
94 - name: Checkout distilled
95 run: git submodule update --init --depth=1 --checkout -- submodules/distilled
96
97 # Mint a token for the alchemy-version-bot GitHub App so the PR
98 # preview comment (created via GitHub.Comment in alchemy.run.ts)
99 # posts under the bot's identity instead of `github-actions[bot]`.
100 # Same App as release.yml — its installation scopes already cover
101 # PR comments.
102 - name: Generate bot token
103 id: bot-token
104 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
105 with:
106 app-id: ${{ secrets.ALCHEMY_VERSION_BOT_ID }}
107 private-key: ${{ secrets.ALCHEMY_VERSION_BOT_PRIVATE_KEY }}
108
109 - name: Restore Astro build cache
110 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
111 with:
112 path: website/node_modules/.astro
113 key: website-astro-full-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}-${{ github.workflow }}-${{ github.run_attempt }}
114 restore-keys: |
115 website-astro-full-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-${{ github.sha }}-
116 website-astro-full-${{ runner.os }}-${{ github.event.pull_request.number || 'main' }}-
117 website-astro-full-${{ runner.os }}-main-
118
119 - name: Restore Turbo cache
120 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
121 with:
122 path: .turbo
123 key: turbo-${{ runner.os }}-${{ github.repository_id }}-${{ github.sha }}-${{ github.workflow }}-${{ github.run_attempt }}
124 restore-keys: |
125 turbo-${{ runner.os }}-${{ github.repository_id }}-${{ github.sha }}-
126 turbo-${{ runner.os }}-${{ github.repository_id }}-
127
128 - name: Setup pnpm, Node.js, Bun and install dependencies
129 uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
130 with:
131 cache: true
132 install: true
133 require-lockfile: true
134
135 - name: Check current preview state
136 if: github.event_name == 'pull_request'
137 id: preview-state
138 env:
139 GH_TOKEN: ${{ github.token }}
140 run: |
141 active="$(gh api "repos/$GITHUB_REPOSITORY/pulls/${{ github.event.number }}" \
142 --jq '[.state == "open", any(.labels[]; .name == "deploy-website")] | all')"
143 echo "active=$active" >> "$GITHUB_OUTPUT"
144
145 # Main/prod use the production account; PR stages use the test account.
146 - name: Deploy
147 if: >-
148 github.event_name != 'pull_request' ||
149 steps.preview-state.outputs.active == 'true'
150 working-directory: website
151 run: bun alchemy deploy --stage "$STAGE" --yes
152 env:
153 PULL_REQUEST: ${{ github.event.number }}
154 CLOUDFLARE_API_TOKEN: ${{ (env.STAGE == 'main' || env.STAGE == 'prod') && secrets.PROD_CLOUDFLARE_API_TOKEN || secrets.TEST_CLOUDFLARE_API_TOKEN }}
155 CLOUDFLARE_ACCOUNT_ID: ${{ (env.STAGE == 'main' || env.STAGE == 'prod') && secrets.PROD_CLOUDFLARE_ACCOUNT_ID || secrets.TEST_CLOUDFLARE_ACCOUNT_ID }}
156 BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
157 GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }}
158
159 # PR builds upload zero-traffic versions of this Worker. Refresh the
160 # parent from merged main using the same test account as PR previews.
161 - name: Deploy preview parent
162 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
163 working-directory: website
164 run: bun alchemy deploy --stage preview-base --yes
165 env:
166 CLOUDFLARE_API_TOKEN: ${{ secrets.TEST_CLOUDFLARE_API_TOKEN }}
167 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.TEST_CLOUDFLARE_ACCOUNT_ID }}
168 BUILD_SHA: ${{ github.sha }}
169
170 cleanup:
171 name: Cleanup
172 runs-on: blacksmith-4vcpu-ubuntu-2404
173 if: >-
174 github.event.pull_request.head.repo.full_name == github.repository &&
175 ((github.event.action == 'closed' &&
176 contains(github.event.pull_request.labels.*.name, 'deploy-website')) ||
177 (github.event.action == 'unlabeled' &&
178 github.event.label.name == 'deploy-website'))
179 permissions:
180 contents: read
181 pull-requests: write
182 steps:
183 - name: Checkout repository
184 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
185
186 - name: Checkout distilled
187 run: git submodule update --init --depth=1 --checkout -- submodules/distilled
188
189 - name: Generate bot token
190 id: bot-token
191 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
192 with:
193 app-id: ${{ secrets.ALCHEMY_VERSION_BOT_ID }}
194 private-key: ${{ secrets.ALCHEMY_VERSION_BOT_PRIVATE_KEY }}
195
196 - name: Setup pnpm, Node.js, Bun
197 uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
198 with:
199 cache: true
200 install: false
201
202 - name: Install dependencies
203 # Destroy evaluates the stack and calls providers; it does not build or
204 # bundle anything. Skip native postinstalls and the root prepare build.
205 run: pnpm install --frozen-lockfile --ignore-scripts
206
207 - name: Check current preview state
208 id: preview-state
209 env:
210 GH_TOKEN: ${{ github.token }}
211 run: |
212 active="$(gh api "repos/$GITHUB_REPOSITORY/pulls/${{ github.event.number }}" \
213 --jq '[.state == "open", any(.labels[]; .name == "deploy-website")] | all')"
214 echo "active=$active" >> "$GITHUB_OUTPUT"
215
216 # Allowlist, not denylist: cleanup must only ever destroy pr-N stages,
217 # so refuse anything else outright (prod/main/preview-base) even if a
218 # future STAGE refactor changes what a pull_request event resolves to.
219 - name: Safety Check
220 if: steps.preview-state.outputs.active != 'true'
221 run: |-
222 case "${{ env.STAGE }}" in
223 pr-[0-9]*) ;;
224 *)
225 echo "ERROR: cleanup only destroys pr-* stages, got '${{ env.STAGE }}'"
226 exit 1
227 ;;
228 esac
229
230 - name: Destroy Preview Environment
231 if: steps.preview-state.outputs.active != 'true'
232 working-directory: website
233 run: bun alchemy destroy --stage "$STAGE" --yes
234 env:
235 PULL_REQUEST: ${{ github.event.number }}
236 CLOUDFLARE_API_TOKEN: ${{ secrets.TEST_CLOUDFLARE_API_TOKEN }}
237 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.TEST_CLOUDFLARE_ACCOUNT_ID }}
238 GITHUB_TOKEN: ${{ steps.bot-token.outputs.token }}