LCOV - code coverage report
Current view: top level - scripts - prune_actions_caches.py (source / functions) Coverage Total Hit
Test: EnvPool coverage report Lines: 25.7 % 152 39
Test Date: 2026-05-15 23:28:50 Functions: - 0 0
Legend: Lines: hit not hit | Branches: + taken - not taken # not executed Branches: - 0 0

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

Generated by: LCOV version 2.0-1