Delete old GitHub Actions workflow runs with retry logic, circuit breaker, rate-limit awareness, and per-workflow filtering. Built for repos that generate a lot of CI noise.
GitHub Actions histories grow without bounds. Scheduled jobs, PR checks, and matrix builds can leave a repo with thousands of runs a year, cluttering the Actions tab and eating into your plan's storage. This action prunes the old ones on a schedule you control, with retries, rate-limit pacing, and a circuit breaker so a flaky API call doesn't leave you with a half-finished cleanup.
If this keeps your Actions tab clean and your storage quota in check, a ⭐ on the repo helps other teams find it too.
- uses: igorjs/gh-actions-clean-workflow@v8
with:
runs_older_than: 7 # delete runs older than 7 days
runs_to_keep: 5 # keep the 5 most recent runs per workflowNeed a full workflow file you can drop in? See Examples below.
- Features
- Inputs
- Outputs
- Permissions
- Examples
- Advanced behavior
- FAQ
- Versioning
- Development
- Migrating from v7
- Migrating from v6
- Security
- License
- Workflow filtering by name, so you only touch the runs you want
- Per-workflow retention via
runs_to_keep(keep the N most recent per workflow, delete the rest) - Automatic retries with exponential backoff on transient failures (5xx, 429, network)
- Circuit breaker that opens after repeated failures and recovers automatically
- Rate-limit aware: respects GitHub's
Retry-Afterheader and paces requests - Dry run mode to preview deletions without touching anything
- Detailed metrics exposed as action outputs for monitoring, alerting, or dashboards
- Hardened CI: signed commits, pinned action SHAs, OSV-Scanner + betterleaks scanning, OpenSSF Scorecard, and dependency review
| Input | Description | Default | Required |
|---|---|---|---|
token |
GitHub token used for the REST API. Needs actions: write on the target repo. |
${{ github.token }} |
No |
owner |
Repository owner. | ${{ github.repository_owner }} |
No |
repo |
Repository name. | extracted from ${{ github.repository }} |
No |
runs_older_than |
Delete runs older than this many days (since the last rerun). | 7 |
No |
runs_to_keep |
Keep this many most-recent runs per workflow. Set to 0 to delete every eligible run. |
0 |
No |
workflow_names |
Comma-separated workflow names to filter. Empty = all workflows. Case-sensitive. | "" |
No |
dry_run |
If true, log what would be deleted but don't call the delete API. |
false |
No |
| Output | Description |
|---|---|
total-runs-found |
Total runs matched by the filter |
runs-deleted |
Runs successfully deleted |
runs-failed |
Runs that failed to delete |
total-api-requests |
Total API calls (list + delete + retries) |
successful-requests |
API calls that returned 2xx |
failed-requests |
API calls that ultimately failed |
retry-attempts |
Retry attempts across the run |
rate-limit-hits |
Times a 429 / secondary rate limit was observed |
circuit-breaker-trips |
Times the circuit opened to back off |
The workflow that calls this action needs actions: write on the target repository so the GitHub API can delete runs.
permissions:
actions: writeIf the default GITHUB_TOKEN doesn't have enough scope (cross-repo cleanups, org-wide policies, ...), pass a PAT explicitly via the token input.
Run every Monday at 00:00 UTC, delete runs older than 14 days, keep the 20 most recent per workflow.
name: Clean workflow runs
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
permissions:
actions: write
jobs:
clean:
runs-on: ubuntu-latest
steps:
- uses: igorjs/gh-actions-clean-workflow@v8
with:
runs_older_than: 14
runs_to_keep: 20Only clean CI / Deploy noise, leave other workflows untouched.
- uses: igorjs/gh-actions-clean-workflow@v8
with:
workflow_names: "CI, Deploy, Tests"
runs_older_than: 14
runs_to_keep: 5Preview what would be deleted without calling the delete API. Useful for the first cron run, or to sanity-check filters.
- uses: igorjs/gh-actions-clean-workflow@v8
with:
dry_run: true
runs_older_than: 7- name: Clean workflow runs
id: clean
uses: igorjs/gh-actions-clean-workflow@v8
with:
runs_older_than: 7
runs_to_keep: 10
- name: Summarize
run: |
echo "Found: ${{ steps.clean.outputs.total-runs-found }}"
echo "Deleted: ${{ steps.clean.outputs.runs-deleted }}"
echo "Failed: ${{ steps.clean.outputs.runs-failed }}"
echo "API requests: ${{ steps.clean.outputs.total-api-requests }}"
echo "Rate limited: ${{ steps.clean.outputs.rate-limit-hits }}"
echo "CB trips: ${{ steps.clean.outputs.circuit-breaker-trips }}"Manual trigger with inputs
name: Clean workflow runs
on:
workflow_dispatch:
inputs:
runs_older_than:
description: "Days old to delete"
default: "7"
runs_to_keep:
description: "Most recent runs to keep per workflow"
default: "0"
workflow_names:
description: "Comma-separated workflow names (empty = all)"
default: ""
dry_run:
description: "Dry run mode"
type: boolean
default: false
permissions:
actions: write
jobs:
clean:
runs-on: ubuntu-latest
steps:
- uses: igorjs/gh-actions-clean-workflow@v8
with:
runs_older_than: ${{ github.event.inputs.runs_older_than }}
runs_to_keep: ${{ github.event.inputs.runs_to_keep }}
workflow_names: ${{ github.event.inputs.workflow_names }}
dry_run: ${{ github.event.inputs.dry_run }}Scheduled + manual with shared defaults
name: Clean workflow runs
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
inputs:
runs_older_than:
description: "Days old to delete"
default: "7"
dry_run:
description: "Dry run mode"
type: boolean
default: false
permissions:
actions: write
env:
SCHEDULED_RUNS_OLDER_THAN: "7"
SCHEDULED_RUNS_TO_KEEP: "20"
jobs:
clean:
runs-on: ubuntu-latest
steps:
- uses: igorjs/gh-actions-clean-workflow@v8
with:
runs_older_than: ${{ github.event.inputs.runs_older_than || env.SCHEDULED_RUNS_OLDER_THAN }}
runs_to_keep: ${{ github.event.inputs.runs_to_keep || env.SCHEDULED_RUNS_TO_KEEP }}
dry_run: ${{ github.event.inputs.dry_run || false }}runs_to_keep and runs_older_than filter independently, then combine:
runs_older_thanselects the candidate pool: only runs older than this many days are considered for deletion at all. Anything newer is never touched, regardless ofruns_to_keep.runs_to_keepthen retains the N newest runs within that already-old pool, per workflow, and only the remainder is deleted.
In other words, runs_to_keep does not mean "keep the N newest runs overall". A run inside the retention window (newer than runs_older_than) is kept automatically and isn't counted against runs_to_keep at all; runs_to_keep only decides how many of the old runs survive.
Example: with runs_older_than: 30 and runs_to_keep: 5, a workflow with 3 runs newer than 30 days and 20 runs older than 30 days ends up with the 3 recent runs plus the 5 newest of the 20 old ones kept, 15 old runs deleted.
Failed requests are retried with exponential backoff before giving up.
- Up to 3 retry attempts
- Backoff: 1s, 2s, 4s
- Retries on: 5xx server errors, 429 rate limit, transient network failures
- Does not retry on 4xx client errors (except 429)
Prevents the action from hammering an unhealthy API.
- Opens after 5 consecutive failures
- Stays open for 60 seconds, then transitions to half-open
- Half-open allows a probe request; success closes the circuit, failure reopens it
- Trip count exported via
circuit-breaker-trips
- Honors GitHub's
Retry-Afterheader on 429 responses - Deletes in concurrent batches of 20, with a paced delay between batches (~170 deletions/min) to stay under secondary rate limits
- Rate-limit hits exported via
rate-limit-hits
- Comma-separated names (
workflow_names: "CI, Deploy") - Case-sensitive match against the workflow's
name:field - Permitted characters: alphanumeric, spaces, dots, dashes, underscores
Will this delete workflow runs that are still in progress?
No. The action only ever lists runs with status completed; anything queued or running is never touched.
What does runs_to_keep: 0 do?
It deletes every run inside the runs_older_than window for that workflow, with nothing kept back. Combine both inputs if you want a fixed floor of recent runs per workflow instead.
Can I see what would be deleted before it actually happens?
Yes. Set dry_run: true and the action logs every run it would delete without calling the delete API. See Dry run.
Does one run of this action clean multiple repositories?
No. owner and repo each take a single repository. For multiple repos, add one job (or a matrix) per repository, each pointing at its own owner/repo.
Pin one of the following based on how aggressively you want updates:
| Tag style | Example | Behavior |
|---|---|---|
| Floating major (recommended) | @v8 |
Auto-updates to the latest v8.x.y patch/minor on each run. Breaking changes only on major bumps. |
| Specific tag | @v8.0.0 |
Pinned; never moves. Update manually when you want a new version. |
| Commit SHA | @<sha> |
Strictest pin. Use when you need byte-for-byte reproducibility (Dependabot can still bump this). |
@main |
@main |
Not recommended for production; pulls whatever is on main at run time. |
Releases follow Semantic Versioning and are cut automatically by semantic-release from Conventional Commits: every push to main is analyzed, and a qualifying commit (fix:, feat:, or a breaking change) tags a release and moves the floating major tag with no manual step. See the Releases page for changelogs.
See CONTRIBUTING.md for prerequisites, setup, common commands, and testing details. Shared org-wide contribution rules (DCO, commit conventions, the code style baseline) are one hop further, linked from CONTRIBUTING.md.
Breaking
days_oldhas been removed. It was deprecated since v4, and in v7.0.1 it was silently ignored: the action always usedruns_older_thanregardless of whatdays_oldwas set to. GitHub Actions now warns on the undeclared input instead of accepting it silently.resultoutput has been removed. It was never written since v7.0.1; the metrics output has always beenruns-deleted.
Upgrade
- uses: igorjs/gh-actions-clean-workflow@v7
+ uses: igorjs/gh-actions-clean-workflow@v8
with:
- days_old: 30
+ runs_older_than: 30
- name: Summarize
run: |
- echo "Deleted: ${{ steps.clean.outputs.result }}"
+ echo "Deleted: ${{ steps.clean.outputs.runs-deleted }}"If your workflow already used runs_older_than, no change is needed beyond replacing result with runs-deleted and bumping the version tag.
v7 keeps the same input names but adds new ones; existing v6 workflows continue to work.
Breaking
- Minimum local Node.js version raised to 26.0.0 (only affects contributors; the published action still runs on GitHub's
node24runtime) - Internal tooling switched from ESLint + Prettier to Biome, and from Jest/
tsupto Vitest + esbuild. End-user surface is unchanged.
New
workflow_namesinput for per-workflow filteringdry_runinput for preview mode- 9 new metric outputs (
total-api-requests,circuit-breaker-trips, etc.) - Circuit breaker + retry + rate-limit handling under the hood
Upgrade
- uses: igorjs/gh-actions-clean-workflow@v6
+ uses: igorjs/gh-actions-clean-workflow@v7
with:
runs_older_than: 7
+ # Optional new inputs:
+ workflow_names: "CI, Deploy"
+ dry_run: falseIf you find a vulnerability, please report it privately to the maintainer rather than opening a public issue. Continuous security scanning runs in CI via OSV-Scanner, betterleaks, OpenSSF Scorecard, and dependency review.