From 492bc61a6be17e66bac0f5498be437b065b3cb2e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:07:34 -0500 Subject: [PATCH] [ci] add CAS-based unstable cache-state deploy flow --- .github/workflows/ci.yml | 224 +++++++++++++++++++++--------- scripts/store-dist-hash-state.mjs | 50 ++++++- 2 files changed, 203 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f710750c..e3253736 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - unstable pull_request: concurrency: @@ -57,76 +58,20 @@ jobs: - name: Build assets run: npm run build + - name: Generate dist hash state - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/unstable' run: | mkdir -p artifacts node scripts/hash-dist.mjs --dist dist --output artifacts/dist-hashes-current.json - - name: Fetch prior dist hash state - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - DIST_HASH_STATE_URL: ${{ secrets.DIST_HASH_STATE_URL }} - run: | - if [ -n "$DIST_HASH_STATE_URL" ]; then - curl --fail --silent --show-error "$DIST_HASH_STATE_URL" -o artifacts/dist-hashes-previous.json - echo "Loaded prior dist hash state from authoritative store." - else - echo '{"combined":"","files":[]}' > artifacts/dist-hashes-previous.json - echo "No DIST_HASH_STATE_URL configured; using empty baseline." - fi - - - name: Diff dist hash states - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - node scripts/diff-dist-hashes.mjs \ - --current artifacts/dist-hashes-current.json \ - --previous artifacts/dist-hashes-previous.json > artifacts/dist-hashes-changed.txt - echo "Changed dist paths:" - cat artifacts/dist-hashes-changed.txt || true - - - name: Plan cache purge scope - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - node scripts/plan-cache-purge.mjs \ - --changed-file artifacts/dist-hashes-changed.txt \ - --output-file artifacts/dist-purge-targets.txt > artifacts/dist-purge-plan.json - cat artifacts/dist-purge-plan.json - - - name: Purge changed Cloudflare URLs - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_PURGE_BASE_URL: ${{ secrets.CLOUDFLARE_PURGE_BASE_URL }} - run: | - if [ ! -s artifacts/dist-purge-targets.txt ]; then - echo "No purge targets detected. Skipping purge." - exit 0 - fi - - if [ -z "$CLOUDFLARE_ZONE_ID" ] || [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_PURGE_BASE_URL" ]; then - echo "Cloudflare secrets not configured; skipping targeted purge." - exit 0 - fi - - node scripts/purge-cloudflare-changed.mjs \ - --changed-file artifacts/dist-purge-targets.txt \ - --base-url "$CLOUDFLARE_PURGE_BASE_URL" - - - name: Record authoritative dist hash state (post-deploy) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - DIST_HASH_STATE_UPLOAD_URL: ${{ secrets.DIST_HASH_STATE_UPLOAD_URL }} - run: | - if [ -z "$DIST_HASH_STATE_UPLOAD_URL" ]; then - echo "No DIST_HASH_STATE_UPLOAD_URL configured; skipping authoritative hash-state update." - exit 0 - fi - - node scripts/store-dist-hash-state.mjs \ - --input artifacts/dist-hashes-current.json \ - --upload-url "$DIST_HASH_STATE_UPLOAD_URL" + - name: Upload dist hash artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/unstable' + uses: actions/upload-artifact@v4 + with: + name: dist-hashes-current + path: artifacts/dist-hashes-current.json + if-no-files-found: error - name: Install Playwright browsers run: npx playwright install --with-deps chromium @@ -150,6 +95,155 @@ jobs: if: steps.visual.outcome == 'failure' run: exit 1 + + deploy-cache-state: + if: github.event_name == 'push' && github.ref == 'refs/heads/unstable' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: deploy-cache-state-unstable + cancel-in-progress: false + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Download dist hash artifact + uses: actions/download-artifact@v4 + with: + name: dist-hashes-current + path: artifacts + + - name: Fetch prior dist hash state and metadata + env: + DIST_HASH_STATE_URL: ${{ secrets.DIST_HASH_STATE_URL }} + run: | + mkdir -p artifacts + + if [ -n "$DIST_HASH_STATE_URL" ]; then + curl --fail --silent --show-error --dump-header artifacts/dist-hashes-previous.headers "$DIST_HASH_STATE_URL" -o artifacts/dist-hashes-previous.json + DIST_HASH_STATE_ETAG=$(awk 'tolower($1)=="etag:" {print $2}' artifacts/dist-hashes-previous.headers | tr -d '\r' | tail -n 1) + DIST_HASH_STATE_VERSION=$(awk 'tolower($1)=="x-dist-hash-version:" {print $2}' artifacts/dist-hashes-previous.headers | tr -d '\r' | tail -n 1) + DIST_HASH_STATE_LAST_MODIFIED=$(awk 'BEGIN{IGNORECASE=1} /^Last-Modified:/ {sub(/^Last-Modified:[[:space:]]*/, ""); sub(/\r$/, ""); print; exit}' artifacts/dist-hashes-previous.headers) + echo "Loaded prior dist hash state from authoritative store." + else + echo '{"combined":"","files":[]}' > artifacts/dist-hashes-previous.json + DIST_HASH_STATE_ETAG='' + DIST_HASH_STATE_VERSION='' + DIST_HASH_STATE_LAST_MODIFIED='' + echo "No DIST_HASH_STATE_URL configured; using empty baseline." + fi + + { + echo "DIST_HASH_STATE_ETAG=$DIST_HASH_STATE_ETAG" + echo "DIST_HASH_STATE_VERSION=$DIST_HASH_STATE_VERSION" + echo "DIST_HASH_STATE_LAST_MODIFIED=$DIST_HASH_STATE_LAST_MODIFIED" + } >> "$GITHUB_ENV" + + echo "State metadata snapshot: etag='${DIST_HASH_STATE_ETAG:-}', version='${DIST_HASH_STATE_VERSION:-}', last-modified='${DIST_HASH_STATE_LAST_MODIFIED:-}'" + + - name: Diff dist hash states + run: | + node scripts/diff-dist-hashes.mjs \ + --current artifacts/dist-hashes-current.json \ + --previous artifacts/dist-hashes-previous.json > artifacts/dist-hashes-changed.txt + echo "Changed dist paths (initial baseline):" + cat artifacts/dist-hashes-changed.txt || true + + - name: Plan cache purge scope + run: | + node scripts/plan-cache-purge.mjs \ + --changed-file artifacts/dist-hashes-changed.txt \ + --output-file artifacts/dist-purge-targets.txt > artifacts/dist-purge-plan.json + echo "Initial purge plan:" + cat artifacts/dist-purge-plan.json + + - name: Record authoritative dist hash state with CAS and resolve races + env: + DIST_HASH_STATE_URL: ${{ secrets.DIST_HASH_STATE_URL }} + DIST_HASH_STATE_UPLOAD_URL: ${{ secrets.DIST_HASH_STATE_UPLOAD_URL }} + run: | + if [ -z "$DIST_HASH_STATE_UPLOAD_URL" ]; then + echo "No DIST_HASH_STATE_UPLOAD_URL configured; skipping authoritative hash-state update." + exit 0 + fi + + set +e + node scripts/store-dist-hash-state.mjs \ + --input artifacts/dist-hashes-current.json \ + --upload-url "$DIST_HASH_STATE_UPLOAD_URL" \ + --if-match "$DIST_HASH_STATE_ETAG" \ + --if-version "$DIST_HASH_STATE_VERSION" \ + --if-unmodified-since "$DIST_HASH_STATE_LAST_MODIFIED" + STORE_EXIT=$? + set -e + + if [ "$STORE_EXIT" -eq 0 ]; then + echo "CAS write succeeded on first attempt; retaining initial purge plan." + exit 0 + fi + + if [ "$STORE_EXIT" -ne 3 ]; then + echo "Hash-state upload failed with exit code $STORE_EXIT" + exit "$STORE_EXIT" + fi + + echo "CAS conflict detected. Re-fetching state and recomputing purge targets before any CDN purge." + + if [ -z "$DIST_HASH_STATE_URL" ]; then + echo "DIST_HASH_STATE_URL is required for race resolution after CAS conflict." + exit 1 + fi + + curl --fail --silent --show-error --dump-header artifacts/dist-hashes-previous.headers "$DIST_HASH_STATE_URL" -o artifacts/dist-hashes-previous.json + DIST_HASH_STATE_ETAG=$(awk 'tolower($1)=="etag:" {print $2}' artifacts/dist-hashes-previous.headers | tr -d '\r' | tail -n 1) + DIST_HASH_STATE_VERSION=$(awk 'tolower($1)=="x-dist-hash-version:" {print $2}' artifacts/dist-hashes-previous.headers | tr -d '\r' | tail -n 1) + DIST_HASH_STATE_LAST_MODIFIED=$(awk 'BEGIN{IGNORECASE=1} /^Last-Modified:/ {sub(/^Last-Modified:[[:space:]]*/, ""); sub(/\r$/, ""); print; exit}' artifacts/dist-hashes-previous.headers) + + echo "Race-resolution metadata: etag='${DIST_HASH_STATE_ETAG:-}', version='${DIST_HASH_STATE_VERSION:-}', last-modified='${DIST_HASH_STATE_LAST_MODIFIED:-}'" + + node scripts/diff-dist-hashes.mjs \ + --current artifacts/dist-hashes-current.json \ + --previous artifacts/dist-hashes-previous.json > artifacts/dist-hashes-changed.txt + echo "Changed dist paths (race-resolved baseline):" + cat artifacts/dist-hashes-changed.txt || true + + node scripts/plan-cache-purge.mjs \ + --changed-file artifacts/dist-hashes-changed.txt \ + --output-file artifacts/dist-purge-targets.txt > artifacts/dist-purge-plan.json + echo "Race-resolved purge plan:" + cat artifacts/dist-purge-plan.json + + node scripts/store-dist-hash-state.mjs \ + --input artifacts/dist-hashes-current.json \ + --upload-url "$DIST_HASH_STATE_UPLOAD_URL" \ + --if-match "$DIST_HASH_STATE_ETAG" \ + --if-version "$DIST_HASH_STATE_VERSION" \ + --if-unmodified-since "$DIST_HASH_STATE_LAST_MODIFIED" + + echo "CAS write succeeded after race resolution; proceeding with recomputed purge targets." + + - name: Purge changed Cloudflare URLs + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_PURGE_BASE_URL: ${{ secrets.CLOUDFLARE_PURGE_BASE_URL }} + run: | + if [ ! -s artifacts/dist-purge-targets.txt ]; then + echo "No purge targets detected. Skipping purge." + exit 0 + fi + + if [ -z "$CLOUDFLARE_ZONE_ID" ] || [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_PURGE_BASE_URL" ]; then + echo "Cloudflare secrets not configured; skipping targeted purge." + exit 0 + fi + + node scripts/purge-cloudflare-changed.mjs \ + --changed-file artifacts/dist-purge-targets.txt \ + --base-url "$CLOUDFLARE_PURGE_BASE_URL" + unit-tests: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/scripts/store-dist-hash-state.mjs b/scripts/store-dist-hash-state.mjs index eff29d82..30e1d526 100644 --- a/scripts/store-dist-hash-state.mjs +++ b/scripts/store-dist-hash-state.mjs @@ -5,7 +5,10 @@ function parseArgs(argv) { const args = { input: null, uploadUrl: process.env.DIST_HASH_STATE_UPLOAD_URL ?? '', - method: process.env.DIST_HASH_STATE_UPLOAD_METHOD ?? 'PUT' + method: process.env.DIST_HASH_STATE_UPLOAD_METHOD ?? 'PUT', + ifMatch: process.env.DIST_HASH_STATE_IF_MATCH ?? '', + ifVersion: process.env.DIST_HASH_STATE_IF_VERSION ?? '', + ifUnmodifiedSince: process.env.DIST_HASH_STATE_IF_UNMODIFIED_SINCE ?? '' }; for (let i = 0; i < argv.length; i += 1) { @@ -25,8 +28,25 @@ function parseArgs(argv) { i += 1; continue; } + if (arg === '--if-match') { + args.ifMatch = argv[i + 1] ?? ''; + i += 1; + continue; + } + if (arg === '--if-version') { + args.ifVersion = argv[i + 1] ?? ''; + i += 1; + continue; + } + if (arg === '--if-unmodified-since') { + args.ifUnmodifiedSince = argv[i + 1] ?? ''; + i += 1; + continue; + } if (arg === '--help' || arg === '-h') { - console.log('Usage: node scripts/store-dist-hash-state.mjs --input [--upload-url ] [--method PUT]'); + console.log( + 'Usage: node scripts/store-dist-hash-state.mjs --input [--upload-url ] [--method PUT] [--if-match ] [--if-version ] [--if-unmodified-since ]' + ); process.exit(0); } throw new Error(`Unknown argument: ${arg}`); @@ -46,15 +66,33 @@ async function main() { const args = parseArgs(process.argv.slice(2)); const body = fs.readFileSync(args.input); + const headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store' + }; + + if (args.ifMatch) { + headers['If-Match'] = args.ifMatch; + } + if (args.ifVersion) { + headers['X-If-Version'] = args.ifVersion; + } + if (args.ifUnmodifiedSince) { + headers['If-Unmodified-Since'] = args.ifUnmodifiedSince; + } + const response = await fetch(args.uploadUrl, { method: args.method, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store' - }, + headers, body }); + if (response.status === 409 || response.status === 412) { + const errorText = await response.text(); + console.error(`CAS_CONFLICT ${response.status}: ${errorText}`); + process.exit(3); + } + if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to upload hash state (${response.status}): ${errorText}`);