fix(engine): escalate entry-incremental flush when an entrant's card types are rewritten #251
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Cache janitor | |
| # Deletes Actions caches that nothing can ever restore again. | |
| # | |
| # The repo runs against a hard 10 GB cache quota; once over it, GitHub evicts by | |
| # least-recently-used. On 2026-07-24 the repo sat at 9.93 GB across 207 entries, | |
| # and the eviction pressure was almost entirely dead weight: 141 of those 207 | |
| # entries (3.4 GB) had been written and never restored even once. | |
| # | |
| # The waste is not "old" or "big" entries — it is entries whose SCOPE is dead. | |
| # A cache is restorable only from its own ref or from the default branch, so: | |
| # | |
| # * gh-readonly-queue/** The merge queue re-synthesizes these branches on | |
| # every entry change and deletes the old ones. 58 of | |
| # 58 such entries (1.00 GB) had never been read, and | |
| # 27 distinct queue refs held caches while only 3 were | |
| # still live. Queue runs restore from main (their base) | |
| # anyway, so nothing is lost. | |
| # * refs/pull/N/merge Useful across pushes WITHIN a live PR (23 entries had | |
| # genuinely been re-read), and dead the moment the PR | |
| # closes. 17 of the 25 PRs holding caches were closed, | |
| # holding 1.84 GB. | |
| # * refs/tags/** Release runs happen once per tag, and a later tag is | |
| # a different ref that cannot read them. 8 of 9 (1.33 | |
| # GB) had never been read. | |
| # | |
| # Deliberately NOT implemented: generational pruning ("keep the newest N per key | |
| # family"). It was measured first and does not pay — every rust family on main | |
| # already carries exactly 2 generations, so a keep-newest-2 sweep would reclaim | |
| # 0.13 GB, and dropping to keep-1 would delete the older generation that | |
| # Swatinem's rust-cache uses as its restore-keys warm-start fallback. Main's | |
| # caches are healthy (42 of 46 re-read); this janitor never touches them. | |
| # | |
| # Mirrors merge-queue-janitor.yml's test: ref-absence is a scope test, not a | |
| # liveness test. Nothing here inspects age or size on the queue path — if the | |
| # ref is gone, the entry is unreachable by definition. | |
| on: | |
| schedule: | |
| # Daily at 06:00 UTC, before the working day builds up new entries. | |
| - cron: "0 6 * * *" | |
| pull_request_target: | |
| # Reclaim a PR's caches as soon as it closes, rather than waiting for the | |
| # next nightly sweep or GitHub's 7-day idle eviction. | |
| # | |
| # `pull_request_target`, not `pull_request`: a fork-originated | |
| # `pull_request` event gets a read-only GITHUB_TOKEN no matter what the | |
| # `permissions:` block asks for, so every closed fork PR would 403 on the | |
| # delete and leave a failed run behind. This repo takes fork | |
| # contributions, so that is the common case, not the edge case. | |
| # `pull_request_target` runs this workflow definition from the base branch | |
| # with a writable token, and it is safe here because nothing in this job | |
| # checks out or executes PR code — it only calls the caches API. | |
| types: [closed] | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: "List what would be deleted without deleting it" | |
| type: boolean | |
| default: false | |
| permissions: | |
| actions: write | |
| contents: read | |
| concurrency: | |
| group: cache-janitor | |
| cancel-in-progress: false | |
| jobs: | |
| sweep: | |
| name: Delete unreachable caches | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| DRY_RUN: ${{ inputs.dry_run || 'false' }} | |
| # PR-close runs sweep only the PR that closed; scheduled and manual runs | |
| # sweep everything. | |
| CLOSED_PR: ${{ github.event_name == 'pull_request_target' && github.event.number || '' }} | |
| # Tag caches are kept briefly so a re-run of the same release can still | |
| # use them, then reclaimed. | |
| TAG_CACHE_MAX_AGE_DAYS: "3" | |
| steps: | |
| - name: Delete caches whose scope can never restore them again | |
| run: | | |
| set -euo pipefail | |
| # Full inventory. The REST route is paginated at 100; --paginate | |
| # walks it. `.actions_caches[]` is the per-entry array. created_at is | |
| # pulled here so the tag-age check needs no per-entry API call. | |
| gh api --paginate "/repos/$REPO/actions/caches?per_page=100" \ | |
| --jq '.actions_caches[] | [.id, .ref, .size_in_bytes, .created_at, .key] | @tsv' > caches.tsv | |
| echo "inventory: $(wc -l < caches.tsv) entries, $(awk -F'\t' '{s+=$3} END {printf "%.2f", s/1073741824}' caches.tsv) GB" | |
| # Live merge-queue refs, so an in-flight queue entry is never swept. | |
| # A FAILED lookup is missing evidence, not an empty live set: an API | |
| # blip would otherwise leave this file empty, mark every queue ref | |
| # orphaned, and delete the caches of entries currently in flight. | |
| # matching-refs returns 200 with `[]` when nothing matches, so a | |
| # genuinely empty live set still succeeds here and sweeps correctly. | |
| # Only needed when the queue scope is in play — a PR-close run must | |
| # not fail on an API blip for a scope it isn't going to touch. | |
| : > live_queue.txt | |
| if [ -z "$CLOSED_PR" ]; then | |
| if ! gh api "/repos/$REPO/git/matching-refs/heads/gh-readonly-queue" \ | |
| --jq '.[].ref | sub("^refs/heads/"; "")' > live_queue.raw; then | |
| echo "::error::could not enumerate live merge-queue refs — refusing to sweep on missing evidence" | |
| exit 1 | |
| fi | |
| sort live_queue.raw > live_queue.txt | |
| echo "live merge-queue refs: $(wc -l < live_queue.txt)" | |
| fi | |
| # Cache-holding PR numbers -> state, resolved once each. A PR that | |
| # 404s (deleted fork, bad ref) is treated as OPEN: this janitor must | |
| # never delete on missing evidence. On a PR-close run only the PR | |
| # that closed is in scope, so resolve just that one rather than | |
| # every PR holding a cache. | |
| if [ -n "$CLOSED_PR" ]; then | |
| prs="$CLOSED_PR" | |
| else | |
| prs="$(cut -f2 caches.tsv | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p' | sort -un)" | |
| fi | |
| : > pr_state.txt | |
| for pr in $prs; do | |
| state="$(gh api "/repos/$REPO/pulls/$pr" --jq '.state' 2>/dev/null || echo open)" | |
| printf '%s\t%s\n' "$pr" "$state" >> pr_state.txt | |
| done | |
| cutoff=$(date -u -d "$TAG_CACHE_MAX_AGE_DAYS days ago" +%s) | |
| deleted=0 | |
| freed=0 | |
| kept=0 | |
| verb="DELETE" | |
| [ "$DRY_RUN" = "true" ] && verb="WOULD DELETE" | |
| while IFS=$'\t' read -r id ref size created key; do | |
| reason="" | |
| # A PR-close run is scoped to that one PR — the queue and tag | |
| # scopes belong to the nightly sweep, which has the live-ref | |
| # evidence a PR-close run deliberately skips fetching. | |
| case "$ref" in | |
| refs/heads/gh-readonly-queue/*) | |
| # The queue branch is gone -> no run can ever check it out, so | |
| # no run can ever restore this entry. | |
| if [ -z "$CLOSED_PR" ] && ! grep -qxF "${ref#refs/heads/}" live_queue.txt; then | |
| reason="merge-queue ref no longer exists" | |
| fi | |
| ;; | |
| refs/pull/*) | |
| pr="$(printf '%s' "$ref" | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p')" | |
| if [ -n "$CLOSED_PR" ] && [ "$pr" != "$CLOSED_PR" ]; then | |
| : # PR-close run: leave every other PR alone. | |
| elif [ "$(awk -F'\t' -v p="$pr" '$1==p {print $2}' pr_state.txt)" = "closed" ]; then | |
| reason="PR #$pr is closed" | |
| fi | |
| ;; | |
| *refs/tags/*) | |
| # Tag refs are single-use: the next release is a different ref | |
| # and cannot read these. Keep briefly for same-tag re-runs. | |
| if [ -z "$CLOSED_PR" ] && [ "$(date -u -d "$created" +%s)" -lt "$cutoff" ]; then | |
| reason="tag cache older than ${TAG_CACHE_MAX_AGE_DAYS}d" | |
| fi | |
| ;; | |
| esac | |
| if [ -z "$reason" ]; then | |
| kept=$((kept + 1)) | |
| continue | |
| fi | |
| printf '%s %6sMB %s (%s)\n' \ | |
| "$verb" "$((size / 1048576))" "$key" "$reason" | |
| if [ "$DRY_RUN" != "true" ]; then | |
| # Only a 404 means the entry is genuinely gone. Swallowing every | |
| # failure would report a 403 (no `actions: write`) or a secondary | |
| # rate-limit as a clean sweep, and the job would exit green | |
| # having reclaimed nothing. `</dev/null` keeps gh off the loop's | |
| # stdin; `2>&1 >/dev/null` captures stderr and drops stdout. | |
| if ! err="$(gh api -X DELETE "/repos/$REPO/actions/caches/$id" </dev/null 2>&1 >/dev/null)"; then | |
| case "$err" in | |
| *"HTTP 404"*) echo " (already gone)" ;; | |
| *) echo "::warning::delete failed for cache $id: $err" ;; | |
| esac | |
| continue | |
| fi | |
| fi | |
| deleted=$((deleted + 1)) | |
| freed=$((freed + size)) | |
| done < caches.tsv | |
| # `${DRY_RUN:+...}` would expand on every run — DRY_RUN is the string | |
| # "false" on scheduled and PR-close runs, not empty. | |
| would="" | |
| [ "$DRY_RUN" = "true" ] && would="would have " | |
| echo | |
| echo "kept $kept, ${would}deleted $deleted entries, freeing $(awk -v b="$freed" 'BEGIN {printf "%.2f", b/1073741824}') GB" | |
| - name: Report remaining usage | |
| if: ${{ always() }} | |
| run: | | |
| gh api "/repos/$REPO/actions/cache/usage" \ | |
| --jq '"after sweep: \(.active_caches_count) entries, \((.active_caches_size_in_bytes / 1073741824) * 100 | round / 100) GB of the 10 GB quota"' |