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

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