From bef53176f37fb9aa11fd2a1ff0696437541ec032 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 19 Jun 2026 13:57:43 -0700 Subject: [PATCH] ci: use dedicated CF_PURGE_TOKEN for edge purge; skip quietly if unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared CLOUDFLARE_API_TOKEN has R2 + Zone:Read but not Zone.Cache-Purge, so the purge step returned 401 and emitted a warning on every publish. Cache purge is optional anyway — `no-store` on the metadata (upload step) already keeps the edge from ever serving a stale index. Switch the purge to a dedicated CF_PURGE_TOKEN secret: if it's set, purge actively (and warn only if that token itself fails); if it's unset, skip with a one-line info instead of a 401 warning. To enable autonomous purge: create a Cloudflare API token scoped to Zone.Cache-Purge on the wheels.dev zone and add it as the CF_PURGE_TOKEN secret. Refs: wheels-dev/wheels#3218 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Peter Amiri --- .github/workflows/wheels-released.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/wheels-released.yml b/.github/workflows/wheels-released.yml index 724830d..8db0fa9 100644 --- a/.github/workflows/wheels-released.yml +++ b/.github/workflows/wheels-released.yml @@ -260,19 +260,25 @@ jobs: - name: Purge Cloudflare edge cache for the published files env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + # Cache purge is a SEPARATE Cloudflare permission (Zone.Cache-Purge) + # from R2 (account-scoped). The shared CLOUDFLARE_API_TOKEN only has + # R2 + Zone:Read, so purge with it returns 401. Use a dedicated + # CF_PURGE_TOKEN (Zone.Cache-Purge on the wheels.dev zone) if one is + # configured; otherwise skip — `no-store` on the metadata (set in the + # upload step) already guarantees the edge never serves a stale index, + # so the purge is an optional latency optimization, not correctness. + CF_PURGE_TOKEN: ${{ secrets.CF_PURGE_TOKEN }} run: | set -uo pipefail - # Best-effort: no-store (set above) prevents FUTURE staleness on its - # own, so this step never fails the publish — it just evicts copies - # that were cached before no-store existed, so clients see the new - # index immediately instead of after the old TTL. If the API token - # lacks Zone.Cache-Purge scope we warn and rely on no-store + TTL. [ -s /tmp/purge-urls.txt ] || { echo "No URLs to purge."; exit 0; } + if [ -z "${CF_PURGE_TOKEN:-}" ]; then + echo "No CF_PURGE_TOKEN configured — skipping purge. (no-store keeps every publish fresh at the edge; add a Zone.Cache-Purge token as CF_PURGE_TOKEN to evict legacy entries immediately.)" + exit 0 + fi ZONE_ID="$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=wheels.dev" \ - -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | jq -r '.result[0].id // empty')" + -H "Authorization: Bearer ${CF_PURGE_TOKEN}" | jq -r '.result[0].id // empty')" if [ -z "$ZONE_ID" ]; then - echo "::warning::Could not resolve the wheels.dev zone id (token may lack Zone scope). Skipping purge; no-store will keep future publishes fresh, and the legacy cached entry clears on its own TTL." + echo "::warning::CF_PURGE_TOKEN is set but the wheels.dev zone could not be resolved (token scope?). Relying on no-store." exit 0 fi # Purge in batches of 30 URLs (Cloudflare per-request cap). @@ -282,12 +288,12 @@ jobs: batch=("${urls[@]:i:30}") payload="$(printf '%s\n' "${batch[@]}" | jq -R . | jq -s '{files: .}')" resp="$(curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \ - -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Authorization: Bearer ${CF_PURGE_TOKEN}" \ -H "Content-Type: application/json" --data "$payload" || true)" if [ "$(jq -r '.success // false' <<<"$resp")" = "true" ]; then echo "Purged ${#batch[@]} url(s)." else - echo "::warning::Purge request did not report success (token scope?): $(jq -rc '.errors // .' <<<"$resp" 2>/dev/null || echo "$resp")" + echo "::warning::CF_PURGE_TOKEN purge did not report success: $(jq -rc '.errors // .' <<<"$resp" 2>/dev/null || echo "$resp")" fi i=$((i + 30)) done