From fa973ac7acacc76e293ccfefe23e575cfb7bae81 Mon Sep 17 00:00:00 2001 From: ancplua Date: Tue, 12 May 2026 00:49:07 +0200 Subject: [PATCH 1/2] Add weekly GitHub Actions cache cleanup --- .github/workflows/actions-cache-cleanup.yml | 147 ++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .github/workflows/actions-cache-cleanup.yml diff --git a/.github/workflows/actions-cache-cleanup.yml b/.github/workflows/actions-cache-cleanup.yml new file mode 100644 index 000000000..7f50a580c --- /dev/null +++ b/.github/workflows/actions-cache-cleanup.yml @@ -0,0 +1,147 @@ +# ============================================================================= +# 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 + contents: read + +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", "")) + if not ref.startswith("refs/pull/"): + continue + + accessed_at = datetime.fromisoformat(last_accessed.replace("Z", "+00:00")) + 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 From c5e629a60b8a7c59caad08047146097f11eafbd9 Mon Sep 17 00:00:00 2001 From: ancplua Date: Tue, 12 May 2026 04:30:20 +0200 Subject: [PATCH 2/2] fix: address Copilot review on actions-cache-cleanup Three findings from the copilot-pull-request-reviewer pass on PR #325, all valid: 1. Filter mismatch (blocker, line 122): the previous predicate `ref.startswith("refs/pull/")` matched both refs/pull//head and refs/pull//merge, but the PR description and the workflow header comment say only the merge-test refs should be deleted. The head refs back the active build for the open PR's head SHA and are still useful while the PR is open. Tighten to require the `/merge` suffix; head caches are now preserved. 2. fromisoformat fragility (important, line 125): if last_accessed_at is missing/empty or malformed, datetime.fromisoformat raises and aborts the whole cleanup run. Skip those entries with a clear ::warning:: instead so one bad cache record cannot break the job. Also handle the empty-string case explicitly (str(None) -> "None" but cache.get(..., "") -> "" is the actual path). 3. Least-privilege permissions (important, line 30): the job only talks to the actions/caches API; it does not check out the repo or read repository contents. Drop `contents: read` so the workflow token grants only `actions: write`. --- .github/workflows/actions-cache-cleanup.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/actions-cache-cleanup.yml b/.github/workflows/actions-cache-cleanup.yml index 7f50a580c..ca23c9014 100644 --- a/.github/workflows/actions-cache-cleanup.yml +++ b/.github/workflows/actions-cache-cleanup.yml @@ -27,7 +27,6 @@ on: permissions: actions: write - contents: read concurrency: group: actions-cache-cleanup @@ -119,10 +118,23 @@ jobs: for cache in caches: ref = str(cache.get("ref", "")) last_accessed = str(cache.get("last_accessed_at", "")) - if not ref.startswith("refs/pull/"): + # 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 - accessed_at = datetime.fromisoformat(last_accessed.replace("Z", "+00:00")) + 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)