Branch data Line data Source code
1 : : #!/usr/bin/env python3
2 : :
3 : 1 : """Prune stale GitHub Actions caches for a given key prefix."""
4 : :
5 : 1 : from __future__ import annotations
6 : :
7 : 1 : import argparse
8 : 1 : import json
9 : 1 : import os
10 : 1 : import sys
11 : 1 : import urllib.error
12 : 1 : import urllib.parse
13 : 1 : import urllib.request
14 : 1 : from collections import defaultdict
15 : 1 : from datetime import datetime, timezone
16 : 1 : from typing import Any
17 : :
18 : :
19 : 1 : def _github_request(
20 : : method: str, url: str, token: str
21 : : ) -> dict[str, Any] | list[dict[str, Any]] | None:
22 : 0 : request = urllib.request.Request(
23 : : url,
24 : : method=method,
25 : : headers={
26 : : "Accept": "application/vnd.github+json",
27 : : "Authorization": f"Bearer {token}",
28 : : "X-GitHub-Api-Version": "2022-11-28",
29 : : },
30 : : )
31 : 0 : try:
32 : 0 : with urllib.request.urlopen(request) as response:
33 [ # ]: 0 : payload = response.read().decode("utf-8")
34 : 0 : except urllib.error.HTTPError as exc:
35 : 0 : if exc.code in {403, 404}:
36 [ # ]: 0 : message = exc.read().decode("utf-8", errors="replace")
37 : 0 : print(
38 : : f"Skipping cache pruning for {url}: HTTP {exc.code} {message}",
39 : : file=sys.stderr,
40 : : )
41 : 0 : return None
42 [ # ]: 0 : raise
43 [ # ]: 0 : if not payload:
44 [ # ]: 0 : return None
45 [ # ]: 0 : return json.loads(payload)
46 : :
47 : :
48 : 1 : def _delete_cache(repo: str, cache_id: int, token: str) -> bool:
49 : 0 : url = f"https://api.github.com/repos/{repo}/actions/caches/{cache_id}"
50 : 0 : request = urllib.request.Request(
51 : : url,
52 : : method="DELETE",
53 : : headers={
54 : : "Accept": "application/vnd.github+json",
55 : : "Authorization": f"Bearer {token}",
56 : : "X-GitHub-Api-Version": "2022-11-28",
57 : : },
58 : : )
59 : 0 : try:
60 : 0 : with urllib.request.urlopen(request):
61 [ # ]: 0 : return True
62 : 0 : except urllib.error.HTTPError as exc:
63 : 0 : if exc.code in {403, 404}:
64 [ # ]: 0 : message = exc.read().decode("utf-8", errors="replace")
65 : 0 : print(
66 : : f"Skipping cache deletion for {cache_id}: "
67 : : f"HTTP {exc.code} {message}",
68 : : file=sys.stderr,
69 : : )
70 : 0 : return False
71 [ # ]: 0 : raise
72 : :
73 : :
74 : 1 : def _branch_exists(repo: str, branch: str, token: str) -> bool:
75 : 0 : quoted_branch = urllib.parse.quote(branch, safe="")
76 : 0 : url = f"https://api.github.com/repos/{repo}/branches/{quoted_branch}"
77 : 0 : request = urllib.request.Request(
78 : : url,
79 : : method="GET",
80 : : headers={
81 : : "Accept": "application/vnd.github+json",
82 : : "Authorization": f"Bearer {token}",
83 : : "X-GitHub-Api-Version": "2022-11-28",
84 : : },
85 : : )
86 : 0 : try:
87 : 0 : with urllib.request.urlopen(request):
88 [ # ]: 0 : return True
89 : 0 : except urllib.error.HTTPError as exc:
90 : 0 : if exc.code == 404:
91 [ # ]: 0 : return False
92 [ # ]: 0 : if exc.code == 403:
93 [ # ]: 0 : message = exc.read().decode("utf-8", errors="replace")
94 : 0 : print(
95 : : f"Could not check whether branch {branch!r} exists: "
96 : : f"HTTP {exc.code} {message}. Keeping its caches.",
97 : : file=sys.stderr,
98 : : )
99 : 0 : return True
100 [ # ]: 0 : raise
101 : :
102 : :
103 : 1 : def _list_matching_caches(
104 : : repo: str, prefix: str, token: str
105 : : ) -> list[dict[str, Any]]:
106 : 0 : caches: list[dict[str, Any]] = []
107 : 0 : page = 1
108 : 0 : while True:
109 : 0 : query = urllib.parse.urlencode({"per_page": 100, "page": page})
110 : 0 : url = f"https://api.github.com/repos/{repo}/actions/caches?{query}"
111 : 0 : response = _github_request("GET", url, token)
112 : 0 : if response is None:
113 [ # ]: 0 : return []
114 [ # ]: 0 : page_entries = response.get("actions_caches", [])
115 : 0 : caches.extend(
116 : : entry for entry in page_entries if entry["key"].startswith(prefix)
117 : : )
118 [ # ]: 0 : if len(page_entries) < 100:
119 [ # ]: 0 : return caches
120 [ # ]: 0 : page += 1
121 : :
122 : :
123 : 1 : def _sort_key(entry: dict[str, Any]) -> tuple[datetime, int]:
124 : 0 : created_at = entry.get("created_at") or "1970-01-01T00:00:00Z"
125 : 0 : try:
126 : 0 : timestamp = datetime.fromisoformat(created_at)
127 : 0 : except ValueError:
128 : 0 : timestamp = datetime(1970, 1, 1, tzinfo=timezone.utc)
129 : 0 : return timestamp, int(entry["id"])
130 : :
131 : :
132 : 1 : def _branch_name_from_cache_key(key: str, prefix: str) -> str | None:
133 : 1 : if not key.startswith(prefix):
134 [ + ]: 1 : return None
135 [ + ]: 1 : suffix = key[len(prefix) :]
136 : :
137 : 1 : def branch_from_run_suffix(value: str) -> str | None:
138 : 1 : branch, separator, run_id = value.rpartition("-")
139 : 1 : if not separator or not branch or not run_id.isdigit():
140 [ + ]: 1 : return None
141 [ + ]: 1 : return branch
142 : :
143 : 1 : branch_and_run, attempt_separator, attempt = suffix.rpartition("-attempt")
144 : 1 : if attempt_separator and attempt.isdigit():
145 [ + ]: 1 : branch = branch_from_run_suffix(branch_and_run)
146 : 1 : if branch is not None:
147 [ + ]: 1 : return branch
148 : :
149 [ + ][ # ]: 1 : branch = branch_from_run_suffix(suffix)
150 : 1 : if branch is None:
151 [ + ]: 1 : return None
152 [ + ]: 1 : return branch
153 : :
154 : :
155 : 1 : def _legacy_stale_caches(
156 : : caches: list[dict[str, Any]], keep: int
157 : : ) -> list[dict[str, Any]]:
158 : 0 : return sorted(caches, key=_sort_key, reverse=True)[keep:]
159 : :
160 : :
161 : 1 : def _stale_caches_by_branch(
162 : : repo: str,
163 : : caches: list[dict[str, Any]],
164 : : prefix: str,
165 : : keep: int,
166 : : delete_missing_branches: bool,
167 : : token: str,
168 : : ) -> list[dict[str, Any]]:
169 : 0 : grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
170 : 0 : ungrouped: list[dict[str, Any]] = []
171 : 0 : for cache in caches:
172 [ # ]: 0 : branch = _branch_name_from_cache_key(cache["key"], prefix)
173 : 0 : if branch is None:
174 [ # ]: 0 : ungrouped.append(cache)
175 : 0 : continue
176 [ # ]: 0 : grouped[branch].append(cache)
177 : :
178 [ # ]: 0 : stale_caches: dict[int, dict[str, Any]] = {}
179 : 0 : branch_exists_cache: dict[str, bool] = {}
180 [ # ]: 0 : for branch, branch_caches in grouped.items():
181 [ # ]: 0 : if delete_missing_branches:
182 [ # ]: 0 : branch_exists_cache[branch] = _branch_exists(repo, branch, token)
183 : 0 : if not branch_exists_cache[branch]:
184 [ # ]: 0 : print(
185 : : f"Branch {branch!r} no longer exists; deleting its caches"
186 : : )
187 : 0 : for cache in branch_caches:
188 [ # ]: 0 : stale_caches[int(cache["id"])] = cache
189 [ # ]: 0 : continue
190 : :
191 [ # ][ # ]: 0 : sorted_branch_caches = sorted(
192 : : branch_caches, key=_sort_key, reverse=True
193 : : )
194 : 0 : for cache in sorted_branch_caches[keep:]:
195 [ # ]: 0 : stale_caches[int(cache["id"])] = cache
196 : :
197 [ # ]: 0 : if ungrouped:
198 [ # ]: 0 : print(
199 : : f"Skipping {len(ungrouped)} caches whose keys do not match "
200 : : f"'<prefix><branch>-<run_id>[-attempt<attempt>]' "
201 : : f"for prefix {prefix}",
202 : : file=sys.stderr,
203 : : )
204 [ # ]: 0 : return sorted(stale_caches.values(), key=_sort_key, reverse=True)
205 : :
206 : :
207 : 1 : def main() -> int:
208 : : """Delete stale caches so only the newest entries per prefix remain."""
209 : 0 : parser = argparse.ArgumentParser()
210 : 0 : parser.add_argument("--repo", required=True)
211 : 0 : parser.add_argument("--prefix", required=True)
212 : 0 : parser.add_argument("--keep", type=int, default=1)
213 : 0 : parser.add_argument(
214 : : "--group-by-branch",
215 : : action="store_true",
216 : : help="Treat prefix as a suite prefix and keep N caches per branch.",
217 : : )
218 : 0 : parser.add_argument(
219 : : "--delete-missing-branches",
220 : : action="store_true",
221 : : help="Delete matching branch caches when the branch no longer exists.",
222 : : )
223 : 0 : args = parser.parse_args()
224 : 0 : if args.keep < 0:
225 [ # ]: 0 : print("--keep must be non-negative", file=sys.stderr)
226 : 0 : return 2
227 [ # ]: 0 : if args.delete_missing_branches and not args.group_by_branch:
228 [ # ]: 0 : print(
229 : : "--delete-missing-branches requires --group-by-branch",
230 : : file=sys.stderr,
231 : : )
232 : 0 : return 2
233 : :
234 [ # ]: 0 : token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
235 : 0 : if not token:
236 [ # ]: 0 : print("GH_TOKEN or GITHUB_TOKEN is required", file=sys.stderr)
237 : 0 : return 1
238 : :
239 [ # ]: 0 : caches = _list_matching_caches(args.repo, args.prefix, token)
240 : 0 : if args.group_by_branch:
241 [ # ]: 0 : stale_caches = _stale_caches_by_branch(
242 : : args.repo,
243 : : caches,
244 : : args.prefix,
245 : : args.keep,
246 : : args.delete_missing_branches,
247 : : token,
248 : : )
249 : : else:
250 [ # ]: 0 : stale_caches = _legacy_stale_caches(caches, args.keep)
251 : 0 : if not stale_caches:
252 [ # ]: 0 : print(f"No stale caches found for prefix {args.prefix}")
253 : 0 : return 0
254 : :
255 [ # ][ # ]: 0 : for cache in stale_caches:
256 [ # ]: 0 : cache_id = cache["id"]
257 : 0 : cache_key = cache["key"]
258 : 0 : if _delete_cache(args.repo, cache_id, token):
259 [ # ]: 0 : print(f"Deleted cache {cache_id} ({cache_key})")
260 [ # ]: 0 : return 0
261 : :
262 : :
263 : 1 : if __name__ == "__main__":
264 [ # ]: 0 : raise SystemExit(main())
|