Last active
April 28, 2026 21:37
-
-
Save viperadnan-git/5968c1dc3756427c83c69a8a71a41a2a to your computer and use it in GitHub Desktop.
GitHub Actions Cleanup Script
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
| #!/bin/bash | |
| # GitHub Actions Cleanup Script | |
| # Deletes completed runs by default; --force also cancels and deletes active runs | |
| # | |
| # Usage: cleanup-gh-actions.sh [--force] [--older-than <days>] [--keep <n>] [<repo>] | |
| # --force Also cancel and delete active runs (in_progress/queued/waiting/pending/requested) | |
| # --older-than <days> Only delete runs older than this many days | |
| # --keep <n> Keep at least the N most recent completed runs (default: 0) | |
| # <repo> Repository in owner/repo format (default: current repo) | |
| set -e | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| CYAN='\033[0;36m' | |
| NC='\033[0m' # No Color | |
| # Check if gh is installed | |
| if ! command -v gh &> /dev/null; then | |
| echo -e "${RED}Error: gh CLI is not installed. Install it from https://cli.github.com/${NC}" | |
| exit 1 | |
| fi | |
| # Check if jq is installed | |
| if ! command -v jq &> /dev/null; then | |
| echo -e "${RED}Error: jq is not installed. Install it with 'apt install jq' or 'brew install jq'${NC}" | |
| exit 1 | |
| fi | |
| # Check if authenticated | |
| if ! gh auth status &> /dev/null; then | |
| echo -e "${RED}Error: Not authenticated with gh. Run 'gh auth login' first.${NC}" | |
| exit 1 | |
| fi | |
| # Parse arguments | |
| REPO="" | |
| FORCE=false | |
| OLDER_THAN="" | |
| KEEP=0 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --force) FORCE=true; shift ;; | |
| --older-than) OLDER_THAN="$2"; shift 2 ;; | |
| --keep) KEEP="$2"; shift 2 ;; | |
| *) REPO="$1"; shift ;; | |
| esac | |
| done | |
| # Validate --older-than | |
| if [ -n "$OLDER_THAN" ] && ! [[ "$OLDER_THAN" =~ ^[0-9]+$ ]]; then | |
| echo -e "${RED}Error: --older-than must be a positive integer (days)${NC}" | |
| exit 1 | |
| fi | |
| # Validate --keep | |
| if ! [[ "$KEEP" =~ ^[0-9]+$ ]]; then | |
| echo -e "${RED}Error: --keep must be a non-negative integer${NC}" | |
| exit 1 | |
| fi | |
| # Cutoff timestamp (seconds since epoch) for --older-than | |
| CUTOFF_TS="" | |
| if [ -n "$OLDER_THAN" ]; then | |
| CUTOFF_TS=$(date -d "$OLDER_THAN days ago" +%s 2>/dev/null || date -v-"${OLDER_THAN}"d +%s) | |
| fi | |
| REPO_FLAG="" | |
| if [ -n "$REPO" ]; then | |
| REPO_FLAG="--repo $REPO" | |
| echo -e "${BLUE}Working on repository: $REPO${NC}" | |
| else | |
| echo -e "${BLUE}Working on current repository${NC}" | |
| fi | |
| if [ "$FORCE" = true ]; then | |
| echo -e "${YELLOW}Force mode: active runs will be cancelled and deleted${NC}" | |
| else | |
| echo -e "${CYAN}Active runs will be skipped (use --force to cancel and delete them too)${NC}" | |
| fi | |
| [ -n "$OLDER_THAN" ] && echo -e "${CYAN}Filter: runs older than ${OLDER_THAN} days${NC}" | |
| [ "$KEEP" -gt 0 ] && echo -e "${CYAN}Keep: at least ${KEEP} most recent completed runs${NC}" | |
| echo "" | |
| iteration=1 | |
| # Process runs in batches until none remain | |
| while true; do | |
| echo -e "${CYAN}=== Iteration $iteration ===${NC}" | |
| # Fetch batch of runs (max 1000 per request), sorted newest-first (API default) | |
| runs=$(gh run list $REPO_FLAG --limit 1000 --json databaseId,status,conclusion,displayTitle,workflowName,createdAt 2>/dev/null) | |
| if [ -z "$runs" ] || [ "$runs" == "[]" ]; then | |
| echo -e "${GREEN}No more workflow runs found.${NC}" | |
| break | |
| fi | |
| batch_count=$(echo "$runs" | jq length) | |
| echo -e "${BLUE}Found $batch_count workflow runs in this batch${NC}" | |
| echo "----------------------------------------" | |
| deleted=0 | |
| kept_total=0 # completed runs seen so far (newest-first), for --keep | |
| # Process each run (newest-first order from API) | |
| while read -r run; do | |
| run_id=$(echo "$run" | jq -r '.databaseId') | |
| status=$(echo "$run" | jq -r '.status') | |
| conclusion=$(echo "$run" | jq -r '.conclusion') | |
| title=$(echo "$run" | jq -r '.displayTitle') | |
| workflow=$(echo "$run" | jq -r '.workflowName') | |
| created_at=$(echo "$run" | jq -r '.createdAt') | |
| # Truncate title if too long | |
| if [ ${#title} -gt 40 ]; then | |
| title="${title:0:37}..." | |
| fi | |
| echo -ne "Run #$run_id [$workflow] \"$title\" - " | |
| # Handle active runs | |
| if [[ "$status" == "in_progress" || "$status" == "queued" || "$status" == "waiting" || "$status" == "pending" || "$status" == "requested" ]]; then | |
| echo -ne "${YELLOW}$status${NC} -> " | |
| if [ "$FORCE" = true ]; then | |
| if gh run cancel "$run_id" $REPO_FLAG 2>/dev/null; then | |
| echo -ne "${GREEN}CANCELLED${NC} -> " | |
| sleep 1 | |
| if gh run delete "$run_id" $REPO_FLAG 2>/dev/null; then | |
| echo -e "${GREEN}DELETED${NC}" | |
| ((deleted++)) || true | |
| else | |
| echo -e "${YELLOW}DELETE PENDING (will retry)${NC}" | |
| fi | |
| else | |
| echo -e "${RED}CANCEL FAILED${NC}" | |
| fi | |
| else | |
| echo -e "SKIPPED" | |
| fi | |
| continue | |
| fi | |
| # Completed run — apply filters before deciding to delete | |
| echo -ne "${BLUE}$status${NC}" | |
| [ -n "$conclusion" ] && [ "$conclusion" != "null" ] && echo -ne " ($conclusion)" | |
| echo -ne " [$created_at] -> " | |
| # --keep: preserve the N most recent completed runs | |
| if [ "$KEEP" -gt 0 ] && [ "$kept_total" -lt "$KEEP" ]; then | |
| ((kept_total++)) || true | |
| echo -e "${GREEN}KEPT (recent)${NC}" | |
| continue | |
| fi | |
| ((kept_total++)) || true | |
| # --older-than: skip runs that are not old enough | |
| if [ -n "$CUTOFF_TS" ]; then | |
| run_ts=$(date -d "$created_at" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created_at" +%s) | |
| if [ "$run_ts" -ge "$CUTOFF_TS" ]; then | |
| echo -e "${CYAN}SKIPPED (not old enough)${NC}" | |
| continue | |
| fi | |
| fi | |
| if gh run delete "$run_id" $REPO_FLAG 2>/dev/null; then | |
| echo -e "${GREEN}DELETED${NC}" | |
| ((deleted++)) || true | |
| else | |
| echo -e "${RED}DELETE FAILED${NC}" | |
| fi | |
| done < <(echo "$runs" | jq -c '.[]') | |
| echo "" | |
| # Stop when nothing was deleted to avoid infinite loops | |
| if [ "$deleted" -eq 0 ]; then | |
| break | |
| fi | |
| ((iteration++)) | |
| # Small delay between iterations to avoid rate limiting | |
| sleep 2 | |
| done | |
| echo "" | |
| echo "========================================" | |
| echo -e "${GREEN}Cleanup complete!${NC}" | |
| echo "========================================" |
Author
Author
Or run directly using curl
curl -fsSL "https://gist.githubusercontent.com/viperadnan-git/5968c1dc3756427c83c69a8a71a41a2a/raw/cleanup-gh-actions.sh" | bash -s -- user/repo
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
GitHub Actions Cleanup Script
A bash script to safely clean up GitHub Actions workflow runs from a repository.
What it does
--older-than/--keepfilters)--forceis passedPrerequisites
gh) installed and authenticatedjqfor JSON parsingInstallation
macOS:
Ubuntu/Debian:
Authenticate with GitHub:
Usage
--force--older-than <days>--keep <n><owner/repo>Examples
Example Output
How it works
--forceis passed, in which case they are cancelled then deleted--keepis set--older-thanis setNotes
--keepand--older-thanflags apply only to completed runs; active runs are handled separately by--force