Skip to content

Instantly share code, notes, and snippets.

@viperadnan-git
Last active April 28, 2026 21:37
Show Gist options
  • Select an option

  • Save viperadnan-git/5968c1dc3756427c83c69a8a71a41a2a to your computer and use it in GitHub Desktop.

Select an option

Save viperadnan-git/5968c1dc3756427c83c69a8a71a41a2a to your computer and use it in GitHub Desktop.
GitHub Actions Cleanup Script
#!/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 "========================================"
@viperadnan-git

viperadnan-git commented Jan 7, 2026

Copy link
Copy Markdown
Author

GitHub Actions Cleanup Script

A bash script to safely clean up GitHub Actions workflow runs from a repository.

What it does

  • Completed runs → Always deleted (subject to --older-than / --keep filters)
  • Active runs (in_progress, queued, waiting, pending, requested) → Skipped by default; cancelled then deleted when --force is passed

Prerequisites

Installation

macOS:

brew install gh jq

Ubuntu/Debian:

sudo apt install gh jq

Authenticate with GitHub:

gh auth login

Usage

chmod +x cleanup-gh-actions.sh

./cleanup-gh-actions.sh [--force] [--older-than <days>] [--keep <n>] [<owner/repo>]
Flag Description
--force Also cancel and delete active runs (default: skip them)
--older-than <days> Only delete runs older than this many days
--keep <n> Keep at least the N most recent completed runs
<owner/repo> Target repository (default: current git repo)

Examples

# Delete all completed runs, skip active ones
./cleanup-gh-actions.sh owner/repo

# Delete all completed runs AND cancel+delete active ones
./cleanup-gh-actions.sh --force owner/repo

# Delete completed runs older than 30 days
./cleanup-gh-actions.sh --older-than 30 owner/repo

# Keep the 100 most recent completed runs, delete the rest
./cleanup-gh-actions.sh --keep 100 owner/repo

# Keep the 100 most recent, delete only those older than 30 days, and also nuke active runs
./cleanup-gh-actions.sh --force --keep 100 --older-than 30 owner/repo

Example Output

Working on repository: myorg/my-repo
Active runs will be skipped (use --force to cancel and delete them too)
Keep: at least 100 most recent completed runs

=== Iteration 1 ===
Found 250 workflow runs in this batch
----------------------------------------
Run #12345680 [CI] "feat: add new feature" - in_progress -> SKIPPED
Run #12345679 [CI] "fix: bug fix" - completed (success) [2026-04-28T10:00:00Z] -> KEPT (recent)
Run #12345678 [CI] "chore: update deps" - completed (cancelled) [2026-01-01T00:00:00Z] -> DELETED
...

========================================
Cleanup complete!
========================================

How it works

  1. Fetches up to 1000 workflow runs per iteration (newest-first)
  2. Active runs are skipped unless --force is passed, in which case they are cancelled then deleted
  3. The N most recent completed runs are preserved when --keep is set
  4. Runs newer than the cutoff are skipped when --older-than is set
  5. Remaining completed runs are deleted
  6. Repeats until no more runs are deleted, with a 2-second delay between iterations

Notes

  • Requires write access to the repository Actions
  • The --keep and --older-than flags apply only to completed runs; active runs are handled separately by --force

@viperadnan-git

Copy link
Copy Markdown
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