Skip to content

🔧 Infra · 🧹 Purge Cloudflare Cache #197

🔧 Infra · 🧹 Purge Cloudflare Cache

🔧 Infra · 🧹 Purge Cloudflare Cache #197

name: '🔧 Infra · 🧹 Purge Cloudflare Cache'
# =============================================================================
# 🧹 Purge Cloudflare Cache — Bust the mlsysbook.ai edge cache after deploys
# =============================================================================
#
# mlsysbook.ai sits behind Cloudflare, in front of GitHub Pages. After a
# *-publish-live.yml workflow ships new content to the `gh-pages` branch,
# Cloudflare's edge can keep serving stale HTML (and stale 404s for newly
# added assets) until its TTL rolls over — typically minutes to hours,
# depending on the path. This workflow purges the edge cache as soon as
# any production deploy completes, so changes are visible immediately.
#
# Why chained off `workflow_run`, not `push: gh-pages`?
# GitHub Actions deliberately suppresses workflow triggers from commits
# pushed using the default GITHUB_TOKEN, to prevent recursion. All 9
# *-publish-live.yml workflows push to gh-pages with GITHUB_TOKEN, so
# `on: push: branches: [gh-pages]` would never fire from them. The
# `workflow_run` trigger sidesteps that restriction by chaining off
# the upstream workflow's completion event instead. We also listen for
# GitHub's generated `pages-build-deployment` run so the edge cache is
# purged after Pages has finished publishing the new artifact, not only
# after the workflow has pushed to `gh-pages`.
#
# Why purge the whole zone, not specific paths?
# Cloudflare's "purge by hostname" mode is Enterprise-only. The Pro/Free
# tiers support "purge everything" (zone-wide) or "purge by URL list"
# (up to 30 URLs per request). Maintaining a hand-curated URL list per
# product (9 of them) is more error-prone than the cost of re-warming
# the zone — most mlsysbook.ai content is static and re-caches on the
# next request. "Purge everything" is the simpler, more robust default.
#
# Flow:
# 1. GATE — Skip unless the upstream deploy ran on `main` (live)
# and concluded successfully.
# 2. RESOLVE — Read the Cloudflare zone id from a repo variable
# (vars.CLOUDFLARE_ZONE_ID, not a secret — zone ids
# aren't sensitive). If the variable is absent, resolve
# the zone id from Cloudflare by hostname.
# 3. PURGE — POST to Cloudflare's purge_cache API with
# {"purge_everything": true}.
# 4. REPORT — Print the API response so failures show in the run log.
#
# Triggers:
# - workflow_run: completion of any *-publish-live.yml workflow or Pages
# deployment
# - workflow_dispatch: manual purge (e.g., after a hand-fix to gh-pages)
#
# Configuration:
# - secrets.CLOUDFLARE_CACHE_PURGE_TOKEN
# — Dedicated token scoped to Zone.Cache
# Purge on the mlsysbook.ai zone.
# Keep separate from deploy tokens used
# for D1/Worker shipments.
# - vars.CLOUDFLARE_ZONE_ID — Optional zone id for mlsysbook.ai.
# - secrets.CLOUDFLARE_ZONE_ID — Optional fallback if the zone id is
# stored as a repository secret instead
# of a repository variable.
# If absent, the workflow resolves it
# through Cloudflare's zone lookup API.
# Automatic workflow_run triggers warn and skip when configuration is
# incomplete; manual workflow_dispatch purges still fail fast.
#
# Related:
# - book-publish-live.yml — Upstream deploy (book content)
# - kits-publish-live.yml — Upstream deploy (hardware kits)
# - tinytorch-publish-live.yml — Upstream deploy (TinyTorch site)
# - mlsysim-publish-live.yml — Upstream deploy (MLSysIM docs)
# - labs-publish-live.yml — Upstream deploy (Marimo labs)
# - staffml-publish-live.yml — Upstream deploy (StaffML interview app)
# - slides-publish-live.yml — Upstream deploy (slide decks)
# - site-publish-live.yml — Upstream deploy (landing site)
# - instructors-publish-live.yml — Upstream deploy (instructor materials)
# - mlperf-edu-publish-live.yml — Upstream MLPerf EDU independent preview
#
# =============================================================================
permissions:
contents: read
concurrency:
# Serialize purges so a burst of back-to-back deploys collapses into a
# single trailing purge rather than racing the Cloudflare API.
group: cloudflare-purge
cancel-in-progress: false
on:
workflow_run:
workflows:
- '📚 Book · 🚀 Publish (Live)'
- '📦 Kits · 🚀 Publish (Live)'
- '🔥 TinyTorch · 🚀 Publish (Live)'
- '🧮 MLSYSIM · 🚀 Publish (Live)'
- '🔮 Labs · 🚀 Publish (Live)'
- '🎯 StaffML · 🚀 Publish (Live)'
- '📊 Slides · 🚀 Publish (Live)'
- '🌐 Landing Site · 🚀 Publish (Live)'
- '🎓 Instructors · 🚀 Publish (Live)'
- 'MLPerf EDU · Publish Independent Preview (Live)'
- 'pages-build-deployment'
types: [completed]
workflow_dispatch:
inputs:
reason:
description: 'Why is a manual purge needed? (logged in run summary)'
required: false
default: 'Manual cache flush'
jobs:
purge:
name: '🧹 Purge mlsysbook.ai edge cache'
runs-on: ubuntu-latest
timeout-minutes: 5
# On workflow_run, only purge if the upstream actually succeeded.
# `workflow_run` fires on ALL completions (success, failure, cancelled),
# so without this gate we'd uselessly purge after failed deploys too.
# Manual workflow_dispatch always runs (no upstream event to gate on).
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
- name: '📝 Log purge trigger'
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "Triggered by: ${{ github.event.workflow_run.name }} (#${{ github.event.workflow_run.run_number }})"
echo "Upstream conclusion: ${{ github.event.workflow_run.conclusion }}"
echo "Upstream run: ${{ github.event.workflow_run.html_url }}"
else
echo "Triggered manually."
echo "Reason: ${{ inputs.reason }}"
fi
- name: '✅ Verify required configuration'
id: config
env:
CLOUDFLARE_CACHE_PURGE_TOKEN: ${{ secrets.CLOUDFLARE_CACHE_PURGE_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_ZONE_ID_SECRET: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
missing=0
if [ -z "$CLOUDFLARE_CACHE_PURGE_TOKEN" ]; then
echo "::warning::secrets.CLOUDFLARE_CACHE_PURGE_TOKEN is not set. Skipping automatic Cloudflare cache purge."
missing=1
fi
CLOUDFLARE_ZONE_ID="${CLOUDFLARE_ZONE_ID:-${CLOUDFLARE_ZONE_ID_SECRET:-}}"
if [ -z "$CLOUDFLARE_ZONE_ID" ] && [ "$missing" -eq 0 ]; then
echo "CLOUDFLARE_ZONE_ID is not set; resolving zone id for mlsysbook.ai."
response=$(curl --silent --show-error --fail-with-body \
--get \
--url "https://api.cloudflare.com/client/v4/zones" \
--header "Authorization: Bearer ${CLOUDFLARE_CACHE_PURGE_TOKEN}" \
--data-urlencode "name=mlsysbook.ai" \
--data-urlencode "status=active")
CLOUDFLARE_ZONE_ID=$(echo "$response" | python3 -c "import json, sys; data = json.load(sys.stdin); zones = data.get('result') or []; print(zones[0].get('id', '') if zones else '')")
fi
if [ -z "$CLOUDFLARE_ZONE_ID" ]; then
echo "::warning::Cloudflare zone id for mlsysbook.ai could not be resolved. Skipping automatic Cloudflare cache purge."
missing=1
fi
if [ "$missing" -eq 0 ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
echo "zone_id=$CLOUDFLARE_ZONE_ID" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "configured=false" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
exit 1
fi
- name: '🧹 Purge Cloudflare cache'
if: ${{ steps.config.outputs.configured == 'true' }}
env:
CLOUDFLARE_CACHE_PURGE_TOKEN: ${{ secrets.CLOUDFLARE_CACHE_PURGE_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ steps.config.outputs.zone_id }}
run: |
set -euo pipefail
response=$(curl --silent --show-error --fail-with-body \
--request POST \
--url "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
--header "Authorization: Bearer ${CLOUDFLARE_CACHE_PURGE_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"purge_everything": true}')
echo "$response" | python3 -m json.tool
# Cloudflare returns { "success": true, ... } on success; the curl
# --fail-with-body flag already catches HTTP non-2xx, but we double-
# check the JSON body in case the API returns 200 with success=false
# (rare but documented).
echo "$response" | python3 -c "import sys, json; data = json.load(sys.stdin); sys.exit(0 if data.get('success') else 1)"