From 831fcbe6c2db5962ffcfc4415981dcc25f48ce62 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 21:53:44 +0200 Subject: [PATCH] fix(ci): keep the freshness signal alive and the push resilient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lastConfirmedAt` lives in gitignored bookkeeping that only refresh.yml restored, so the workflow that actually builds and deploys never saw it: every source reported its last *content change* as its last success, and the UI flagged anything whose bytes had not moved in two days as stale — which is most wiki pages most of the time. ci.yml now restores the same cache read-only before building the feed. The refresh push was a bare `git push`, so a human push landing in between made it non-fast-forward: the job failed and threw away pages it had just fetched, while the bookkeeping had already been saved, so those sources would not be re-asked for six hours. Rebase and retry instead — never force. The cache save key used run_id, which is stable across re-runs, so a re-run saved nothing and the run after it restored stale bookkeeping. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ .github/workflows/refresh.yml | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1427653..094fb97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,20 @@ jobs: - run: bun install --frozen-lockfile + # `lastConfirmedAt` — the "we asked the wiki and it said this is still + # current" half of the freshness badge — lives in the gitignored + # snapshots/*.state.json, so without this the feed built here has only + # `contentChangedAt` to go on and every source reads as stale two days + # after its bytes last moved, which for a wiki page is most of the time. + # Restore-only: refresh.yml owns writing this cache, and a miss just + # returns the pre-existing fallback behaviour. + - name: Restore refresh bookkeeping + uses: actions/cache/restore@v4 + with: + path: snapshots/*.state.json + key: refresh-state- + restore-keys: refresh-state- + - name: Typecheck run: bun run typecheck @@ -65,6 +79,16 @@ jobs: with: bun-version: "1.3" - run: bun install --frozen-lockfile + + # This is the job whose output is deployed, so this is the one that must + # see the refresh bookkeeping; see the same step in `check`. + - name: Restore refresh bookkeeping + uses: actions/cache/restore@v4 + with: + path: snapshots/*.state.json + key: refresh-state- + restore-keys: refresh-state- + - name: Build # Pages serves from //, so the app is built with a matching base # href. Built at the domain root it would 404 on every asset. diff --git a/.github/workflows/refresh.yml b/.github/workflows/refresh.yml index 7b10316..7958dec 100644 --- a/.github/workflows/refresh.yml +++ b/.github/workflows/refresh.yml @@ -67,7 +67,7 @@ jobs: uses: actions/cache/restore@v4 with: path: snapshots/*.state.json - key: refresh-state-${{ github.run_id }} + key: refresh-state- restore-keys: refresh-state- - name: Refresh @@ -88,12 +88,16 @@ jobs: fi bun run refresh "${args[@]}" + # The key must differ every time this step runs. `run_id` is stable across + # re-runs, so a re-run's save hits an existing key, is skipped, and the + # next run restores the bookkeeping from before the re-run — records of + # requests we did make, lost. `run_attempt` increments per attempt. - name: Save refresh bookkeeping if: always() uses: actions/cache/save@v4 with: path: snapshots/*.state.json - key: refresh-state-${{ github.run_id }} + key: refresh-state-${{ github.run_id }}-${{ github.run_attempt }} # git is the authority on "did anything change" — a 304, an unchanged # body, or a rejected parse all leave the working tree clean. @@ -108,15 +112,40 @@ jobs: echo "changed=false" >> "$GITHUB_OUTPUT" fi + # A human push landing between the checkout and this push makes the push + # non-fast-forward. Failing there would throw away pages we have already + # fetched while the bookkeeping above (saved with `if: always()`) has + # already spent their six-hour budget — the wikis would be asked again for + # nothing. So rebase onto whatever landed and try again. Never force: this + # commit is only ever new files under snapshots/, so it has nothing to say + # about anyone else's work. - name: Commit refreshed snapshots if: steps.diff.outputs.changed == 'true' && inputs.dry_run != true + env: + BRANCH: ${{ github.ref_name }} run: | + set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -- snapshots git commit -m "chore(data): refresh source snapshots" \ -m "Automated fetch from ${{ github.workflow }} run ${{ github.run_id }}." - git push + + for attempt in 1 2 3; do + if git push origin "HEAD:$BRANCH"; then + exit 0 + fi + echo "push rejected (attempt $attempt); rebasing onto origin/$BRANCH" + git fetch origin "$BRANCH" + if ! git rebase "origin/$BRANCH"; then + git rebase --abort || true + echo "::error::snapshot commit conflicts with $BRANCH; not force-pushing" + exit 1 + fi + sleep $((attempt * 5)) + done + echo "::error::could not push refreshed snapshots after 3 attempts" + exit 1 # ci.yml owns typecheck, tests, the feed sanity check, the image and the # Pages deploy. Dispatching it is how the refreshed data reaches the site