Skip to content

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.

TriggerWorkflowDeploys
Merge to main (push)deploy.ymlstaging jobStaging: affected workers (of 15) + the app + staging migrations
Merge to main (push)deploy.ymldocs-build + docs-deploy jobsProduction docs sites (no staging variant exists): docs-public (Worker at www.podcasterplus.com/docs), docs-internal (Pages project podcasterplus-internal-docs)
Manual workflow_dispatchdeploy-production.ymlProduction: 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 (including wrangler.toml drift, which redeploys live infrastructure) mark that worker.
  • Value-import tracing across the whole of src/: each worker's src/index.ts is resolved with esbuild (bundle metafile, the same resolution wrangler performs), so a change touching only src/api/utils/r2-presign.ts marks media-processor. import type bundles nothing and is correctly ignored.
  • The embed player directory (src/lib/components/embed-player/) and vite.player.config.ts mark public-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-delivery and public-api are 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-deliverypublic-apirss-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 including deploy.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-feed or media-delivery, per .claude/rules/backend/workers.md.

  • The app build needs more heap than Node gives it by default. Both Deploy the app steps set NODE_OPTIONS: --max-old-space-size=6144, matching ci.yml's build and typecheck jobs. Without it the SvelteKit build dies partway through rendering chunks with exit code 134 and FATAL 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.yml and 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 deploy or pnpm run deploy:staging runs the identical build, so export the same value first if your Node default is lower than the build needs:

    bash
    NODE_OPTIONS=--max-old-space-size=6144 pnpm run deploy:staging

One-time setup (GitHub repository secrets)

SecretValueNotes
CLOUDFLARE_API_TOKENCustom Cloudflare API token (min.im account)Scopes below
STAGING_DB_URLStaging branch session-pooler URI, port 5432GitHub 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_KEYAnthropic API key for the security reviewMoved 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.media and showfm.dev (staging; podcasterplus.dev retired 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.

  1. No step that installs or builds may have a secret in its env. The staging and production jobs already worked this way (install via setup-project with nothing in scope, then run wrangler separately). The docs job did not: it ran pnpm install and the whole VitePress build inside a step carrying CLOUDFLARE_API_TOKEN.

  2. 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 during vitepress build could overwrite node_modules/.bin/wrangler and have the tampered binary run moments later with the token exported. docs-deploy therefore 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.

  3. The deploy-only wrangler comes from a committed lockfile, whole tree.npx wrangler in 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 bare npm install [email protected] still resolves wrangler's transitive tree fresh, and wrangler imports unenv during a deploy, which declares pathe: ^2.0.3. So .github/wrangler-cli/ holds a deploy-only package.json plus a committed package-lock.json, installed with npm ci, which pins all 91 packages with integrity hashes.

    Three pins must stay in lockstep: .github/wrangler-cli, docs-public and docs-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 no npx wrangler anywhere 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.

Internal documentation - Not for public distribution