Warning, file /acts/CI/prune_ccache.py was not indexed
or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).
0001
0002 """Retain the newest main compiler cache per variant and archive format.
0003
0004 Dry-run by default. Only SHA-suffixed ccache keys are eligible; an uploaded,
0005 non-empty replacement must exist before an older archive can be deleted.
0006 """
0007
0008 import argparse
0009 from collections import defaultdict
0010 import json
0011 import os
0012 from pathlib import Path
0013 import re
0014 import subprocess
0015
0016 MAIN_REF = "refs/heads/main"
0017 CACHE_KEY = re.compile(r"^(ccache-.+-r\d+(?:-.+)?)-[0-9a-f]{40}$")
0018
0019
0020 def superseded_caches(caches):
0021 """Return (replacement, obsolete archives) pairs, preserving cache versions."""
0022 groups = defaultdict(list)
0023 for cache in caches:
0024 match = CACHE_KEY.fullmatch(cache["key"])
0025 if (
0026 cache["ref"] == MAIN_REF
0027 and match
0028 and cache.get("version")
0029 and cache["size_in_bytes"] > 0
0030 ):
0031 groups[(match[1], cache["version"])].append(cache)
0032 result = []
0033 for group in groups.values():
0034 ordered = sorted(group, key=lambda c: (c["created_at"], c["id"]), reverse=True)
0035 if len(ordered) > 1:
0036 result.append((ordered[0], ordered[1:]))
0037 return result
0038
0039
0040 def list_caches(repo, key="ccache-"):
0041
0042
0043 output = subprocess.check_output(
0044 [
0045 "gh",
0046 "api",
0047 "--method",
0048 "GET",
0049 "--paginate",
0050 "--slurp",
0051 f"repos/{repo}/actions/caches",
0052 "-f",
0053 f"ref={MAIN_REF}",
0054 "-f",
0055 f"key={key}",
0056 "-f",
0057 "per_page=100",
0058 ],
0059 text=True,
0060 )
0061 return [cache for page in json.loads(output) for cache in page["actions_caches"]]
0062
0063
0064 def prune(repo, caches, apply=False):
0065 total = 0
0066 for replacement, obsolete in superseded_caches(caches):
0067 if apply:
0068
0069
0070
0071 available = list_caches(repo, replacement["key"])
0072 if not any(
0073 c["id"] == replacement["id"] and c["size_in_bytes"] > 0
0074 for c in available
0075 ):
0076 print(f"Keep older caches: replacement {replacement['id']} disappeared")
0077 continue
0078 print(f"Keep {replacement['id']}: {replacement['key']}")
0079 for cache in obsolete:
0080 print(
0081 f"{'Delete' if apply else 'Would delete'} {cache['id']}: {cache['key']}"
0082 )
0083 if apply:
0084 subprocess.run(
0085 [
0086 "gh",
0087 "api",
0088 "--method",
0089 "DELETE",
0090 f"repos/{repo}/actions/caches/{cache['id']}",
0091 ],
0092 check=True,
0093 )
0094 total += cache["size_in_bytes"]
0095 print(f"{'Removed' if apply else 'Reclaimable'} archive bytes: {total:,}")
0096 return total
0097
0098
0099 def main():
0100 parser = argparse.ArgumentParser(description=__doc__)
0101 parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"))
0102 parser.add_argument(
0103 "--apply", action="store_true", help="Delete superseded archives"
0104 )
0105 parser.add_argument(
0106 "--snapshot", type=Path, help="Dry-run a saved cache API response"
0107 )
0108 args = parser.parse_args()
0109 if args.apply and args.snapshot:
0110 parser.error("--apply requires a fresh API listing, not --snapshot")
0111 if not args.snapshot and not args.repo:
0112 parser.error("--repo or GITHUB_REPOSITORY is required")
0113 caches = (
0114 json.loads(args.snapshot.read_text())["actions_caches"]
0115 if args.snapshot
0116 else list_caches(args.repo)
0117 )
0118 prune(args.repo, caches, args.apply)
0119
0120
0121 if __name__ == "__main__":
0122 main()