Skip to content

ship/ci cache banking and janitor#6603

Merged
matthewevans merged 4 commits into
mainfrom
ship/ci-cache-banking-and-janitor
Jul 24, 2026
Merged

ship/ci cache banking and janitor#6603
matthewevans merged 4 commits into
mainfrom
ship/ci-cache-banking-and-janitor

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 24, 2026

Copy link
Copy Markdown
Member
  • ci(deploy): cache draft pools and bank caches when data is complete
  • ci: reclaim Actions caches whose scope can never restore them
  • ci(card-data): refresh embedded catalogs daily, not weekly

Summary by CodeRabbit

  • Bug Fixes
    • Prevented incomplete or failed staging data from being saved to cache.
    • Reduced unnecessary re-fetching by switching to restore-then-save caching in key pipeline steps.
    • Improved cache cleanup safety by deleting entries only when they’re no longer needed.
  • New Features
    • Added an automated cache janitor to remove stale cache entries based on ref eligibility rules.
  • Maintenance
    • Updated refresh runs to occur daily.
    • Increased data-generation timeout and added clearer cache validation/reporting behavior.

The card-data job recompiled engine + draft-core on every run to produce
draft-pools.json — 5m37s of cargo for a ~2s JSON transform, because
draft-pool-gen cannot take --features cli and so never reuses the units
gen-card-data.sh built. Cache the output instead, keyed on the MTGJSON
data version (via the shared mtgjson-cache-key action, so the two roll
together and /clear-caches mtgjson sweeps both) plus a hash of the
sources that define it.

Also convert every cache in the job from the combined actions/cache to
restore + an explicit save at the point its data is complete. The
combined action registers its save as a post step declared
post-if: success(), so a job that dies downstream saves nothing. On
2026-07-24 that turned a routine MTGJSON publish into a self-sustaining
outage: the key rolled, the run paid ~28m of cold downloads, hit the 30m
job timeout mid-R2-upload, saved nothing, and every following run
recomputed the same missing key and paid it again (run 30114030288).
Raise that ceiling to 50m for the same reason — the budget has to clear
the cold path or the cache can never be seeded at all.

The pool save is skipped when any set download failed: fetch-draft-sets.sh
treats a failed set as non-fatal, and freezing a short pool under a key
that only rolls on the next MTGJSON publish would defeat the existing
self-heal (sets/ is file-existence-gated, so the next run fetches only
what is still missing).
The repo sat at 9.93GB against the hard 10GB quota across 207 entries,
with 141 of those (3.4GB) written and never restored even once. The
waste is not old or large entries — it is entries whose ref scope is
dead. A cache is restorable only from its own ref or the default branch,
so caches on vanished gh-readonly-queue branches (58 of 58 never read),
on closed PRs, and on one-shot tag refs are unreachable by construction.
A dry run over the live inventory reclaims 2.84GB immediately.

Deliberately not generational pruning. That was measured first and does
not pay: every rust family on main already carries exactly 2 generations,
so keep-newest-2 reclaims 0.13GB, and keep-1 would delete the fallback
Swatinem's rust-cache restores through restore-keys. Main's caches are
healthy (42 of 46 re-read) and this never touches them.

Mirrors merge-queue-janitor.yml: ref-absence is a scope test, not a
liveness test. Uses pull_request_target for the PR-close trigger because
fork-originated pull_request events get a read-only token regardless of
the permissions block; safe here since nothing checks out PR code.
MTGJSON publishes roughly daily (5.3.0+20260723 -> +20260724 on
consecutive days) while this ran Mondays, so for six days out of seven
the committed known-tokens.toml was stale. That is not cosmetic:
build.rs embeds that file, so when gen-card-data.sh regenerates it
mid-run the deploy has to compile the engine a SECOND time to embed the
new catalog — 5m35s on top of the first 5m39s compile, measured on run
30114030288, on a job that was timing out at 30m.

Safe by construction: the vintage write-gate means a run against
unchanged MTGJSON rewrites nothing and opens no PR.
@matthewevans
matthewevans enabled auto-merge July 24, 2026 18:33
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a20242e-f97a-40f0-a4bc-4dcd9e4d1efd

📥 Commits

Reviewing files that changed from the base of the PR and between c9f8150 and fbfe65e.

📒 Files selected for processing (2)
  • .github/actions/mtgjson-cache-key/action.yml
  • .github/workflows/cache-janitor.yml

📝 Walkthrough

Walkthrough

The pull request adds draft-pool cache-key derivation, separates cache restoration from persistence in staging and WASM workflows, introduces cache cleanup automation, and changes scheduled card-data refreshes from weekly to daily.

Changes

Cache lifecycle and refresh workflows

Layer / File(s) Summary
Draft-pool cache-key derivation
.github/actions/mtgjson-cache-key/action.yml
Adds draft_pools_suffix using the MTGJSON suffix, tracked source-file hashes, and gating inputs.
Staging data cache flow
.github/workflows/deploy.yml
Separates cache restoration from saving and conditionally persists MTGJSON, Scryfall, and draft-pool data after generation.
WASM build cache flow
.github/workflows/release.yml
Applies explicit restore and save steps to MTGJSON, Scryfall, and draft-pool caches, with generation and failure-count conditions.
Cache janitor sweep
.github/workflows/cache-janitor.yml
Adds scheduled, pull-request-close, and manual cache cleanup with scope checks, dry-run support, deletion, and quota reporting.
Daily refresh scheduling
.github/workflows/refresh-card-data.yml
Changes scheduled card-data refreshes from weekly Monday execution to daily execution.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

Cache janitor sweep

sequenceDiagram
  participant CacheJanitor
  participant GitHubRefs
  participant GitHubPullRequests
  participant GitHubCaches
  CacheJanitor->>GitHubCaches: paginate cache inventory
  CacheJanitor->>GitHubRefs: inspect merge-queue refs
  CacheJanitor->>GitHubPullRequests: inspect pull-request states
  CacheJanitor->>GitHubCaches: delete eligible caches or report dry run
  GitHubCaches-->>CacheJanitor: return remaining quota usage
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly points to the main CI cache banking and cache janitor changes in this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/ci-cache-banking-and-janitor

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
.github/workflows/cache-janitor.yml (3)

153-156: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Every delete failure is reported as "already gone", so a permissions or rate-limit failure looks like a clean sweep.

2>&1 discards the response body along with the status, so 403, 409, and secondary rate-limit responses all print (already gone) and the job exits green having reclaimed nothing. Only a 404 genuinely means the entry is gone.

🔍 Proposed error handling
             if [ "$DRY_RUN" != "true" ]; then
-              gh api -X DELETE "/repos/$REPO/actions/caches/$id" >/dev/null 2>&1 \
-                || { echo "  (already gone)"; continue; }
+              # </dev/null so nothing can consume the loop's stdin.
+              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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cache-janitor.yml around lines 153 - 156, Update the cache
deletion logic in the DRY_RUN guard to distinguish a genuine 404 from other gh
api failures: preserve the “already gone” message only for 404 responses, while
exposing and failing on permission, conflict, rate-limit, or other errors
instead of suppressing them. Keep successful deletions and dry-run behavior
unchanged.

121-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

PR-close runs sweep more than the closed PR, contradicting the comment at Lines 79-80.

Only the refs/pull/* arm honors CLOSED_PR; the queue and tag arms evaluate on every closed PR, and the loop at Lines 106-109 also resolves state for every cache-holding PR on those runs. Either gate the whole sweep or restrict it to the PR arm explicitly and correct the comment.

♻️ Proposed scoping fix
             case "$ref" in
               refs/heads/gh-readonly-queue/*)
+                if [ -n "$CLOSED_PR" ]; then
+                  : # PR-close run: queue and tag scopes are the nightly sweep's job.
                 # The queue branch is gone -> no run can ever check it out, so
                 # no run can ever restore this entry.
-                if ! grep -qxF "${ref#refs/heads/}" live_queue.txt; then
+                elif ! grep -qxF "${ref#refs/heads/}" live_queue.txt; then
                   reason="merge-queue ref no longer exists"
                 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 [ "$(date -u -d "$created" +%s)" -lt "$cutoff" ]; then
+                if [ -z "$CLOSED_PR" ] && [ "$(date -u -d "$created" +%s)" -lt "$cutoff" ]; then
                   reason="tag cache older than ${TAG_CACHE_MAX_AGE_DAYS}d"
                 fi
                 ;;

And skip the needless state lookups on PR-close runs:

-          for pr in $(cut -f2 caches.tsv | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p' | sort -un); do
+          for pr in ${CLOSED_PR:-$(cut -f2 caches.tsv | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p' | sort -un)}; do
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cache-janitor.yml around lines 121 - 144, Scope PR-close
cleanup to CLOSED_PR consistently: gate the queue and tag cases so they are
skipped when a specific closed PR is provided, and avoid resolving PR state for
unrelated cache-holding PRs in the loop around the state lookup. Update the
comment near the sweep setup to describe the corrected behavior, while
preserving normal full-sweep behavior when CLOSED_PR is unset.

38-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Suppress the zizmor dangerous-triggers finding inline rather than leaving an error-level result.

The rationale for pull_request_target is already documented at Lines 46-53 and holds — nothing checks out or executes PR code. Since zizmor reports this at error severity, add an inline ignore so the audit stays green and the exemption is auditable at the trigger itself.Ignore comment support was added in v0.6.0, findings can be ignored inline with # zizmor: ignore[rulename] comments, and the comment must sit within the finding's span and not inside a string or block literal.

🧹 Proposed inline suppression
-  pull_request_target:
+  pull_request_target: # zizmor: ignore[dangerous-triggers]
     # 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.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cache-janitor.yml around lines 38 - 60, Add an inline
zizmor suppression for the dangerous-triggers finding within the
pull_request_target trigger span, using the rule name specified by the audit and
placing it on a YAML comment line near that trigger. Keep the existing rationale
and trigger configuration unchanged, and ensure the suppression remains
auditable at the trigger.

Source: Linters/SAST tools

.github/workflows/release.yml (1)

100-109: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

binaryen cache still uses the combined action.

This step still relies on actions/cache@v4 with its implicit post-if: success() save, the exact pattern this PR is moving away from elsewhere in the same job for the 2026-07-24 incident. The blast radius here is much smaller (a single small binaryen download vs. multi-minute compiles), so this is low priority, but converting it to restore/save for consistency would close the last combined-cache gap in this job.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 100 - 109, Convert the binaryen
cache flow in the “Cache binaryen” and “Install binaryen” steps from
actions/cache@v4 to explicit restore and save actions, preserving the existing
path, key, and cache-hit condition. Add a separate save step after installation
using the same key, and ensure saving occurs only when the cache was not already
restored.
.github/workflows/deploy.yml (1)

80-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Cache restore/save/generate flow is duplicated near-verbatim across two workflows. Both files independently implement ~80 lines of MTGJSON/draft-pools/Scryfall restore, generate, and conditional save logic, and each file's comments explicitly point at the other as the reference copy — the same coupling risk the PR already solved for suffix computation by extracting mtgjson-cache-key. A future fix applied to only one copy would silently fork behavior again, echoing the exact incident this PR is fixing.

  • .github/workflows/deploy.yml#L80-L93: extract the restore/save/generate sequence (MTGJSON, draft-pools, Scryfall) into a shared composite action, parameterized by path/key/gating inputs and job-specific steps (download/generate) via callbacks or a fixed contract.
  • .github/workflows/release.yml#L133-L139: consume the same composite action instead of maintaining a parallel copy.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 80 - 93, Extract the duplicated
MTGJSON, draft-pools, and Scryfall restore/generate/conditional-save flow from
.github/workflows/deploy.yml lines 80-93 into a shared composite action,
parameterized for path, cache-key, gating, and job-specific download/generation
behavior. Update .github/workflows/release.yml lines 133-139 to consume that
action instead of maintaining its parallel implementation; ensure both workflows
preserve their existing job-specific behavior and cache conditions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/actions/mtgjson-cache-key/action.yml:
- Around line 73-86: Update the source validation in the cache-key step around
the git ls-files invocation to validate each input path independently, ensuring
crates/draft-core, set_catalog.rs, and set_gating.rs each match at least one
tracked file. Fail with an error if any individual path is missing, rather than
relying on the combined srcs result.

In @.github/workflows/cache-janitor.yml:
- Around line 97-100: Update the live queue reference lookup in the
cache-janitor workflow to fail closed when the gh api pipeline fails: do not
create an empty live_queue.txt or continue to sweeping in that case. Preserve
the valid 200/empty-array result as an empty list, but abort the job before
cache deletion on API or pipeline errors, matching the existing missing-evidence
behavior near the PR lookup.
- Line 162: Update the summary echo using DRY_RUN so “would have deleted”
appears only when DRY_RUN is true; scheduled and PR-close runs with DRY_RUN set
to “false” must report “deleted” instead. Preserve the existing kept, deleted,
and freed values.

---

Nitpick comments:
In @.github/workflows/cache-janitor.yml:
- Around line 153-156: Update the cache deletion logic in the DRY_RUN guard to
distinguish a genuine 404 from other gh api failures: preserve the “already
gone” message only for 404 responses, while exposing and failing on permission,
conflict, rate-limit, or other errors instead of suppressing them. Keep
successful deletions and dry-run behavior unchanged.
- Around line 121-144: Scope PR-close cleanup to CLOSED_PR consistently: gate
the queue and tag cases so they are skipped when a specific closed PR is
provided, and avoid resolving PR state for unrelated cache-holding PRs in the
loop around the state lookup. Update the comment near the sweep setup to
describe the corrected behavior, while preserving normal full-sweep behavior
when CLOSED_PR is unset.
- Around line 38-60: Add an inline zizmor suppression for the dangerous-triggers
finding within the pull_request_target trigger span, using the rule name
specified by the audit and placing it on a YAML comment line near that trigger.
Keep the existing rationale and trigger configuration unchanged, and ensure the
suppression remains auditable at the trigger.

In @.github/workflows/deploy.yml:
- Around line 80-93: Extract the duplicated MTGJSON, draft-pools, and Scryfall
restore/generate/conditional-save flow from .github/workflows/deploy.yml lines
80-93 into a shared composite action, parameterized for path, cache-key, gating,
and job-specific download/generation behavior. Update
.github/workflows/release.yml lines 133-139 to consume that action instead of
maintaining its parallel implementation; ensure both workflows preserve their
existing job-specific behavior and cache conditions.

In @.github/workflows/release.yml:
- Around line 100-109: Convert the binaryen cache flow in the “Cache binaryen”
and “Install binaryen” steps from actions/cache@v4 to explicit restore and save
actions, preserving the existing path, key, and cache-hit condition. Add a
separate save step after installation using the same key, and ensure saving
occurs only when the cache was not already restored.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a26dc575-7664-49e4-8477-1fe0cd4079c4

📥 Commits

Reviewing files that changed from the base of the PR and between 7ac9d51 and c9f8150.

📒 Files selected for processing (5)
  • .github/actions/mtgjson-cache-key/action.yml
  • .github/workflows/cache-janitor.yml
  • .github/workflows/deploy.yml
  • .github/workflows/refresh-card-data.yml
  • .github/workflows/release.yml

Comment thread .github/actions/mtgjson-cache-key/action.yml Outdated
Comment thread .github/workflows/cache-janitor.yml Outdated
Comment thread .github/workflows/cache-janitor.yml Outdated
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
Addresses review findings on #6603. Three of these are guards that could
not fire on the input they exist to catch:

* The draft-pool key validated its source paths with a single combined
  emptiness check. `crates/draft-core` alone matches 13 tracked files, so
  renaming either single-file engine path left the output non-empty, passed
  the guard, and silently dropped that file from the hash — after which the
  key would never roll on its content again. Validate each path
  independently.

* The live merge-queue lookup used `|| : > live_queue.txt`, which cannot
  distinguish "no queue refs exist" (200 with `[]`) from "the API call
  failed". A blip left the file empty, marked every queue ref orphaned, and
  swept the caches of in-flight entries — the exact fail-open the PR arm
  already refuses. Abort instead, and skip the lookup entirely on PR-close
  runs so they cannot fail on a scope they do not touch.

* Every delete failure printed "(already gone)". Only a 404 means that; a
  403 from a missing `actions: write` or a secondary rate-limit would have
  reported a clean sweep having reclaimed nothing. Classify the error and
  emit a warning annotation for anything else.

Also scope PR-close runs to the PR that closed, which the env comment
already claimed but only the `refs/pull/*` arm implemented, and fix the
summary line's `${DRY_RUN:+...}` — DRY_RUN is the string "false" on real
runs, not empty, so every sweep reported "would have deleted".

Verified by dry-running the sweep against the live cache inventory: the
scheduled path reclaims 115 entries / 2.84 GB unchanged, and a PR-close run
for a closed cache-holding PR touches only that PR (0 queue, 0 tag, 0 other
PRs, live-ref lookup skipped).
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Jul 24, 2026
@matthewevans
matthewevans enabled auto-merge July 24, 2026 18:52
@matthewevans
matthewevans merged commit 438cad0 into main Jul 24, 2026
5 of 13 checks passed
@matthewevans
matthewevans deleted the ship/ci-cache-banking-and-janitor branch July 24, 2026 18:52
@matthewevans

Copy link
Copy Markdown
Member Author

Review dispositions

Fixed (fbfe65e) — three of these were guards that could not fire on the input they exist to catch.

# Finding Evidence
1 Per-path validation in mtgjson-cache-key Confirmed: crates/draft-core matches 13 tracked files vs 1 each for the engine paths. Simulated renaming set_catalog.rs — old combined check passed with 14 files still matched; new per-path check exits 1.
2 Fail-closed on the live-ref lookup Confirmed, and the most serious of the eight — it contradicted the "never delete on missing evidence" rule the refs/pull/* arm already follows. Also skipped the lookup entirely on PR-close runs, so they cannot fail on a scope they don't touch.
3 ${DRY_RUN:+would have } always expands Confirmed — DRY_RUN is the string "false" on real runs, not empty.
4 Delete failures all reported as "already gone" Confirmed. Stub-tested all four classes: 404 → (already gone), 403/429 → ::warning:: annotation, success → counted. No branch aborts the loop under set -euo pipefail.
5 PR-close runs swept more than the closed PR Confirmed — the env comment already claimed this scoping but only the refs/pull/* arm implemented it. Gated the queue and tag arms, and scoped the PR-state resolution (25 API calls → 1).

Verified by dry-running the sweep against the live cache inventory. Scheduled path is unchanged at 115 entries / 2.84 GB; a PR-close run for a closed cache-holding PR touches only that PR — 0 queue, 0 tag, 0 other PRs, live-ref lookup skipped.

Declined, with reasons:

zizmor inline suppression — there is no zizmor, actionlint, or any workflow SAST configured in this repo (rg -ni "zizmor|actionlint" .github/ → no matches). An inline ignore would be dead config for a linter that never runs here. The pull_request_target rationale stays documented at the trigger, which is what makes it auditable.

binaryen restore/save conversion — the key is binaryen-123-x86_64-linux, entirely static. A static key is structurally immune to the failure this PR fixes: it can never roll to a nonexistent entry, so it cannot enter the "miss → job dies → nothing saved → miss again" loop. Once saved it hits until someone bumps the version. The combined action is the simpler correct choice there. (Note the same pattern exists twice in deploy.yml and again in release.yml's lobby-worker job, so this decision is uniform rather than a one-off skip.)

Shared composite action for the cache flow — composite actions have no callback mechanism, so "job-specific steps via callbacks" isn't available. The two jobs interleave genuinely different work between the same caches (card-data does R2 upload, Scryfall images, schema validation and semantic audit; build-wasm does wasm-pack), so a shared action would either need to be split into six single-step wrappers — indirection with no deduplication — or force both jobs into one rigid contract. mtgjson-cache-key was extracted because it is a pure function with a real single-authority requirement: two workflows computing the suffix differently would silently fork the cache namespace. Declarative path/key config carries no such risk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant