Skip to content
Merged
Changes from 1 commit
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
147 changes: 147 additions & 0 deletions .github/workflows/actions-cache-cleanup.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +19 to +26

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
Comment on lines +119 to +125

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
Loading