-
Notifications
You must be signed in to change notification settings - Fork 0
ci: migrate deployment to Alchemy #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bfe13bc
8db0794
fdea814
c9a9051
293c8e6
a81060b
2134b4a
6399ef9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| self-hosted-runner: | ||
| labels: | ||
| - blacksmith-4vcpu-ubuntu-2404 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| # Checks only. Cloudflare deploys this Worker through its Git integration | ||
| # (Workers Builds), so nothing here needs a Cloudflare API token. | ||
| # Checks and Alchemy plan. Production deploy runs from deploy.yml after a | ||
| # successful push CI on master. | ||
|
|
||
| name: CI | ||
|
|
||
|
|
@@ -31,17 +31,54 @@ jobs: | |
| - run: pnpm run typecheck | ||
| - run: pnpm run test | ||
|
|
||
| # No SENTRY_AUTH_TOKEN here on purpose. Cloudflare builds and deploys the | ||
| # Worker, so a release uploaded from CI would never match a shipped | ||
| # bundle, and a fork PR cannot read the secret anyway. Without the token | ||
| # the Sentry Vite plugin disables itself and the build stays fast. | ||
| # No SENTRY_AUTH_TOKEN here on purpose. A fork PR cannot read the secret. | ||
| # Without the token the Sentry Vite plugin disables itself and the build | ||
| # stays fast. | ||
| - name: Build | ||
| env: | ||
| VITE_SITE_URL: ${{ vars.VITE_SITE_URL }} | ||
| run: pnpm run build | ||
|
|
||
| # Prints "Total Upload: ... / gzip: ...". Cloudflare rejects a Worker | ||
| # above 3 MiB gzip. Catching it here fails the PR instead of the | ||
| # Cloudflare build. | ||
| # Prints "Total Upload: ... / gzip: ...". Production is on Workers Paid | ||
| # (10 MiB gzip). Fail the PR if the bundle is unexpectedly huge. | ||
| # Point at the Vite SSR bundle; Alchemy injects its Cloudflare plugin | ||
| # during plan/deploy, so root wrangler.jsonc is not the size path. | ||
| - name: Report Worker bundle size | ||
| run: pnpm exec wrangler deploy --dry-run | ||
| run: pnpm exec wrangler deploy --dry-run --name error-wolf dist/server/server.js | ||
|
|
||
| plan: | ||
| needs: build | ||
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | ||
| runs-on: blacksmith-4vcpu-ubuntu-2404 | ||
| environment: preview | ||
| env: | ||
| ALCHEMY_PROFILE: ci | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: pnpm/action-setup@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 24 | ||
| cache: pnpm | ||
| - run: pnpm install --frozen-lockfile | ||
| - name: Configure Alchemy state access | ||
| env: | ||
| ALCHEMY_STATE_STORE_CREDENTIALS: ${{ secrets.ALCHEMY_STATE_STORE_CREDENTIALS }} | ||
| run: | | ||
| credentials_dir="$HOME/.alchemy/credentials/$ALCHEMY_PROFILE" | ||
| credentials_file="$credentials_dir/cloudflare-state-store.json" | ||
| install -d -m 700 "$credentials_dir" | ||
| printf '%s' '{"version":0,"profiles":{"ci":{"Cloudflare":{"method":"env"}}}}' > "$HOME/.alchemy/profiles.json" | ||
| printf '%s' "$ALCHEMY_STATE_STORE_CREDENTIALS" > "$credentials_file" | ||
| jq -e \ | ||
| '(.url | length > 0) and (.authToken | length > 0) and (.accountId | length > 0)' \ | ||
| "$credentials_file" >/dev/null | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Plan cannot load Alchemy stateHigh Severity The plan job writes a Additional Locations (1)Reviewed by Cursor Bugbot for commit 6399ef9. Configure here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bugbot Autofix determined this is a false positive. Alchemy 2.0.0-beta.67 still reads ~/.alchemy/credentials//cloudflare-state-store.json when CI=true (CI only skips writing), and this PR's plan job already passed with the read-only token plus that file. You can send follow-ups to the cloud agent here. |
||
| - name: Plan production changes | ||
| run: pnpm exec alchemy plan --stage prod | ||
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| GITHUB_SHA: ${{ github.sha }} | ||
| VITE_SITE_URL: ${{ vars.VITE_SITE_URL }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| name: Deploy | ||
|
|
||
| on: | ||
| workflow_run: | ||
| workflows: ["CI"] | ||
| types: [completed] | ||
|
|
||
| concurrency: | ||
| group: deploy-${{ github.event.workflow_run.head_branch }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| production: | ||
| if: >- | ||
| github.event.workflow_run.conclusion == 'success' && | ||
| github.event.workflow_run.event == 'push' && | ||
| github.event.workflow_run.head_branch == 'master' | ||
| runs-on: blacksmith-4vcpu-ubuntu-2404 | ||
| environment: production | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.event.workflow_run.head_sha }} | ||
| - uses: pnpm/action-setup@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 24 | ||
| cache: pnpm | ||
| - run: pnpm install --frozen-lockfile | ||
| - name: Deploy production | ||
| run: pnpm exec alchemy deploy --stage prod --yes | ||
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} | ||
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| VITE_SITE_URL: ${{ vars.VITE_SITE_URL }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,3 +39,4 @@ pnpm-debug.log* | |
|
|
||
| # Sentry Config File | ||
| .env.sentry-build-plugin | ||
| .alchemy/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -142,7 +142,7 @@ Use **pnpm**. | |
| | `pnpm dev` | Vite dev server on port 3000 | | ||
| | `pnpm build` | Production build into `dist/` | | ||
| | `pnpm preview` | Serve the production build | | ||
| | `pnpm deploy` | Build, then `wrangler deploy` | | ||
| | `pnpm deploy` | Deploy with Alchemy | | ||
| | `pnpm test` | Vitest, one run | | ||
| | `pnpm test:watch` | Vitest in watch mode | | ||
| | `pnpm lint` | Oxlint (`--type-aware`) | | ||
|
|
@@ -168,44 +168,44 @@ There is no combined `check` script. After a substantive edit, run | |
| - There is no `routes` block. The site serves from `*.workers.dev` until the | ||
| custom domain moves. | ||
|
|
||
| The Vite build writes `dist/server/wrangler.json`. Run `wrangler deploy` from the | ||
| repo root after `vite build`. It finds that file. | ||
| Alchemy injects its Cloudflare Vite plugin during `alchemy plan` and | ||
| `alchemy deploy`. Local Wrangler checks use root `wrangler.jsonc` | ||
| (`main`: `src/server.ts`). Keep that file aligned with `alchemy.run.ts`. | ||
|
|
||
| **Workers have no filesystem.** Do not use `node:fs` or `process.cwd()` in code | ||
| that the server bundle reaches. Read files at build time instead. See | ||
| `src/lib/example-traces.ts` and `src/lib/announcements/load.ts`. | ||
|
|
||
| **Bundle size.** Cloudflare rejects a Worker above 3 MiB gzip. Run | ||
| `pnpm exec wrangler deploy --dry-run` to print the current size. | ||
| **Bundle size.** Production runs on the Cloudflare Workers Paid plan (10 MiB | ||
| gzip). After `pnpm build`, run | ||
| `pnpm exec wrangler deploy --dry-run --name error-wolf dist/server/server.js` | ||
| to print the current size. This is a read-only size check, not the deployment | ||
| path. Keep the Worker under the paid limit; treat 3 MiB as a soft target so a | ||
| plan downgrade would still fit. | ||
|
Comment on lines
+179
to
+184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: As of August 20, 2026, the Cloudflare Workers platform has moved away from enforced compressed size limits [1]. While some documentation pages may still show historical tables referencing 3 MB (Free) and 10 MB (Paid) limits for compressed scripts [2][3][4], these constraints have been removed [1]. The platform now enforces an uncompressed Worker size limit of 64 MB for all plans [1]. To verify current information, you should refer to the most recent Cloudflare Workers documentation, which clarifies that the previous compressed size check (gzip) is no longer the governing limit [1]. Citations:
🌐 Web query:
💡 Result: As of August 20, 2026, the 64 MB uncompressed size limit for Cloudflare Workers has not been removed [1][2]. Official documentation continues to specify a limit of 64 MB for the size of a Worker before compression, regardless of the plan [1][2]. For the size after compression (gzip), the limits remain 3 MB for the Workers Free plan and 10 MB for the Workers Paid plan [1][2]. Citations:
🌐 Web query:
💡 Result: Cloudflare Workers have transitioned to a single, unified Worker size limit of 64 MiB (uncompressed) across all plans, following the removal of previous compressed size limits (3 MiB for Free and 10 MiB for Paid plans) [1][2][3]. This update was finalized via Cloudflare documentation updates (e.g., Pull Request Citations:
🏁 Script executed: python3 - <<'PY'
import urllib.request
for url in [
"https://developers.cloudflare.com/workers/platform/limits/",
"https://raw.githubusercontent.com/cloudflare/cloudflare-docs/production/src/content/docs/workers/platform/limits.mdx",
"https://api.github.com/repos/cloudflare/cloudflare-docs/pulls/31067",
]:
print(f"\n=== {url} ===")
try:
req = urllib.request.Request(url, headers={"User-Agent": "review-verifier"})
data = urllib.request.urlopen(req, timeout=15).read().decode("utf-8", "replace")
print(data[:30000])
except Exception as e:
print(type(e).__name__, e)
PYRepository: bearfire-dev/error-wolf Length of output: 864 🏁 Script executed: set -eu
for url in \
"https://developers.cloudflare.com/workers/platform/limits/" \
"https://raw.githubusercontent.com/cloudflare/cloudflare-docs/production/src/content/docs/workers/platform/limits.mdx" \
"https://api.github.com/repos/cloudflare/cloudflare-docs/pulls/31067" \
"https://api.github.com/repos/cloudflare/cloudflare-docs/pulls/31002" \
"https://api.github.com/repos/cloudflare/workers-sdk/pulls/14001"
do
printf '\n=== %s ===\n' "$url"
curl -LksS --max-time 20 -A 'review-verifier' "$url" | head -c 40000
printf '\n'
doneRepository: bearfire-dev/error-wolf Length of output: 50382 🏁 Script executed: set -eu
curl -LksS --max-time 20 \
https://raw.githubusercontent.com/cloudflare/cloudflare-docs/production/src/content/docs/workers/platform/limits.mdx |
rg -n -A35 -B8 'worker-size|Worker size|compression|gzip|compressed|64 MiB|64 MB'
printf '\n=== PR 31067 ===\n'
curl -LksS --max-time 20 \
https://api.github.com/repos/cloudflare/cloudflare-docs/pulls/31067 |
jq '{number,title,state,merged_at,updated_at,body,html_url}'
printf '\n=== PR 31002 ===\n'
curl -LksS --max-time 20 \
https://api.github.com/repos/cloudflare/cloudflare-docs/pulls/31002 |
jq '{number,title,state,merged_at,updated_at,body,html_url}'
printf '\n=== Workers SDK PR 14001 ===\n'
curl -LksS --max-time 20 \
https://api.github.com/repos/cloudflare/workers-sdk/pulls/14001 |
jq '{number,title,state,merged_at,updated_at,body,html_url}'Repository: bearfire-dev/error-wolf Length of output: 9889 Use Cloudflare documents the Workers Paid compressed limit as 🤖 Prompt for AI Agents |
||
|
|
||
| To test against the Workers runtime and not the Vite dev server, run | ||
| `pnpm build`, then `pnpm exec wrangler dev`. Node API differences appear there. | ||
|
|
||
| ### Deploy | ||
|
|
||
| **Cloudflare deploys this Worker itself**, through its Git integration (Workers | ||
| Builds). A push to `master` triggers a Cloudflare build, and Cloudflare runs | ||
| `wrangler deploy`. GitHub Actions does not deploy, and the repo needs no | ||
| Cloudflare API token. | ||
|
|
||
| Cloudflare build settings: | ||
|
|
||
| | Setting | Value | | ||
| | -------------- | --------------------- | | ||
| | Build command | `pnpm run build` | | ||
| | Deploy command | `npx wrangler deploy` | | ||
| | Root directory | repo root | | ||
| **Alchemy deploys this Worker.** Same-repository pull requests run an Alchemy | ||
| plan after CI succeeds. Fork pull requests run CI only. A push to `master` | ||
| deploys production after CI succeeds. The one-time `stacks/github.ts` stack | ||
| creates the preview (read-only plan token + Alchemy state credentials) and | ||
| production (deploy) environment secrets used by these jobs. | ||
|
|
||
| Set `VITE_SITE_URL` as a build variable in the Cloudflare project. Vite inlines | ||
| it at build time, so it must be present in the Cloudflare build and not only in | ||
| GitHub Actions. | ||
|
|
||
| `.github/workflows/ci.yml` runs the checks on Blacksmith runners: format, lint, | ||
| typecheck, test, build, and the Worker size report. It gates the pull request. | ||
| It does not ship anything. | ||
| typecheck, test, build, and the Worker size report. Same-repository pull | ||
| requests then run `alchemy plan --stage prod` with the `preview` environment. | ||
| `.github/workflows/deploy.yml` deploys production after a successful push CI on | ||
| `master`. | ||
|
|
||
| `pnpm deploy` still works for a deploy by hand. It needs a local `wrangler | ||
| login`. | ||
| `pnpm deploy` runs `alchemy deploy --stage prod` for a deploy by hand. It needs | ||
| a Cloudflare API token with the same deployment permissions as CI. | ||
|
|
||
| ## Environment variables | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import * as Alchemy from "alchemy" | ||
| import * as Cloudflare from "alchemy/Cloudflare" | ||
| import { adopt } from "alchemy/AdoptPolicy" | ||
| import { retain } from "alchemy/RemovalPolicy" | ||
| import * as Effect from "effect/Effect" | ||
|
|
||
| /** Deploys the existing Error Wolf Worker through Alchemy. */ | ||
| export default Alchemy.Stack( | ||
| "ErrorWolf", | ||
| { | ||
| providers: Cloudflare.providers(), | ||
| state: Cloudflare.state(), | ||
| }, | ||
| Effect.gen(function* () { | ||
| const stage = yield* Alchemy.Stage | ||
| if (stage !== "prod") { | ||
| return yield* Effect.die( | ||
| new Error("The error-wolf stack supports only the prod stage.") | ||
| ) | ||
| } | ||
|
|
||
| const app = yield* Cloudflare.Website.Vite("ErrorWolf", { | ||
| name: "error-wolf", | ||
| compatibility: { date: "2026-07-30", flags: ["nodejs_compat"] }, | ||
| observability: { enabled: true }, | ||
| }).pipe(adopt(true), retain()) | ||
|
slate-rehm marked this conversation as resolved.
|
||
|
|
||
| return { url: app.url } | ||
| }) | ||
| ) | ||


Uh oh!
There was an error while loading. Please reload this page.