Cleanup Old Workflow Runs #358
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 Old Workflow Runs | |
| on: | |
| schedule: | |
| - cron: '0 * * * *' # Run every Sunday at 3 AM UTC | |
| workflow_dispatch: | |
| inputs: | |
| retain_days: | |
| description: 'Delete runs older than this many days' | |
| required: false | |
| type: number | |
| default: '30' | |
| keep_minimum_runs: | |
| description: 'Always keep at least this many recent runs per workflow' | |
| required: false | |
| type: number | |
| default: '5' | |
| dry_run: | |
| description: 'Dry run — log what would be deleted without actually deleting' | |
| required: false | |
| type: boolean | |
| default: false | |
| max_deletions: | |
| description: 'Maximum number of runs to delete per execution (prevents rate limiting)' | |
| required: false | |
| type: number | |
| default: 300 | |
| permissions: | |
| actions: write # Required to delete workflow runs | |
| jobs: | |
| cleanup: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: 🧹 Delete old workflow runs | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| RETAIN_DAYS: ${{ inputs.retain_days || '30' }} | |
| KEEP_MINIMUM_RUNS: ${{ inputs.keep_minimum_runs || '5' }} | |
| DRY_RUN: ${{ inputs.dry_run || 'false' }} | |
| MAX_DELETIONS: ${{ inputs.max_deletions || '100' }} | |
| run: | | |
| set -euo pipefail | |
| # Strip any decimal suffix (e.g., 30.0 → 30) | |
| RETAIN_DAYS=${RETAIN_DAYS%.*} | |
| KEEP_MINIMUM_RUNS=${KEEP_MINIMUM_RUNS%.*} | |
| MAX_DELETIONS=${MAX_DELETIONS%.*} | |
| TOTAL_DELETED=0 | |
| TOTAL_RETAINED=0 | |
| SUMMARY_ROWS="" | |
| # Cutoff timestamp in seconds since epoch | |
| CUTOFF=$(date -d "-${RETAIN_DAYS} days" +%s) | |
| echo "ℹ️ retain_days=${RETAIN_DAYS} keep_minimum_runs=${KEEP_MINIMUM_RUNS} dry_run=${DRY_RUN} max_deletions=${MAX_DELETIONS}" | |
| echo "ℹ️ Cutoff date: $(date -d "-${RETAIN_DAYS} days" --utc +%Y-%m-%dT%H:%M:%SZ)" | |
| RATE_REMAINING=$(gh api rate_limit --jq '.rate.remaining' 2>/dev/null || echo "unknown") | |
| echo "ℹ️ API rate limit remaining: ${RATE_REMAINING}" | |
| # Fetch all workflows (paginated) | |
| WORKFLOWS=$(gh api "repos/${REPO}/actions/workflows?per_page=100" \ | |
| --paginate \ | |
| --jq '.workflows[] | "\(.id)\t\(.name)"') | |
| while IFS=$'\t' read -r WF_ID WF_NAME; do | |
| echo "" | |
| echo "▶ Processing workflow: ${WF_NAME} (id=${WF_ID})" | |
| # Collect completed run IDs and their created_at timestamps, | |
| # newest first (API default ordering is newest-first by created_at) | |
| RUNS=$(gh api \ | |
| "repos/${REPO}/actions/workflows/${WF_ID}/runs?status=completed&per_page=100" \ | |
| --jq '.workflow_runs[] | "\(.id)\t\(.created_at)"' || echo "") | |
| # Sanitize workflow name for markdown table (escape pipe characters) | |
| WF_NAME_SAFE="${WF_NAME//|/\\|}" | |
| if [ -z "${RUNS}" ]; then | |
| echo " ℹ️ No completed runs found." | |
| SUMMARY_ROWS="${SUMMARY_ROWS}| ${WF_NAME_SAFE} | 0 | 0 |\n" | |
| continue | |
| fi | |
| WF_DELETED=0 | |
| WF_RETAINED=0 | |
| RUN_INDEX=0 | |
| while IFS=$'\t' read -r RUN_ID RUN_CREATED; do | |
| RUN_INDEX=$((RUN_INDEX + 1)) | |
| # Always keep the most recent KEEP_MINIMUM_RUNS runs | |
| if [ "${RUN_INDEX}" -le "${KEEP_MINIMUM_RUNS}" ]; then | |
| echo " ✅ Keeping run ${RUN_ID} (${RUN_CREATED}) — within minimum keep count" | |
| WF_RETAINED=$((WF_RETAINED + 1)) | |
| continue | |
| fi | |
| # Check age | |
| RUN_TS=$(date -d "${RUN_CREATED}" +%s) | |
| if [ "${RUN_TS}" -ge "${CUTOFF}" ]; then | |
| echo " ✅ Keeping run ${RUN_ID} (${RUN_CREATED}) — within retention period" | |
| WF_RETAINED=$((WF_RETAINED + 1)) | |
| continue | |
| fi | |
| # Delete (or dry-run log) | |
| if [ "${DRY_RUN}" = "true" ]; then | |
| echo " 🔍 [DRY RUN] Would delete run ${RUN_ID} (${RUN_CREATED})" | |
| else | |
| if [ "$(( TOTAL_DELETED + WF_DELETED ))" -ge "${MAX_DELETIONS}" ]; then | |
| echo " ⚠️ Reached maximum deletions (${MAX_DELETIONS}), stopping. Will continue in next run." | |
| break | |
| fi | |
| echo " 🗑️ Deleting run ${RUN_ID} (${RUN_CREATED})" | |
| if ! gh api --method DELETE "repos/${REPO}/actions/runs/${RUN_ID}" 2>/dev/null; then | |
| echo " ⚠️ Delete failed (possibly rate-limited), stopping deletions for this workflow" | |
| break | |
| fi | |
| sleep 1 | |
| fi | |
| WF_DELETED=$((WF_DELETED + 1)) | |
| done <<< "${RUNS}" | |
| echo " Summary: deleted=${WF_DELETED} retained=${WF_RETAINED}" | |
| TOTAL_DELETED=$((TOTAL_DELETED + WF_DELETED)) | |
| TOTAL_RETAINED=$((TOTAL_RETAINED + WF_RETAINED)) | |
| SUMMARY_ROWS="${SUMMARY_ROWS}| ${WF_NAME_SAFE} | ${WF_RETAINED} | ${WF_DELETED} |\n" | |
| if [ "${TOTAL_DELETED}" -ge "${MAX_DELETIONS}" ]; then | |
| echo "" | |
| echo "⚠️ Reached maximum deletions cap (${MAX_DELETIONS}). Remaining cleanup will happen in the next scheduled run." | |
| break | |
| fi | |
| done <<< "${WORKFLOWS}" | |
| echo "" | |
| echo "✅ Done. Total deleted: ${TOTAL_DELETED} Total retained: ${TOTAL_RETAINED}" | |
| # Write step summary | |
| { | |
| if [ "${DRY_RUN}" = "true" ]; then | |
| echo "### 🔍 Workflow Runs Cleanup — Dry Run" | |
| else | |
| echo "### 🧹 Workflow Runs Cleanup" | |
| fi | |
| echo "" | |
| echo "| Setting | Value |" | |
| echo "| --- | --- |" | |
| echo "| Retain days | ${RETAIN_DAYS} |" | |
| echo "| Keep minimum runs | ${KEEP_MINIMUM_RUNS} |" | |
| echo "| Max deletions per run | ${MAX_DELETIONS} |" | |
| echo "| Dry run | ${DRY_RUN} |" | |
| echo "" | |
| echo "| Workflow | Retained | Deleted |" | |
| echo "| --- | --- | --- |" | |
| printf "%b" "${SUMMARY_ROWS}" | |
| echo "" | |
| echo "| **Total** | **${TOTAL_RETAINED}** | **${TOTAL_DELETED}** |" | |
| if [ "${TOTAL_DELETED}" -ge "${MAX_DELETIONS}" ]; then | |
| echo "" | |
| echo "> ⚠️ **Deletion cap reached** (${MAX_DELETIONS}). Run the workflow again or wait for the next scheduled run to continue cleanup." | |
| fi | |
| } >> "${GITHUB_STEP_SUMMARY}" |