Deploy pipeline (W6)
Two GitHub Actions workflows are the single deploy driver for every environment. Manual wrangler deploy remains valid as a break-glass path, but routine deploys go through the pipeline so the deployed-SHA record stays true.
| Trigger | Workflow | Deploys |
|---|---|---|
Merge to main (push) | deploy.yml → staging job | Staging: affected workers (of 15) + the app + staging migrations |
Merge to main (push) | deploy.yml → docs-build + docs-deploy jobs | Production docs sites (no staging variant exists): docs-public (Worker at www.podcasterplus.com/docs), docs-internal (Pages project podcasterplus-internal-docs) |
Manual workflow_dispatch | deploy-production.yml | Production: affected workers (of 16) + the app. Never migrations, never docs |
Production is a manual promotion (plan decision 3). Production migrations stay behind the six-step checklist in supabase/CLAUDE.md; the production workflow only warns when the promoted range contains migration files.
Affected-surface detection
scripts/affected-workers.mjs (unit-tested in scripts/__tests__/) decides what a diff actually requires deploying:
workers/<name>/**tree changes (includingwrangler.tomldrift, which redeploys live infrastructure) mark that worker.- Value-import tracing across the whole of
src/: each worker'ssrc/index.tsis resolved with esbuild (bundle metafile, the same resolution wrangler performs), so a change touching onlysrc/api/utils/r2-presign.tsmarksmedia-processor.import typebundles nothing and is correctly ignored. - The embed player directory (
src/lib/components/embed-player/) andvite.player.config.tsmarkpublic-api, whose deploy builds the player bundle. A test asserts the player's imports never leave that directory; if it ever fails, widen the rule. media-deliveryandpublic-apiare coupled: either one affected deploys both, media-delivery first (the?dl=colo-poisoning invariant).- Any
src/,static/, or root build-config change marks the app. - A change to the pipeline itself (the script, the two workflows, the setup action) deploys everything.
- Everything else is reported
inert, never silently dropped.
Deploy order is fixed: media-delivery → public-api → rss-feed → the rest → the app last (it consumes service bindings to rss-feed/public-api).
The deployed/* tags
The diff base is the last successfully deployed SHA, recorded by three force-moved tags: deployed/staging, deployed/docs, deployed/production. A failed run does not move its tag, so the next run re-diffs from the last good deploy and self-heals whatever the failure missed. A scope=only production deploy deliberately does not move the tag.
First runs: staging and docs auto-baseline by deploying everything; production refuses affected until a baseline exists — run it once with scope=all.
🔴 Hazards
Never put
[skip ci]in a merge commit. It suppresses ALL push workflows includingdeploy.yml, so the merge silently does not deploy staging. The pre-W6 habit of[skip ci]on squash merges must not return.The smoke tests pin a real slug (
nathan-in-india) on both feed hostnames. If that podcast is renamed or deleted, update the slug in both workflow files.Secrets are not deployed by the pipeline. Worker secrets stay attached to each worker across deploys (
wrangler secret put, per the matrix in Staging Environment). A NEW worker or NEW secret is still a manual provisioning step before its first deploy.The production workflow verifies the DR route-binding invariant (route patterns, never Custom Domains) after any deploy touching
rss-feedormedia-delivery, per.claude/rules/backend/workers.md.The app build needs more heap than Node gives it by default. Both
Deploy the appsteps setNODE_OPTIONS: --max-old-space-size=6144, matchingci.yml's build and typecheck jobs. Without it the SvelteKit build dies partway throughrendering chunkswith exit code 134 andFATAL ERROR: Ineffective mark-compacts near heap limit.This bites hardest when only one side has the ceiling: on 2026-08-07 CI was green and the staging deploy of the same commit died, because the ceiling had been added to
ci.ymland the deploy workflows were not swept for the same step. If you raise it in one place, raise it in all three.The break-glass path is affected too. A local
pnpm run deployorpnpm run deploy:stagingruns the identical build, so export the same value first if your Node default is lower than the build needs:bashNODE_OPTIONS=--max-old-space-size=6144 pnpm run deploy:staging
One-time setup (GitHub repository secrets)
| Secret | Value | Notes |
|---|---|---|
CLOUDFLARE_API_TOKEN | Custom Cloudflare API token (min.im account) | Scopes below |
STAGING_DB_URL | Staging branch session-pooler URI, port 5432 | GitHub runners are IPv4-only and the branch's direct host is IPv6-only, so the pooler form (postgres.ncjbvoyalhkgqdvzltqh@…pooler.supabase.com:5432, password percent-encoded) is required |
CLAUDE_API_KEY | Anthropic API key for the security review | Moved at W6 from the GitHub environment of the same name (delete that environment) to a plain repository secret. The review is label-gated and NOT automatic: see Security Review |
Cloudflare token scopes (account min.im / b8eff1b484adf398bda38644efc85bce):
- Account → Workers Scripts: Edit (covers worker uploads, versions, workflows)
- Account → Queues: Edit (deploys register queue producers/consumers)
- Account → Cloudflare Pages: Edit (docs-internal)
- Account → Containers: Edit (media-processor's container image push)
- Account → Account Settings: Read
- Zone → Workers Routes: Edit on
podcasterplus.com,show.fm,cdn.mediaandshowfm.dev(staging;podcasterplus.devretired at GATE-9) - Zone → Zone: Read on both zones (zone_name resolution + the route-binding verification)
The first staging run validates the token end to end: a 403 from wrangler names the missing scope — extend the token rather than working around it.
Keeping the token away from dependency code
Three rules hold this together. All three were added on 2026-08-19 after an audit, and a change that quietly undoes any of them re-opens a path from one compromised npm package to a production Worker deploy.
No step that installs or builds may have a secret in its
env. The staging and production jobs already worked this way (install viasetup-projectwith nothing in scope, then run wrangler separately). The docs job did not: it ranpnpm installand the whole VitePress build inside a step carryingCLOUDFLARE_API_TOKEN.Docs build and docs deploy are separate JOBS, not separate steps. Separate steps were not enough: they share a filesystem and a
node_modules, so a dependency executing duringvitepress buildcould overwritenode_modules/.bin/wranglerand have the tampered binary run moments later with the token exported.docs-deploytherefore starts on a clean runner, takes only the built static output as an artifact, and installs only wrangler into$RUNNER_TEMP/wrangler-cli. It never installs either docs dependency tree.The deploy-only wrangler comes from a committed lockfile, whole tree.
npx wranglerin the docs packages used to resolve whatever was latest on npm and run it with the token, on every docs deploy. Pinning wrangler's own version was not sufficient either: a barenpm install [email protected]still resolves wrangler's transitive tree fresh, and wrangler importsunenvduring a deploy, which declarespathe: ^2.0.3. So.github/wrangler-cli/holds a deploy-onlypackage.jsonplus a committedpackage-lock.json, installed withnpm ci, which pins all 91 packages with integrity hashes.Three pins must stay in lockstep:
.github/wrangler-cli,docs-publicanddocs-internal(the latter two run wrangler from their own tree for manual deploys). The install step fails on drift, and fails on any non-exact specifier, before it installs anything. There should be nonpx wrangleranywhere in.github/workflows/.
Related: both deploy.yml checkouts set persist-credentials: false, because the workflow has contents: write and actions/checkout otherwise leaves a push-capable credential in .git/config for every install to read. The four git operations that need auth build the remote from GH_TOKEN/GH_REPO env vars. Note the two git fetch calls are wrapped in || true, so if that auth ever breaks they fail silently and degrade affected-surface detection to "deploy everything" with nothing reporting it.
The runner CI jobs are covered separately in CI Runner.
Rollback
Workers retain the last 100 versions: npx wrangler rollback (per worker directory, --env staging for staging) promotes a prior version without a build. The docs Pages project supports instant rollback from the dashboard. The rehearsed runbook and drill are W7.