Cleanup All Workflow Runs #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Cleanup All Workflow Runs | |
on: | |
workflow_dispatch: # Manual trigger | |
schedule: | |
- cron: "0 0 * * 0" # Weekly on Sunday at midnight UTC | |
permissions: | |
actions: write # Required to delete workflow runs | |
contents: read | |
jobs: | |
cleanup: | |
runs-on: ubuntu-latest | |
steps: | |
- name: Checkout Repository | |
uses: actions/checkout@v4 | |
- name: Setup Python 3.13 | |
uses: actions/setup-python@v5 | |
with: | |
python-version: '3.13' | |
- name: Run Cleanup Script | |
env: | |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
GITHUB_REPOSITORY: ${{ github.repository }} | |
DELETE_WITHIN_DAYS: 30 | |
KEEP_MINIMUM_RUNS: 0 | |
run: | | |
python <<'EOF' | |
#!/usr/bin/env python3 | |
""" | |
Author: Diwas Neupane | |
Purpose: Delete all GitHub Actions workflow runs (except in-progress ones) created within the last DELETE_WITHIN_DAYS. | |
Keeps KEEP_MINIMUM_RUNS most recent runs if specified. | |
Pure stdlib (urllib), handles pagination. | |
""" | |
import os | |
import json | |
from datetime import datetime, timedelta, timezone | |
from urllib import request, error, parse | |
# --- Configuration --- | |
token = os.getenv("GITHUB_TOKEN") | |
repo = os.getenv("GITHUB_REPOSITORY") | |
delete_within_days = int(os.getenv("DELETE_WITHIN_DAYS", "30")) | |
keep_minimum = int(os.getenv("KEEP_MINIMUM_RUNS", "0")) | |
if not token or not repo: | |
print("❌ ERROR: Missing GITHUB_TOKEN or GITHUB_REPOSITORY environment variables.") | |
exit(1) | |
if keep_minimum < 0: | |
print(f"❌ ERROR: KEEP_MINIMUM_RUNS must be zero or positive (got {keep_minimum}).") | |
exit(1) | |
headers = { | |
"Authorization": f"token {token}", | |
"Accept": "application/vnd.github.v3+json", | |
"User-Agent": "cleanup-script" | |
} | |
cutoff_date = datetime.now(timezone.utc) - timedelta(days=delete_within_days) | |
print(f"🧹 Deletion policy: targeting runs created after {cutoff_date.isoformat()}") | |
print(f"🛡️ Safety policy: keeping {keep_minimum} most recent runs") | |
def github_get(url, params=None): | |
if params: | |
url += "?" + parse.urlencode(params) | |
req = request.Request(url, headers=headers) | |
with request.urlopen(req) as resp: | |
return json.loads(resp.read().decode()) | |
def github_delete(url): | |
req = request.Request(url, headers=headers, method="DELETE") | |
try: | |
with request.urlopen(req) as resp: | |
return resp.status == 204 | |
except error.HTTPError as e: | |
print(f"❌ Failed to delete run: {e}") | |
return False | |
# --- Fetch all workflow runs (paginated) --- | |
all_runs = [] | |
page = 1 | |
while True: | |
api_url = f"https://api.github.com/repos/{repo}/actions/runs" | |
data = github_get(api_url, {"per_page": 100, "page": page}) | |
runs = data.get("workflow_runs", []) | |
if not runs: | |
break | |
all_runs.extend(runs) | |
page += 1 | |
print(f"📦 Fetched {len(all_runs)} total workflow runs") | |
# --- Filter runs: skip in_progress, filter by cutoff date --- | |
eligible_runs = [] | |
for run in all_runs: | |
if run.get("status") == "in_progress": | |
continue # Skip runs still running | |
try: | |
created_at = datetime.strptime(run["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) | |
except (KeyError, ValueError): | |
print(f"⚠️ Skipping run with invalid 'created_at': {run}") | |
continue | |
if created_at >= cutoff_date: | |
eligible_runs.append(run) | |
if not eligible_runs: | |
print("✅ No workflow runs eligible for deletion within the time window.") | |
exit(0) | |
# Sort runs newest first to preserve the most recent ones | |
eligible_runs.sort(key=lambda r: datetime.strptime(r["created_at"], "%Y-%m-%dT%H:%M:%SZ"), reverse=True) | |
if keep_minimum > 0 and len(eligible_runs) <= keep_minimum: | |
print(f"ℹ️ Found {len(eligible_runs)} runs but minimum keep threshold is {keep_minimum}. Nothing to delete.") | |
exit(0) | |
# Determine which runs to delete based on keep_minimum | |
runs_to_delete = eligible_runs if keep_minimum == 0 else eligible_runs[keep_minimum:] | |
print(f"🗑️ Deleting {len(runs_to_delete)} runs...") | |
# Delete runs one-by-one | |
for run in runs_to_delete: | |
run_id = run.get("id") | |
if not run_id: | |
print(f"⚠️ Skipping run without an ID: {run}") | |
continue | |
delete_url = f"https://api.github.com/repos/{repo}/actions/runs/{run_id}" | |
if github_delete(delete_url): | |
print(f"✅ Deleted run ID {run_id} (Created at {run['created_at']})") | |
print("🎉 Cleanup finished successfully.") | |
EOF |