Skip to content

ci: purge the Cloudflare cache automatically after each site deploy #1188

Description

@vivek7405

Problem

Static assets on https://webjs.dev are served with cache-control: public, max-age=14400 at STABLE urls (/public/tailwind.css, /public/brand/*.svg),
so after a deploy Cloudflare keeps serving the previous copy for up to four
hours. This has now caused two separate user-visible incidents in one day:

  1. After feat(website): world-class brand and design system #1179 merged, webjs.dev served a pre-redesign tailwind.css while
    the Railway origin served the new one. The site looked broken (missing
    horizontal padding, wrong logo variant in dark theme) for anyone on the
    cached copy. Verified by diffing the Cloudflare-served bytes against the
    origin bytes, which differed.
  2. After fix: replace the wordmark's SVG mask with plain filled paths #1185 merged (the SVG mask fix for the mobile Safari logo bug), the
    fixed marks again sat behind a stale cached copy.

Each time the remedy was a manual dashboard purge, which is easy to forget and
is invisible to whoever merged the PR.

Design / approach

Add a GitHub Actions workflow that purges the Cloudflare cache automatically
after each deploy of the site, plus a workflow_dispatch trigger so a purge
can be run on demand without the dashboard.

The ordering is the part that matters and the part a naive implementation gets
wrong. Railway auto-deploys from a push to main (RAILPACK builder, source
repo webjsdev/webjs, confirmed in the service config), and that build takes
minutes. A workflow that purges immediately on push races the deploy: it
evicts the cache while the origin is still serving OLD bytes, the next visitor
repopulates the cache with those old bytes, and the site is now stale for
another four hours with no signal that anything went wrong. So the workflow
MUST confirm the new code is live on the ORIGIN before purging.

Detection uses the existing GET /__webjs/version endpoint (#239), which
returns { version, build, node, uptime }. uptime resets when Railway
restarts the process for a new deploy, so the workflow records its own start
time and polls the Railway origin (bypassing Cloudflare) until uptime is
LESS than the elapsed time since the run began. That proves the restart
happened after this push rather than matching some earlier deploy. build is
also a content fingerprint (sorted relpath:contentHash lines plus the server
version, see packages/server/src/importmap.js), so it is logged as a
secondary signal.

Purge is zone-wide (purge_everything), not a file list. All four site
hostnames (webjs.dev, example-blog.webjs.dev, docs.webjs.dev,
ui.webjs.dev) are Cloudflare-proxied inside the one webjs.dev zone
(verified: all four return a cf-ray header), so one call covers every host,
and a zone-wide purge cannot miss an asset the way a hand-maintained file list
can. Purging evicts only, it deletes nothing, and the next request refetches
from the origin.

Note the more permanent fix, deliberately NOT in scope here: tailwind.css
and the brand SVGs are referenced by hand-written urls in
website/app/layout.ts and lib/design/brand.ts, so the framework's
content-hash ?v= fingerprinting (#243), which already covers module imports
and framework-emitted urls, never touches them. Giving those author-written
urls a content hash would make every deploy serve a NEW url and remove the
need for a purge entirely. That is a larger change to how the layout emits
asset urls and should be its own issue. The purge workflow is worth having
regardless, as the safety net for anything that stays on a stable url.

Implementation notes (for the implementing agent)

  • Where to add: a NEW workflow, .github/workflows/purge-cdn.yml. Do not
    fold this into ci.yml (which runs on pull_request too, where there is no
    deploy to track) or release.yml (which fires only on a changelog/**
    push, an npm package release, NOT a site deploy; the site redeploys on
    EVERY push to main).
  • Triggers: push to main, plus workflow_dispatch for a manual purge.
  • Steps:
    1. Record date +%s at the start.
    2. Poll https://webjs.up.railway.app/__webjs/version (the Railway origin
      for the @webjsdev/website service, so Cloudflare is bypassed) until
      uptime < elapsed_seconds, with a timeout of about 15 minutes and a
      sleep of about 15 seconds between attempts.
    3. On timeout, FAIL the job with an explicit message rather than purging.
      Purging against an origin that has not finished deploying is the exact
      failure this workflow exists to prevent, so a loud failure that prompts
      a manual purge is the correct outcome.
    4. POST https://api.cloudflare.com/client/v4/zones/<zone>/purge_cache
      with {"purge_everything":true} and
      Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}.
    5. Assert .success == true in the JSON response and fail the job if not.
      A non-2xx status or success:false must not pass silently.
  • Zone id is 46692b879a06f3a6a987b99915560393 (the webjs.dev zone). A zone
    id is not a secret, so hardcode it in the workflow with a comment rather
    than adding a second required secret; only the token needs to be a secret.
  • Secret to add: CLOUDFLARE_API_TOKEN, scoped to Zone / Cache Purge / Purge
    for the webjs.dev zone only. Do NOT reuse a broad account-wide token.
  • Landmines: curl must use --fail-with-body or an explicit status check,
    because a bare curl exits 0 on a 4xx and the job would report success
    after a rejected purge. Cloudflare rate-limits purge_everything (about
    1000 per day per zone), far above the merge rate here, but that is why the
    workflow must not run on pull_request. Poll the RAILWAY origin, not
    webjs.dev, otherwise the detection request can itself be served from the
    cache being tested.
  • Invariants: none affected (CI only, no runtime or public API surface).
  • Tests + docs: no unit test applies to a workflow file. Validate the YAML
    parses, and verify the purge call shape against the live API before merging
    (a real purge is safe and idempotent). Document the new secret and the
    manual workflow_dispatch escape hatch in framework-dev.md, next to the
    existing deploy notes, so the next person knows the purge exists and how to
    run it by hand.

Acceptance criteria

  • .github/workflows/purge-cdn.yml runs on push to main and on
    workflow_dispatch
  • The workflow waits for the new build to be live on the Railway origin
    before purging, and fails loudly rather than purging on timeout
  • The purge call fails the job on a non-success response from Cloudflare
  • Zone-wide purge, covering all four proxied hostnames in one call
  • framework-dev.md documents the required CLOUDFLARE_API_TOKEN secret,
    its required scope, and the manual purge path
  • Verified end to end on a real merge to main, confirming the
    Cloudflare-served bytes match the origin bytes right after the run

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions