Clean Up GitHub Workflow Runs From the Terminal
Delete all GitHub workflow runs in terminal using gh cli command
Why
Renaming or deleting a workflow file doesn't clear its run history — the old runs stay listed under Actions → All workflows with no active workflow behind them, and the GitHub UI has no bulk-delete button for them. Deleting the runs through the API with gh is the only way to clear that list.
Before you start
- Install and authenticate the GitHub CLI:
gh auth login - Run the commands from inside the repo, or add
--repo owner/repoto everygh run list/gh run deletecall - Use the workflow's filename (e.g.
deploy.yml), not its display name, for--workflow
How it works
macOS / Linux (bash, zsh)
gh run list only returns 20 runs by default. Leave that alone and the loop silently stops after the first page, so anything past run #20 never gets deleted. Add --limit set comfortably above your run count — 1000 is a safe ceiling — to actually clear everything.
WORKFLOW="deploy.yml"
for id in $(gh run list --workflow "$WORKFLOW" --limit 1000 --json databaseId --jq '.[].databaseId'); do
gh run delete "$id"
doneWorks unchanged in bash and zsh — the default shell on modern macOS and most Linux distros.
Windows (PowerShell)
$workflow = "deploy.yml"
gh run list --workflow $workflow --limit 1000 --json databaseId --jq ".[].databaseId" |
ForEach-Object { gh run delete $_ }gh's --jq flag runs on a built-in JSON engine, you don't need a separate jq install on either OS. Prefer the bash version? It runs unchanged in Git Bash or WSL.
A safer version (confirmation, dry run, progress)
The scripts above delete everything immediately, with no preview and no way back. This version counts the runs first, can list them without deleting (--dry-run), and asks for confirmation before touching anything.
bash / zsh
#!/usr/bin/env bash
set -euo pipefail
WORKFLOW="${1:?Usage: $0 <workflow-file> [--dry-run]}"
DRY_RUN=false
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
ids=()
while IFS= read -r id; do
ids+=("$id")
done < <(gh run list --workflow "$WORKFLOW" --limit 1000 --json databaseId --jq '.[].databaseId')
if [[ ${#ids[@]} -eq 0 ]]; then
echo "No runs found for $WORKFLOW"
exit 0
fi
echo "Found ${#ids[@]} run(s) for $WORKFLOW"
if $DRY_RUN; then
printf '%s\n' "${ids[@]}"
exit 0
fi
read -rp "Delete all ${#ids[@]} runs? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || exit 0
count=0
for id in "${ids[@]}"; do
gh run delete "$id"
count=$((count+1))
echo "Deleted $count/${#ids[@]}"
doneUsage: ./cleanup-runs.sh deploy.yml to run, or ./cleanup-runs.sh deploy.yml --dry-run to preview. Uses a plain while read loop instead of mapfile so it also runs on the old bash (3.2) that macOS ships by default.
PowerShell
param(
[Parameter(Mandatory)][string]$Workflow,
[switch]$DryRun
)
$ids = @(gh run list --workflow $Workflow --limit 1000 --json databaseId --jq ".[].databaseId") | Where-Object { $_ -ne "" }
if ($ids.Count -eq 0) {
Write-Host "No runs found for $Workflow"
exit 0
}
Write-Host "Found $($ids.Count) run(s) for $Workflow"
if ($DryRun) {
$ids | ForEach-Object { Write-Host $_ }
exit 0
}
$confirm = Read-Host "Delete all $($ids.Count) runs? [y/N]"
if ($confirm -notmatch '^[Yy]$') { exit 0 }
$count = 0
foreach ($id in $ids) {
gh run delete $id
$count++
Write-Host "Deleted $count/$($ids.Count)"
}Usage: .\cleanup-runs.ps1 -Workflow deploy.yml, add -DryRun to preview first. The @(...) wrapper matters — without it, PowerShell collapses a single-line result into a plain string instead of a one-item array, and .Count breaks.
Variant: keep the last N runs
For trimming history instead of wiping it — useful once a workflow's been running for a while and only the recent runs matter.
#!/usr/bin/env bash
set -euo pipefail
WORKFLOW="${1:?Usage: $0 <workflow-file> <keep-count>}"
KEEP="${2:?Usage: $0 <workflow-file> <keep-count>}"
ids=()
while IFS= read -r id; do
ids+=("$id")
done < <(gh run list --workflow "$WORKFLOW" --limit 1000 --json databaseId --jq '.[].databaseId')
to_delete=("${ids[@]:$KEEP}")
if [[ ${#to_delete[@]} -eq 0 ]]; then
echo "Nothing to delete — ${#ids[@]} run(s), keeping $KEEP"
exit 0
fi
echo "Deleting ${#to_delete[@]} run(s), keeping the newest $KEEP"
for id in "${to_delete[@]}"; do
gh run delete "$id"
doneUsage: ./keep-last-n.sh deploy.yml 10 deletes everything except the 10 most recent runs gh run list returns runs newest-first, so the slice after the first KEEP entries is everything older.
Variant: only failed or cancelled runs
Leaves successful runs alone and clears out the noise.
gh run list --workflow deploy.yml --limit 1000 --json databaseId,conclusion \
--jq '.[] | select(.conclusion=="failure" or .conclusion=="cancelled") | .databaseId' \
| xargs -n1 gh run delete