Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/workflows/purge-cdn.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: Purge CDN

# Evicts the Cloudflare edge cache after a site deploy reaches the origin.
#
# Why this exists: the site's static assets (/public/tailwind.css, the brand
# SVGs) are served with `cache-control: public, max-age=14400` at STABLE urls,
# so without a purge Cloudflare keeps serving the previous copy for up to four
# hours after a deploy. That shipped two visible regressions in one day (a
# pre-redesign stylesheet after #1179, then the un-fixed logo marks after
# #1185), each needing a manual dashboard purge somebody had to remember.
#
# Why it waits instead of purging on push: Railway auto-deploys from a push to
# main and the build takes minutes. Purging immediately would evict the cache
# while the origin still serves the OLD bytes, the next visitor would
# repopulate the cache with exactly those old bytes, and the site would be
# stale for another four hours with nothing to show for the run. So the job
# confirms the new build is live on the ORIGIN first, and fails loudly rather
# than purging if it never shows up.
#
# Required secret: CLOUDFLARE_API_TOKEN, scoped to Zone / Cache Purge / Purge
# on the webjs.dev zone only (not an account-wide token). See framework-dev.md.
#
# Manual purge: run this workflow from the Actions tab (workflow_dispatch),
# which skips the deploy wait and purges immediately.

on:
push:
branches: [main]
workflow_dispatch:

# Nothing here reads or writes repo contents (there is no checkout), so the
# job needs no GITHUB_TOKEN scopes at all.
permissions: {}

# A newer push supersedes an older in-flight wait. Never cancel mid-purge: a
# run cancelled after it already purged would report as failed and hide a
# successful eviction.
concurrency:
group: purge-cdn
cancel-in-progress: false

jobs:
purge:
name: Wait for the deploy, then purge
runs-on: ubuntu-latest
# Comfortably above the wait step's own 15-minute budget, so the step's
# explanatory failure always fires before the job-level kill (which would
# leave no diagnosis in the log).
timeout-minutes: 25
env:
# A zone id identifies a domain, it is not a credential, so it lives in
# the workflow rather than costing a second required secret.
ZONE_ID: 46692b879a06f3a6a987b99915560393
# The Railway service domain for @webjsdev/website. Polled directly so
# the readiness check bypasses the very cache this job is about to
# purge (asking webjs.dev could be answered from the edge).
ORIGIN: https://webjs.up.railway.app
# How long to wait for the deploy before giving up, in seconds.
WAIT_BUDGET: 900

steps:
- name: Wait for the new build to be live on the origin
if: github.event_name == 'push'
run: |
set -euo pipefail
started=$(date +%s)
deadline=$(( started + WAIT_BUDGET ))

# /__webjs/version (#239) reports {version, build, node, uptime}.
# `uptime` resets when Railway restarts the process for a deploy, so
# "uptime < seconds elapsed since this run began" proves the restart
# happened AFTER this push, rather than matching some earlier deploy
# that was already live when the run started.
#
# The loop is deadline-driven rather than a fixed iteration count so
# the budget stays 15 minutes whatever the per-request latency is; a
# count multiplied by (curl timeout + sleep) can silently drift past
# the job's own timeout and lose the diagnostic message below.
echo "Waiting up to $(( WAIT_BUDGET / 60 ))m for a process restart on $ORIGIN newer than this run..."
while [ "$(date +%s)" -lt "$deadline" ]; do
elapsed=$(( $(date +%s) - started ))
# `|| true` on every extraction: under `set -e` a failing jq (a
# non-JSON body from a mid-deploy error page) would abort the step
# at the assignment instead of retrying, turning a transient blip
# into a hard failure.
body=$(curl -fsS --max-time 10 "$ORIGIN/__webjs/version" 2>/dev/null || true)
uptime=$(printf '%s' "$body" | jq -r '.uptime // empty' 2>/dev/null || true)
build=$(printf '%s' "$body" | jq -r '.build // empty' 2>/dev/null || true)

if [ -n "$uptime" ]; then
if [ "$(jq -n --argjson u "$uptime" --argjson e "$elapsed" '$u < $e' 2>/dev/null || echo false)" = "true" ]; then
echo "Origin restarted ${uptime}s ago, within the ${elapsed}s since this run began."
echo "Live build: $build"
exit 0
fi
echo " origin uptime ${uptime}s >= elapsed ${elapsed}s, deploy not live yet"
else
echo " origin not answering with a version yet (a mid-deploy restart looks like this)"
fi
sleep 15
done

echo "::error::No new deploy observed on $ORIGIN within $(( WAIT_BUDGET / 60 ))m."
echo "Refusing to purge: evicting the cache while the origin still serves the"
echo "previous build would repopulate the edge with stale bytes, which is the"
echo "exact failure this workflow exists to prevent."
echo "If the deploy landed later, re-run this workflow from the Actions tab to purge."
exit 1

- name: Purge the Cloudflare cache
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then
echo "::error::CLOUDFLARE_API_TOKEN is not set. Add it as a repository secret,"
echo "scoped to Zone / Cache Purge / Purge on the webjs.dev zone."
exit 1
fi

# Zone-wide rather than a file list. Every site hostname
# (webjs.dev, example-blog / docs / ui .webjs.dev) is proxied inside
# this one zone, so a single call covers them all, and a zone purge
# cannot silently miss an asset the way a hand-maintained path list
# does. Purging evicts only; nothing is deleted, and the next
# request refetches from the origin.
#
# --fail-with-body matters: a bare curl exits 0 on a 4xx, so without
# it a rejected purge (bad or expired token) would report success.
response=$(curl --fail-with-body -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}')

echo "$response" | jq .
if [ "$(printf '%s' "$response" | jq -r '.success' 2>/dev/null || echo false)" != "true" ]; then
echo "::error::Cloudflare rejected the purge."
exit 1
fi
echo "Cache purged for the webjs.dev zone."

- name: Confirm the edge now matches the origin
run: |
set -euo pipefail
# The purge is asynchronous across pops, so give it a moment before
# sampling. This is a smoke check on one asset, not a guarantee for
# every pop, which is why it warns rather than failing the job: the
# purge itself already succeeded and reported its own status.
sleep 20

# tailwind.css is the asset that actually broke in #1179, so it is
# the one worth sampling.
etag_of() {
curl -fsS -I --max-time 10 "$1" 2>/dev/null | tr -d '\r' \
| awk 'tolower($1)=="etag:"{print $2}' || true
}
origin_etag=$(etag_of "$ORIGIN/public/tailwind.css")
edge_etag=$(etag_of "https://webjs.dev/public/tailwind.css")

echo "origin etag: ${origin_etag:-<none>}"
echo "edge etag: ${edge_etag:-<none>}"

if [ -z "$origin_etag" ] || [ -z "$edge_etag" ]; then
# Cloudflare can strip ETags depending on zone settings, so a
# missing header is inconclusive, NOT proof the edge is correct.
echo "::warning::Could not compare ETags (one side did not send one)."
echo "This check is inconclusive; the purge above reported success."
elif [ "$origin_etag" != "$edge_etag" ]; then
echo "::warning::Edge and origin ETags still differ for /public/tailwind.css."
echo "This can be a slow pop rather than a real failure. Re-check with:"
echo " curl -sI https://webjs.dev/public/tailwind.css | grep -i 'etag\\|cf-cache-status'"
else
echo "Edge matches the origin."
fi
22 changes: 22 additions & 0 deletions framework-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ Net: edit the `HEALTHCHECK` for the Docker contract, keep `railway.json` for the

---

### CDN cache: purged automatically after each deploy

`webjs.dev` sits behind Cloudflare, and the site's static assets (`/public/tailwind.css`, the brand SVGs) are served with `cache-control: public, max-age=14400` at STABLE urls. Without an eviction the edge therefore keeps serving the PREVIOUS copy for up to four hours after a deploy. That shipped two visible regressions in one day (a pre-redesign stylesheet after #1179, then the un-fixed logo marks after #1185), and staleness is per-asset rather than all-or-nothing, so the site can look half-updated.

`.github/workflows/purge-cdn.yml` handles this on every push to `main`. It does NOT purge immediately: 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. Instead the job polls `GET /__webjs/version` (#239) on the Railway ORIGIN (bypassing the cache it is about to purge) until the reported `uptime` is lower than the time elapsed since the run began, which proves the process restarted for THIS push, then issues a zone-wide `purge_everything`. On timeout it fails loudly rather than purging, because a mistimed purge is the precise failure the workflow exists to prevent.

The purge is zone-wide rather than a path list: all four hostnames (`webjs.dev`, `example-blog.webjs.dev`, `docs.webjs.dev`, `ui.webjs.dev`) are proxied inside the one `webjs.dev` zone, so a single call covers them, and a zone purge cannot silently miss an asset the way a hand-maintained list does. Purging evicts only; nothing is deleted.

- **Required secret:** `CLOUDFLARE_API_TOKEN`, scoped to **Zone / Cache Purge / Purge** on the `webjs.dev` zone only. Do not reuse a broad account-wide token.
- **Manual purge:** run the "Purge CDN" workflow from the Actions tab (`workflow_dispatch`), which skips the deploy wait and purges straight away. Use this if a deploy landed after the wait timed out.
- **Checking staleness by hand:** compare the edge against the origin rather than trusting `cf-cache-status`, since a `HIT` on fresh content is fine and only differing bytes are a problem.

```sh
curl -s https://webjs.up.railway.app/public/tailwind.css -o /tmp/o
curl -s https://webjs.dev/public/tailwind.css -o /tmp/e
cmp /tmp/o /tmp/e && echo fresh || echo stale
```

The permanent fix for the assets themselves is a content hash in the url, the way the framework already fingerprints module imports and its own emitted urls (`?v=`, #243). `tailwind.css` and the brand marks are referenced by hand-written urls in `website/app/layout.ts` and `website/lib/design/brand.ts`, which that machinery never sees, so they stay on stable urls and depend on this purge. Fingerprinting them would retire the dependency; the workflow stays worthwhile as the safety net for anything else on a stable url.

---

### Repo health: worktree-safe git config (core.bare / hooksPath)

This repo uses git worktrees (the review subagents spawn throwaway ones under `.claude/worktrees/`). Git's worktree machinery can leave `core.bare=true` in the shared `.git/config`, which is lethal to the main checkout: every git operation that needs a work tree then fails with `fatal: this operation must be run in a work tree`. The shared value is harmless only while the main worktree carries a per-worktree override (`extensions.worktreeConfig=true` plus a `.git/config.worktree` pinning `core.bare=false`).
Expand Down