ci: purge the Cloudflare cache after each site deploy - #1189
Merged
Conversation
webjs.dev's static assets are served with max-age=14400 at stable urls, so after a deploy the edge keeps serving the previous copy for up to four hours. That shipped two visible regressions in one day (a pre-redesign tailwind.css after #1179, then the un-fixed logo marks after #1185), each cleared by a manual dashboard purge somebody had to remember. Staleness is also per-asset rather than all-or-nothing, so the site can sit half-updated with no signal. The workflow deliberately does not purge on push. Railway auto-deploys from the same push and the build takes minutes, so an immediate purge would evict the cache while the origin still served the old bytes and the next visitor would repopulate the edge with exactly those bytes, leaving the site stale for another four hours and burning the run for nothing. Instead it polls /__webjs/version (#239) on the Railway origin, bypassing the cache it is about to purge, until the reported uptime is lower than the elapsed time since the run began, which proves the restart belongs to THIS push rather than some earlier deploy. On timeout it fails loudly instead of purging. Purge is zone-wide: all four hostnames are proxied in the one webjs.dev zone, so a single call covers them and cannot silently miss an asset the way a hand-maintained path list does. curl uses --fail-with-body and the response is asserted on .success, since a bare curl exits 0 on a 4xx and would report a rejected purge as a success. workflow_dispatch is wired for on-demand purges, which is what the two incidents above actually needed. Closes #1188
Self-review of the new workflow turned up four real defects, all in the
wait step's failure handling.
1. Under `set -e`, `uptime=$(... | jq ...)` aborts the whole step when jq
fails, so a single non-JSON body from the origin (exactly what a
mid-deploy error page returns) turned a transient blip into a hard
failure instead of another retry. Every extraction now ends in
`|| true`, and the numeric comparison defaults to false.
2. The loop was 60 iterations of (10s curl + 15s sleep), a 25 minute
worst case against `timeout-minutes: 25`, so the job-level kill could
fire before the step printed why it gave up. The loop is now
deadline-driven on an explicit 15 minute WAIT_BUDGET, which holds
whatever the per-request latency turns out to be, leaving 10 minutes
of headroom for the diagnostic.
3. The verify step reported "Edge matches the origin" when EITHER side
sent no ETag, and Cloudflare strips ETags under some zone settings, so
the reassuring message was reachable with no comparison behind it. A
missing header is now reported as inconclusive.
4. The job declared no `permissions`, inheriting the default token scopes
despite never checking out the repo or calling the GitHub API. Now
`permissions: {}`.
Verified by running the real wait step against stub origins: the happy
path (uptime below elapsed) exits 0 after detecting the restart, and a
dead origin exits 1 with the refuse-to-purge guidance.
vivek7405
marked this pull request as ready for review
July 30, 2026 09:44
This was referenced Jul 30, 2026
vivek7405
added a commit
that referenced
this pull request
Jul 30, 2026
* ci: gate the CDN purge on the real Railway deployment status The first version inferred the deploy by watching the origin's /__webjs/version uptime for a restart, on the assumption that every push to main produces a Railway deploy. It does not. Railway marked #1189's own merge commit SKIPPED (it touched only .github and a .md), so no restart ever happened, the wait ran its full 15 minute budget, and the job failed. Left alone it would red-X every docs-only and CI-only commit, and a job that cries wolf on routine commits stops being read. Ask Railway instead of guessing. Its GraphQL API returns, per deployment, both a status and the originating meta.commitHash, so the job now resolves the deployment for github.sha and branches on the real status: SUCCESS purges, SKIPPED exits 0 with a notice (nothing was deployed, so nothing is stale), FAILED / CRASHED / REMOVED exit 0 with a warning (no new content reached the origin), anything in progress keeps polling. An unresolved deployment now warns and leaves the run GREEN rather than failing, which is the whole point. The purge and verify steps become conditional on that outcome, and workflow_dispatch still purges immediately with no wait. Two API details worth keeping: Railway sits behind Cloudflare, which answers `error code: 1010` to automated-looking user agents (python urllib is blocked outright, curl is fine), and a GraphQL error arrives with HTTP 200 plus an `errors` array, so the status code alone proves nothing. Token type is not knowable ahead of time either, since a project token uses Project-Access-Token and an account token uses Authorization: Bearer, so both are tried. Verified by exercising the extraction and branching against the real response shape for both known commits: 93883bb resolves to SKIPPED (the commit that broke the previous version, now a clean no-op) and 086b3c7 to SUCCESS. Closes #1192 * ci: fail loudly on a bad Railway token, parse either meta shape Review of the new gating step found two defects of the same class: both turned a misconfiguration into a GREEN run that silently never purges, which is precisely the failure the workflow exists to catch. 1. `meta` may arrive as an object or as a JSON-encoded string, and this was not verifiable against the live API beforehand. Indexing a string with `.commitHash` is a jq ERROR rather than a null, so the unguarded filter would have swallowed every response, polled to the deadline and reported green without ever purging. Normalize both shapes, and leave a null `meta` resolving to empty so it keeps polling instead of crashing. 2. A wrong, expired, or mis-scoped RAILWAY_TOKEN produced an auth error on every poll, which read as "no deployment yet" and ended in the same green no-op. Track whether ANY clean response ever arrived: none means the credential or endpoint is wrong, so exit 1 and print the server's own error body (never the token). "Answered fine but the sha never reached a terminal status" stays a warning and stays green, which is the case #1192 was about. Also dump the deployments actually seen on the timeout path, so a field-name or response-shape mismatch is diagnosable in the log instead of looking identical to "the deploy never happened". Verified: object, string, and null `meta` all resolve without a jq crash, and the bad-token path exits 1 carrying the API's message. * ci: make the purge diagnostic name the missing field in full The timeout-path dump sliced its own fallback string to eight characters, so a response with no commitHash printed "<no comm". That line exists to make a field-name or shape mismatch obvious at a glance, and a truncated placeholder is exactly the confusion it was added to remove. Slice only when a sha is present.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
.github/workflows/purge-cdn.yml, which evicts the Cloudflare edge cache after each site deploy, plus aworkflow_dispatchtrigger for on-demand purges.webjs.devserves its static assets withcache-control: public, max-age=14400at stable urls, so the edge keeps the previous copy for up to four hours after a deploy. That caused two visible regressions in one day (staletailwind.cssafter #1179, stale logo marks after #1185), each cleared by a manual dashboard purge.The workflow waits before purging, and that is the point. Railway auto-deploys from the same push and the build takes minutes. Purging immediately would evict the cache while the origin still serves the OLD bytes, the next visitor repopulates the edge with those bytes, and the site is stale for another four hours with nothing to show for the run. So the job polls
GET /__webjs/version(#239) on the Railway origin (bypassing the cache it is about to purge) until the reporteduptimeis lower than the elapsed time since the run began, which proves the restart belongs to this push. On timeout it fails loudly rather than purging.Purge is zone-wide: all four hostnames (
webjs.dev,example-blog,docs,ui) are proxied inside the onewebjs.devzone, so one call covers them all and cannot silently miss an asset the way a path list does.Action required before this is useful
Add a repo secret
CLOUDFLARE_API_TOKEN, scoped to Zone / Cache Purge / Purge on thewebjs.devzone only (not an account-wide token). Without it the purge step fails with an explicit message rather than passing silently.Test plan
runblock passesbash -nuptime=459s, elapsed 5s and 100s correctly evaluate "not yet deployed", a large elapsed evaluates "deployed"webjs.dev)mainNote on the permanent fix
tailwind.cssand the brand marks are referenced by hand-written urls inapp/layout.tsandlib/design/brand.ts, so the framework's content-hash?v=fingerprinting (#243) never touches them. Fingerprinting those urls would remove the need for a purge entirely. That is a separate, larger change; this workflow is worth having regardless as the safety net for anything on a stable url. Documented inframework-dev.md.Closes #1188