|
| 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