Skip to content

Commit ef4660f

Browse files
ten9876claude
andauthored
ci: sweep Actions caches belonging to closed PRs (#5009)
## What it does Adds `sweep-closed-prs` to `cache-cleanup.yml`: a daily (`17 4 * * *`) plus on-demand job that deletes caches on `refs/pull/N/merge` once the PR is no longer open. Those caches live on a ref only that PR can read. When it closes they become permanently unreachable but keep holding budget for up to the full 7-day retention, which is not configurable. ## Why scheduled, not `pull_request: closed` The obvious trigger cannot work, which is why #5008 pulled the original version rather than shipping it: for a `pull_request` event whose head is a fork, GitHub issues a **read-only** `GITHUB_TOKEN`, and a `permissions:` block can only lower that ceiling, never raise it. `gh cache delete` returns 403 and the job fails. **44 of the last 60 closed PRs here are from forks** (measured 2026-09-10). `pull_request_target` would get a writable token and is the documented-safe shape for a job that checks nothing out. It's deliberately not used: a schedule needs no privileged trigger at all, is idempotent, and **self-heals** — it picks up PRs closed while the workflow was broken, renamed, or disabled, which an event-driven job misses forever. The cost is latency, and it is not quite free: while the repo is over its allowance, dead entries compete with main's live ones for LRU until the sweep runs. If that bites, the job is idempotent and cheap enough to also hang off the existing `workflow_run` trigger, with the schedule kept as the backstop. Not done here; see follow-ups. ## Measured premise (2026-09-10, 25 days after #5008) The original draft held this PR until it could be shown that anything still accumulates on PR refs after #5008's save-on-main split. It does: ``` PR-ref caches: 20 entries, 6881 MiB repo usage: 23 entries, 9.88 GiB of 10 GiB #5458 MERGED 1720 MiB ← unreachable, swept by this job #5462 OPEN 396 MiB #5539 OPEN 1323 MiB #5542 OPEN 1720 MiB #5547 OPEN 1720 MiB ``` That is past the "> 2 GiB, or any single Qt-sized entry → merge" threshold the draft set for itself. The "PRs should restore rather than write" hypothesis did not hold: each open PR carries its own copy of the same 1,297 MiB Qt key main holds, because at the allowance LRU evicts main's copy between runs and the next PR re-saves it. This job reclaims the closed-PR leg of that loop; the loop itself is a ci.yml matter (follow-ups). ## Failure behaviour | case | behaviour | |---|---| | PR state unresolvable | Skipped and retried next run — a transient API error must not become data loss. Counted, and surfaced as a `::warning::` with gh's stderr so a persistent cause (missing scope, rate limit) cannot hide | | `--limit 500` truncation | Emits `::warning::`, because silent truncation looks exactly like "nothing to clean" | | Individual delete fails / LRU took it first | Deleted by id like prune-main; a 404 is logged, does not abort the loop, and does not count toward `freed` | | Dispatch overlaps the cron | `concurrency` group queues the second run | ## Cannot be exercised on this PR `schedule` and `workflow_dispatch` only fire from the **default branch**. First real run is after merge: trigger it via `workflow_dispatch` immediately rather than waiting for 04:17, and read the log before trusting it. That first run is also what confirms the `pull-requests: read` grant is sufficient for `gh pr view` under `GITHUB_TOKEN`. ## Not addressed here - Caches on **tag refs** (`refs/tags/v*`) from the release workflows are swept by neither job. Low volume; noted so it isn't silently forgotten. - The open-PR duplication loop above. The durable fix is giving the dependency caches (Qt, FFTW3, DeepFilterNet3, qtkeychain) the same restore-on-PR / save-on-main split #5008 gave the compiler caches; `install-qt-action`'s built-in cache has no restore-only mode and needs its own decision. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent df0520a commit ef4660f

1 file changed

Lines changed: 162 additions & 14 deletions

File tree

.github/workflows/cache-cleanup.yml

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,37 +34,43 @@ name: Cache cleanup
3434
# pins the repo at its allowance on its own, holding well under a day of caches
3535
# nothing will ever read.
3636
#
37-
# NOT handled here: caches on refs/pull/N/merge, which become unreadable when
38-
# the PR closes. A job for those was written and pulled back out before merge —
39-
# `pull_request` grants a read-only GITHUB_TOKEN for fork PRs and `permissions:`
40-
# cannot elevate it, so `gh cache delete` 403s; 22 of 25 open PRs are from
41-
# forks, so it would have failed on nearly every close. It also may not be
42-
# needed: PRs only WRITE a dependency cache (Qt, FFTW, DeepFilterNet) when the
43-
# key is not already readable, and the reason they were doing so is that main's
44-
# copy kept being evicted by the churn ci.yml's save-on-main split removes. Left
45-
# for a follow-up that can measure whether anything is still accumulating there,
46-
# and settle the trigger (a scheduled sweep avoids the privileged-trigger
47-
# question entirely).
37+
# The other cause is caches on refs/pull/N/merge, which become unreadable the
38+
# moment the PR closes — handled by sweep-closed-prs below, on a schedule rather
39+
# than on the close event. See that job for why the trigger is what it is.
4840

4941
on:
5042
workflow_run:
5143
workflows: [CI]
5244
types: [completed]
45+
# Daily, plus on demand. See sweep-closed-prs for why this is scheduled
46+
# rather than fired from `pull_request: closed`.
47+
schedule:
48+
- cron: "17 4 * * *"
49+
workflow_dispatch:
5350

5451
# actions:write is the only elevated scope and it cannot reach anything outside
5552
# the cache API. It lives in this workflow rather than as a step in ci.yml
5653
# specifically so ci.yml keeps `contents: read` — nothing that compiles
5754
# PR-authored code ever holds a write scope.
5855
#
59-
# workflow_run runs in the BASE repository context with a full token even when
60-
# the run that triggered it came from a fork, which is why this job works where
61-
# a `pull_request`-triggered one would not.
56+
# pull-requests:read is a read scope, needed only by sweep-closed-prs to ask
57+
# whether a PR is still open. Listing it is not optional: naming ANY scope in a
58+
# `permissions:` block sets every unnamed one to `none`, and the job's lookup
59+
# would then fail on every ref while the job stayed green.
60+
#
61+
# All three triggers run in the BASE repository context with a full token:
62+
# workflow_run does so even when the run that triggered it came from a fork,
63+
# and schedule / workflow_dispatch only ever fire from the default branch. That
64+
# is why both jobs work where a `pull_request`-triggered one would not.
6265
permissions:
6366
actions: write
6467
contents: read
68+
pull-requests: read
6569

6670
jobs:
6771
prune-main:
72+
# Only on a main push run — a scheduled or manual invocation is for the
73+
# sweep below and has no new main entry to supersede anything.
6874
# workflow_run.event == 'push', NOT head_branch == 'main'. A fork's default
6975
# branch is usually also called main, so a head_branch test fires on fork PR
7076
# runs too — harmless in effect (the prune is idempotent and no fork run
@@ -163,3 +169,145 @@ jobs:
163169
gh api "repos/${{ github.repository }}/actions/cache/usage" \
164170
--jq '"Repo Actions cache: \(.active_caches_count) entries, \((.active_caches_size_in_bytes/1073741824)*100|floor/100) GiB of 10 GiB allowance"' \
165171
| tee -a "$GITHUB_STEP_SUMMARY"
172+
173+
# ── Dead cause 2: the PR closed ─────────────────────────────────────────────
174+
# A PR's caches live on refs/pull/N/merge, a ref only that PR can read. Once it
175+
# closes nothing can ever read them again, but they hold budget for up to the
176+
# full 7-day retention.
177+
#
178+
# ci.yml's save-on-main split already stops PRs writing the big COMPILER
179+
# caches. What still lands on a PR ref is a content-hash-keyed DEPENDENCY
180+
# cache (Qt, FFTW3, DeepFilterNet3, qtkeychain, install-qt-action). In theory a
181+
# PR only writes one when the key is not readable from main; in practice the
182+
# repo sits at its allowance, LRU evicts main's copy between runs, and the
183+
# next PR run re-saves its own — measured 2026-09-10: four open PRs each
184+
# holding the same 1,297 MiB Qt entry main holds, plus a merged PR holding
185+
# 1,720 MiB nothing could read. That loop is a ci.yml problem (the dependency
186+
# caches want the same restore-on-PR / save-on-main split the compiler caches
187+
# got); this job only reclaims the closed-PR leg of it.
188+
#
189+
# WHY SCHEDULED, not `pull_request: closed`
190+
#
191+
# The obvious trigger cannot work. For a `pull_request` event whose head is a
192+
# fork, GitHub issues a read-only GITHUB_TOKEN and a `permissions:` block can
193+
# only lower that ceiling, never raise it — so actions:write is not granted,
194+
# `gh cache delete` returns 403, and the job fails. Most closed PRs here come
195+
# from forks (44 of the last 60 on 2026-09-10), so that is the common case.
196+
#
197+
# `pull_request_target` would get a writable token and is the documented-safe
198+
# shape for a job that checks nothing out. It is deliberately not used: a
199+
# schedule needs no privileged trigger at all, is idempotent, and self-heals —
200+
# it picks up PRs closed while the workflow was broken, renamed or disabled,
201+
# which an event-driven job silently misses forever.
202+
#
203+
# The cost is latency: a closed PR's caches live until the next run, and while
204+
# the repo is over its allowance they compete with main's live entries for LRU.
205+
# If that bites, the sweep is idempotent and cheap enough to also hang off the
206+
# workflow_run trigger above; the schedule then stays as the backstop.
207+
#
208+
# A PR that is closed and later REOPENED can read its ref again, but this job
209+
# will already have deleted it; the reopened PR's first run re-saves its
210+
# dependency caches (~1.7 GiB across platforms). Accepted, not a bug.
211+
sweep-closed-prs:
212+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
213+
runs-on: ubuntu-latest
214+
# A dispatch overlapping the cron would run two sweeps over the same refs;
215+
# harmless to data but the two summaries would double-count. Queue instead.
216+
concurrency:
217+
group: cache-cleanup-sweep
218+
cancel-in-progress: false
219+
steps:
220+
- name: Delete caches belonging to closed PRs
221+
env:
222+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
223+
GH_REPO: ${{ github.repository }}
224+
run: |
225+
set -euo pipefail
226+
227+
# gh paginates, so a high cap is free. It matters which end gets cut
228+
# if the cap is ever hit: the default order is last-accessed first,
229+
# and a closed PR's entries are by definition the least recently
230+
# accessed, so a low cap would drop exactly the candidates. 36 entries
231+
# on 2026-09-10 (usage API counts 23 active; the two endpoints differ),
232+
# ~5 per open PR. Logged below if the cap is hit, because a silent
233+
# truncation here would look exactly like "nothing to clean".
234+
LIMIT=500
235+
caches=$(gh cache list --limit "$LIMIT" --json id,ref,sizeInBytes)
236+
total=$(printf '%s' "$caches" | jq 'length')
237+
[ "$total" -lt "$LIMIT" ] || echo "::warning::cache list hit the $LIMIT limit; some entries not examined this run"
238+
239+
# Only refs/pull/N/merge entries are candidates. main and tag refs are
240+
# not swept here: main's are handled by prune-main, and tag caches
241+
# belong to release workflows that run rarely enough not to matter.
242+
# One jq pass emits, per PR ref: number, total bytes, and the ids.
243+
candidates=$(printf '%s' "$caches" | jq -r '
244+
[.[] | select(.ref | test("^refs/pull/[0-9]+/merge$"))]
245+
| group_by(.ref) | .[]
246+
| "\(.[0].ref | capture("^refs/pull/(?<n>[0-9]+)/merge$").n)\t\(map(.sizeInBytes) | add)\t\(map(.id) | join(","))"')
247+
248+
if [ -z "$candidates" ]; then
249+
echo "No PR-scoped caches at all — nothing to sweep."
250+
exit 0
251+
fi
252+
253+
freed=0
254+
swept=0
255+
unresolved=0
256+
errlog="$RUNNER_TEMP/pr-view.err"
257+
: > "$errlog"
258+
while IFS=$'\t' read -r num bytes ids; do
259+
mib=$(( bytes / 1048576 ))
260+
261+
# An OPEN PR's caches are still live — leave them. Anything else
262+
# (CLOSED or MERGED) can never be read again.
263+
state=$(gh pr view "$num" --json state --jq .state 2>>"$errlog" || echo UNKNOWN)
264+
if [ "$state" = "OPEN" ]; then
265+
continue
266+
fi
267+
if [ "$state" = "UNKNOWN" ]; then
268+
# A cache ref with no resolvable PR. Left alone deliberately:
269+
# deleting on a failed lookup would turn a transient API error
270+
# into data loss, and the next run retries for free. Counted and
271+
# surfaced below so a PERSISTENT cause (a missing scope, a rate
272+
# limit) cannot hide behind the same message run after run.
273+
echo " #$num: state unresolved, skipping this run"
274+
unresolved=$(( unresolved + 1 ))
275+
continue
276+
fi
277+
278+
echo "#$num ($state): sweeping ${mib} MiB"
279+
# Delete by id, the same idiom as prune-main: unambiguous, and a
280+
# single 404 (LRU took the entry between the list and now) neither
281+
# aborts the loop nor counts toward `freed`.
282+
ref_freed=0
283+
for id in ${ids//,/ }; do
284+
size=$(printf '%s' "$caches" | jq --argjson i "$id" '.[] | select(.id == $i) | .sizeInBytes')
285+
if gh cache delete "$id"; then
286+
ref_freed=$(( ref_freed + size ))
287+
else
288+
echo " #$num: delete of $id failed or already gone"
289+
fi
290+
done
291+
if [ "$ref_freed" -gt 0 ]; then
292+
freed=$(( freed + ref_freed / 1048576 ))
293+
swept=$(( swept + 1 ))
294+
fi
295+
done <<< "$candidates"
296+
297+
echo "Swept $swept closed PR(s), freeing ~${freed} MiB."
298+
if [ "$unresolved" -gt 0 ]; then
299+
echo "::warning::$unresolved PR state lookup(s) failed; their caches were left alone. gh stderr:"
300+
cat "$errlog"
301+
fi
302+
303+
# Same line prune-main prints, kept as a copy: a shared always() job was
304+
# tried and rejected (see prune-main). If you change one, change both.
305+
- name: Report repo cache usage
306+
if: always()
307+
env:
308+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
309+
run: |
310+
set -euo pipefail
311+
gh api "repos/${{ github.repository }}/actions/cache/usage" \
312+
--jq '"Repo Actions cache: \(.active_caches_count) entries, \((.active_caches_size_in_bytes/1073741824)*100|floor/100) GiB of 10 GiB allowance"' \
313+
| tee -a "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)