Skip to content

Commit e62a40c

Browse files
authored
Merge pull request #6151 from hjmjohnson/prune-superseded-ci-caches
COMP: Prune superseded GitHub Actions ccache entries from CI
2 parents 3b813e8 + b4f29cf commit e62a40c

3 files changed

Lines changed: 304 additions & 0 deletions

File tree

.github/workflows/arm.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,28 @@ jobs:
214214
- name: ccache stats
215215
if: always()
216216
run: ccache --show-stats
217+
218+
prune-superseded-caches:
219+
# Background housekeeping: removes ccache entries whose SHA-suffixed
220+
# key has been superseded by a newer build of the same (ref, OS).
221+
# ccache restore-keys still falls back to the newest prefix match,
222+
# so older entries are dead weight under the 10 GB repo cache budget.
223+
needs: build
224+
if: always()
225+
runs-on: ubuntu-24.04
226+
permissions:
227+
actions: write
228+
contents: read
229+
continue-on-error: true
230+
steps:
231+
- name: Checkout maintenance script
232+
uses: actions/checkout@v5
233+
with:
234+
sparse-checkout: Utilities/Maintenance/PruneSupersededCiCaches.py
235+
sparse-checkout-cone-mode: false
236+
237+
- name: Prune superseded ccache entries
238+
env:
239+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
240+
GITHUB_REPOSITORY: ${{ github.repository }}
241+
run: python3 Utilities/Maintenance/PruneSupersededCiCaches.py

.github/workflows/pixi.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,28 @@ jobs:
187187
- name: ccache stats
188188
if: always()
189189
run: ccache --show-stats
190+
191+
prune-superseded-caches:
192+
# Background housekeeping: removes ccache entries whose SHA-suffixed
193+
# key has been superseded by a newer build of the same (ref, OS).
194+
# ccache restore-keys still falls back to the newest prefix match,
195+
# so older entries are dead weight under the 10 GB repo cache budget.
196+
needs: Pixi-Cxx
197+
if: always()
198+
runs-on: ubuntu-24.04
199+
permissions:
200+
actions: write
201+
contents: read
202+
continue-on-error: true
203+
steps:
204+
- name: Checkout maintenance script
205+
uses: actions/checkout@v5
206+
with:
207+
sparse-checkout: Utilities/Maintenance/PruneSupersededCiCaches.py
208+
sparse-checkout-cone-mode: false
209+
210+
- name: Prune superseded ccache entries
211+
env:
212+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
213+
GITHUB_REPOSITORY: ${{ github.repository }}
214+
run: python3 Utilities/Maintenance/PruneSupersededCiCaches.py
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
#!/usr/bin/env python3
2+
"""Prune superseded GitHub Actions caches for the ITK repository.
3+
4+
ITK's CI workflows seed ccache via cache keys of the form
5+
``ccache-v4-<OS>-<variant>-<sha>``. Each new push produces a new cache
6+
with a fresh SHA suffix, while ``actions/cache`` ``restore-keys`` falls
7+
back to the most recent prefix match. Older SHA-suffixed entries are
8+
therefore dead weight: they consume the per-repository 10 GB cache
9+
budget but never get restored once a newer entry exists.
10+
11+
For each ``(ref, key-prefix-stripped-of-trailing-SHA)`` tuple this
12+
script keeps the newest cache and deletes the rest. It is intended to
13+
run as a non-blocking housekeeping job at the end of each CI workflow.
14+
It deliberately exits 0 even on partial failure so that a missing
15+
``actions: write`` permission (e.g. fork-PR runs) cannot turn a green
16+
build red.
17+
18+
Required environment when run from CI:
19+
- ``GITHUB_TOKEN`` — token with ``actions: write`` scope
20+
- ``GITHUB_REPOSITORY`` — ``owner/repo`` of the target repository
21+
22+
Usage::
23+
24+
python3 PruneSupersededCiCaches.py [--dry-run] [--repo OWNER/REPO]
25+
[--token TOKEN] [--keep N]
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import argparse
31+
import json
32+
import os
33+
import re
34+
import shutil
35+
import subprocess
36+
import sys
37+
from collections import defaultdict
38+
from typing import Any
39+
40+
SHA_RE = re.compile(r"^(?P<prefix>.+)-(?P<sha>[0-9a-f]{40})$")
41+
REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
42+
43+
44+
def _gh(args: list[str], token: str) -> tuple[int, str, str]:
45+
"""Run ``gh`` with the supplied args. Token is passed via env, never argv."""
46+
gh = shutil.which("gh")
47+
if gh is None:
48+
return 127, "", "gh CLI not found on PATH"
49+
env = os.environ.copy()
50+
env["GH_TOKEN"] = token
51+
proc = subprocess.run( # noqa: S603 — fixed argv, token via env
52+
[gh, *args], capture_output=True, text=True, env=env, check=False
53+
)
54+
return proc.returncode, proc.stdout, proc.stderr
55+
56+
57+
REF_RE = re.compile(r"^refs/(?:heads|tags|pull)/[A-Za-z0-9._/+-]+$")
58+
59+
60+
def list_caches(repo: str, token: str, ref: str | None = None) -> list[dict[str, Any]]:
61+
caches: list[dict[str, Any]] = []
62+
page = 1
63+
# Ref scoping: when this script runs on a PR, the GITHUB_TOKEN is
64+
# repo-scoped (not ref-scoped) and would otherwise list and prune
65+
# superseded entries across every ref in the repo, including main
66+
# and unrelated PRs. Filtering by ref keeps each run honest about
67+
# which caches it can touch.
68+
ref_query = f"&ref={ref}" if ref else ""
69+
while True:
70+
path = (
71+
f"/repos/{repo}/actions/caches"
72+
f"?per_page=100&page={page}&sort=created_at&direction=desc"
73+
f"{ref_query}"
74+
)
75+
rc, out, err = _gh(
76+
["api", "-H", "X-GitHub-Api-Version: 2022-11-28", path], token
77+
)
78+
if rc != 0:
79+
print(f"WARN: list caches failed: {err.strip()[:200]}", file=sys.stderr)
80+
return caches
81+
try:
82+
payload = json.loads(out)
83+
except json.JSONDecodeError as exc:
84+
print(f"WARN: cannot parse cache list: {exc}", file=sys.stderr)
85+
return caches
86+
batch = payload.get("actions_caches", [])
87+
caches.extend(batch)
88+
if len(batch) < 100:
89+
break
90+
page += 1
91+
return caches
92+
93+
94+
def delete_cache(repo: str, token: str, cache_id: int) -> bool:
95+
path = f"/repos/{repo}/actions/caches/{cache_id}"
96+
rc, _, err = _gh(["api", "-X", "DELETE", path], token)
97+
if rc == 0:
98+
return True
99+
print(f"WARN: delete cache {cache_id} failed: {err.strip()[:200]}", file=sys.stderr)
100+
return False
101+
102+
103+
def normalize_prefix(key: str) -> str:
104+
"""Strip a trailing ``-<40-hex-sha>`` so siblings group together."""
105+
m = SHA_RE.match(key)
106+
return m.group("prefix") if m else key
107+
108+
109+
def plan_deletions(
110+
caches: list[dict[str, Any]], keep: int
111+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
112+
groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
113+
for c in caches:
114+
# An unexpectedly shaped cache entry (missing ref/key/created_at)
115+
# must not crash the housekeeping run. Skip with a notice instead.
116+
try:
117+
groups[(c["ref"], normalize_prefix(c["key"]))].append(c)
118+
except KeyError as exc:
119+
print(
120+
f"WARN: skipping cache with missing field {exc}: id={c.get('id', '?')}",
121+
file=sys.stderr,
122+
)
123+
keep_list: list[dict[str, Any]] = []
124+
delete_list: list[dict[str, Any]] = []
125+
for members in groups.values():
126+
try:
127+
members.sort(key=lambda c: c["created_at"], reverse=True)
128+
except KeyError:
129+
print("WARN: skipping group with missing created_at", file=sys.stderr)
130+
continue
131+
keep_list.extend(members[:keep])
132+
delete_list.extend(members[keep:])
133+
return keep_list, delete_list
134+
135+
136+
def human_size(n: int) -> str:
137+
return f"{n / 1e9:.2f} GB" if n >= 1e9 else f"{n / 1e6:.1f} MB"
138+
139+
140+
def main(argv: list[str] | None = None) -> int:
141+
parser = argparse.ArgumentParser(description=__doc__)
142+
parser.add_argument(
143+
"--repo",
144+
default=os.environ.get("GITHUB_REPOSITORY"),
145+
help="OWNER/REPO (defaults to $GITHUB_REPOSITORY)",
146+
)
147+
parser.add_argument(
148+
"--token",
149+
default=os.environ.get("GITHUB_TOKEN"),
150+
help="GitHub token with actions:write (defaults to $GITHUB_TOKEN)",
151+
)
152+
parser.add_argument(
153+
"--keep",
154+
type=int,
155+
default=1,
156+
help="How many newest caches to keep per (ref, key-prefix). Default: 1",
157+
)
158+
parser.add_argument(
159+
"--ref",
160+
default=os.environ.get("GITHUB_REF"),
161+
help=(
162+
"Restrict the prune to caches that belong to this ref"
163+
" (e.g. refs/heads/main, refs/pull/123/merge). Defaults to"
164+
" $GITHUB_REF so that PR runs only touch their own caches."
165+
" Pass an empty string to operate repo-wide."
166+
),
167+
)
168+
parser.add_argument(
169+
"--dry-run",
170+
action="store_true",
171+
help="Print the deletion plan without calling DELETE",
172+
)
173+
args = parser.parse_args(argv)
174+
175+
if args.repo and not REPO_RE.match(args.repo):
176+
print(f"INFO: invalid --repo value: {args.repo!r}. Skipping.", file=sys.stderr)
177+
return 0
178+
179+
if args.ref and not REF_RE.match(args.ref):
180+
print(
181+
f"INFO: --ref does not look like a refs/... path: {args.ref!r}. Skipping.",
182+
file=sys.stderr,
183+
)
184+
return 0
185+
186+
# --keep < 1 would empty every group. Treat as a misuse rather than a
187+
# silent wipe; exit 0 to keep CI green.
188+
if args.keep < 1:
189+
print(
190+
f"INFO: --keep must be >= 1 (got {args.keep}). Skipping.",
191+
file=sys.stderr,
192+
)
193+
return 0
194+
195+
if not args.repo or not args.token:
196+
# Housekeeping should never fail a build. Exit 0 with a notice.
197+
print(
198+
"INFO: --repo and --token are required (or $GITHUB_REPOSITORY"
199+
" and $GITHUB_TOKEN). Skipping cache prune.",
200+
file=sys.stderr,
201+
)
202+
return 0
203+
204+
caches = list_caches(args.repo, args.token, ref=args.ref or None)
205+
if not caches:
206+
print("INFO: no caches found (or insufficient permissions). Nothing to do.")
207+
return 0
208+
209+
keep_list, delete_list = plan_deletions(caches, keep=args.keep)
210+
total = sum(c["size_in_bytes"] for c in caches)
211+
keep_bytes = sum(c["size_in_bytes"] for c in keep_list)
212+
drop_bytes = sum(c["size_in_bytes"] for c in delete_list)
213+
214+
print(f"Repo: {args.repo}")
215+
print(f"Caches: {len(caches)} ({human_size(total)})")
216+
print(f"Keeping: {len(keep_list)} ({human_size(keep_bytes)})")
217+
print(f"Pruning: {len(delete_list)} ({human_size(drop_bytes)})")
218+
219+
if not delete_list:
220+
return 0
221+
222+
deleted = 0
223+
freed = 0
224+
try:
225+
sorted_delete_list = sorted(
226+
delete_list, key=lambda c: (c["ref"], c["created_at"])
227+
)
228+
except KeyError as exc:
229+
print(f"WARN: cannot sort deletion list: missing {exc}", file=sys.stderr)
230+
return 0
231+
for c in sorted_delete_list:
232+
try:
233+
action = "DRY-RUN" if args.dry_run else "DELETE "
234+
print(
235+
f" {action} id={c['id']:>12} {c['ref']:<28} "
236+
f"{c['size_in_bytes'] / 1e6:>7.1f} MB {c['key'][:80]}"
237+
)
238+
if args.dry_run or delete_cache(args.repo, args.token, c["id"]):
239+
deleted += 1
240+
freed += c["size_in_bytes"]
241+
except KeyError as exc:
242+
print(
243+
f"WARN: skipping cache with missing field {exc}",
244+
file=sys.stderr,
245+
)
246+
continue
247+
248+
verb = "Would free" if args.dry_run else "Freed"
249+
print(f"{verb} {human_size(freed)} across {deleted} cache(s).")
250+
return 0
251+
252+
253+
if __name__ == "__main__":
254+
sys.exit(main())

0 commit comments

Comments
 (0)