forked from phase-rs/phase
-
Notifications
You must be signed in to change notification settings - Fork 0
206 lines (191 loc) · 9.78 KB
/
Copy pathcache-janitor.yml
File metadata and controls
206 lines (191 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
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"'