diff --git a/.github/workflows/actions-cache-cleanup.yml b/.github/workflows/actions-cache-cleanup.yml new file mode 100644 index 000000000..ca23c9014 --- /dev/null +++ b/.github/workflows/actions-cache-cleanup.yml @@ -0,0 +1,159 @@ +# ============================================================================= +# GitHub Actions Cache Cleanup +# ----------------------------------------------------------------------------- +# Deletes stale PR merge-ref caches so the repository stays below GitHub's +# Actions cache storage limit without evicting useful main-branch caches. +# ============================================================================= + +name: Actions Cache Cleanup + +on: + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + inputs: + minimum_age_days: + description: "Delete pull-ref caches last accessed at least this many days ago" + required: false + default: "3" + dry_run: + description: "Report matching caches without deleting them" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + +permissions: + actions: write + +concurrency: + group: actions-cache-cleanup + cancel-in-progress: false + +jobs: + cleanup: + name: Delete stale pull-request caches + runs-on: ubuntu-latest + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MINIMUM_AGE_DAYS: ${{ inputs.minimum_age_days || '3' }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + + steps: + - name: Delete stale pull-request merge-ref caches + shell: bash + run: | + set -euo pipefail + + python3 <<'PY' + from __future__ import annotations + + import json + import os + import sys + import urllib.error + import urllib.request + from datetime import datetime, timedelta, timezone + + token = os.environ["GITHUB_TOKEN"] + repository = os.environ["GITHUB_REPOSITORY"] + dry_run = os.environ["DRY_RUN"].casefold() == "true" + + try: + minimum_age_days = int(os.environ["MINIMUM_AGE_DAYS"]) + except ValueError: + print("::error::minimum_age_days must be an integer", file=sys.stderr) + sys.exit(1) + + if minimum_age_days < 0: + print("::error::minimum_age_days must not be negative", file=sys.stderr) + sys.exit(1) + + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + api_root = f"https://api.github.com/repos/{repository}/actions/caches" + cutoff = datetime.now(timezone.utc) - timedelta(days=minimum_age_days) + + def request_json(url: str) -> dict[str, object]: + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + + def delete_cache(cache_id: int) -> None: + request = urllib.request.Request( + f"{api_root}/{cache_id}", + headers=headers, + method="DELETE", + ) + try: + with urllib.request.urlopen(request, timeout=30): + return + except urllib.error.HTTPError as error: + if error.code == 404: + print(f"cache id {cache_id} disappeared before deletion") + return + raise + + caches: list[dict[str, object]] = [] + page = 1 + while True: + payload = request_json(f"{api_root}?per_page=100&page={page}") + page_items = payload.get("actions_caches", []) + if not isinstance(page_items, list): + print("::error::unexpected cache API response shape", file=sys.stderr) + sys.exit(1) + + caches.extend(item for item in page_items if isinstance(item, dict)) + if len(page_items) < 100: + break + page += 1 + + targets: list[dict[str, object]] = [] + for cache in caches: + ref = str(cache.get("ref", "")) + last_accessed = str(cache.get("last_accessed_at", "")) + # Only PR merge-test refs: refs/pull//merge. The PR head + # refs (refs/pull//head) are preserved because their caches + # often back the active build for the PR head SHA. + if not (ref.startswith("refs/pull/") and ref.endswith("/merge")): + continue + + if not last_accessed: + print(f"::warning::cache id {cache.get('id')} ref={ref} has no last_accessed_at; skipping") + continue + try: + accessed_at = datetime.fromisoformat(last_accessed.replace("Z", "+00:00")) + except ValueError: + print( + f"::warning::cache id {cache.get('id')} ref={ref} " + f"has invalid last_accessed_at={last_accessed!r}; skipping" + ) + continue + if accessed_at <= cutoff: + targets.append(cache) + + total_bytes = sum(int(cache.get("size_in_bytes", 0)) for cache in targets) + total_gib = total_bytes / 1024 / 1024 / 1024 + action = "Would delete" if dry_run else "Deleting" + print( + f"{action} {len(targets)} pull-ref cache(s), " + f"{total_gib:.2f} GiB, last accessed before {cutoff.isoformat()}." + ) + + for cache in targets: + cache_id = int(cache["id"]) + cache_ref = str(cache.get("ref", "")) + cache_key = str(cache.get("key", "")) + cache_size = int(cache.get("size_in_bytes", 0)) / 1024 / 1024 + print(f"{cache_id} {cache_ref} {cache_size:.2f} MiB {cache_key}") + if not dry_run: + delete_cache(cache_id) + + print("Cache cleanup complete.") + PY