You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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:
Record date +%s at the start.
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.
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.
POST https://api.cloudflare.com/client/v4/zones/<zone>/purge_cache
with {"purge_everything":true} and Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}.
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
Problem
Static assets on https://webjs.dev are served with
cache-control: public, max-age=14400at 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:
webjs.devserved a pre-redesigntailwind.csswhilethe 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.
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_dispatchtrigger so a purgecan 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(RAILPACKbuilder, sourcerepo
webjsdev/webjs, confirmed in the service config), and that build takesminutes. 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/versionendpoint (#239), whichreturns
{ version, build, node, uptime }.uptimeresets when Railwayrestarts the process for a new deploy, so the workflow records its own start
time and polls the Railway origin (bypassing Cloudflare) until
uptimeisLESS than the elapsed time since the run began. That proves the restart
happened after this push rather than matching some earlier deploy.
buildisalso a content fingerprint (sorted
relpath:contentHashlines plus the serverversion, see
packages/server/src/importmap.js), so it is logged as asecondary signal.
Purge is zone-wide (
purge_everything), not a file list. All four sitehostnames (
webjs.dev,example-blog.webjs.dev,docs.webjs.dev,ui.webjs.dev) are Cloudflare-proxied inside the onewebjs.devzone(verified: all four return a
cf-rayheader), 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.cssand the brand SVGs are referenced by hand-written urls in
website/app/layout.tsandlib/design/brand.ts, so the framework'scontent-hash
?v=fingerprinting (#243), which already covers module importsand 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)
.github/workflows/purge-cdn.yml. Do notfold this into
ci.yml(which runs on pull_request too, where there is nodeploy to track) or
release.yml(which fires only on achangelog/**push, an npm package release, NOT a site deploy; the site redeploys on
EVERY push to main).
pushtomain, plusworkflow_dispatchfor a manual purge.date +%sat the start.https://webjs.up.railway.app/__webjs/version(the Railway originfor the
@webjsdev/websiteservice, so Cloudflare is bypassed) untiluptime < elapsed_seconds, with a timeout of about 15 minutes and asleep of about 15 seconds between attempts.
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.
POST https://api.cloudflare.com/client/v4/zones/<zone>/purge_cachewith
{"purge_everything":true}andAuthorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}..success == truein the JSON response and fail the job if not.A non-2xx status or
success:falsemust not pass silently.46692b879a06f3a6a987b99915560393(thewebjs.devzone). A zoneid 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.
CLOUDFLARE_API_TOKEN, scoped to Zone / Cache Purge / Purgefor the
webjs.devzone only. Do NOT reuse a broad account-wide token.curlmust use--fail-with-bodyor an explicit status check,because a bare
curlexits 0 on a 4xx and the job would report successafter a rejected purge. Cloudflare rate-limits
purge_everything(about1000 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, notwebjs.dev, otherwise the detection request can itself be served from thecache being tested.
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_dispatchescape hatch inframework-dev.md, next to theexisting deploy notes, so the next person knows the purge exists and how to
run it by hand.
Acceptance criteria
.github/workflows/purge-cdn.ymlruns on push tomainand onworkflow_dispatchbefore purging, and fails loudly rather than purging on timeout
framework-dev.mddocuments the requiredCLOUDFLARE_API_TOKENsecret,its required scope, and the manual purge path
main, confirming theCloudflare-served bytes match the origin bytes right after the run