Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions .github/workflows/actions-cache-cleanup.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +19 to +26

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/<N>/merge. The PR head
# refs (refs/pull/<N>/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
Comment on lines +119 to +125

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
Loading