At some point, I had to admit it: I’ve turned GitHub Issues into a glorified chart gallery.
Let me explain.
Over on my amedee/ansible-servers
repository, I have a workflow called workflow-metrics.yml
, which runs after every pipeline. It uses yykamei/github-workflows-metrics
to generate beautiful charts that show how long my CI pipeline takes to run. Those charts are then posted into a GitHub Issue—one per run.
It’s neat. It’s visual. It’s entirely unnecessary to keep them forever.
The thing is: every time the workflow runs, it creates a new issue and closes the old one. So naturally, I end up with a long, trailing graveyard of “CI Metrics” issues that serve no purpose once they’re a few weeks old.
Cue the digital broom. 🧹
Enter cleanup-closed-issues.yml
To avoid hoarding useless closed issues like some kind of GitHub raccoon, I created a scheduled workflow that runs every Monday at 3:00 AM UTC and deletes the cruft:
schedule:
- cron: '0 3 * * 1' # Every Monday at 03:00 UTC
This workflow:
- Keeps at least 6 closed issues (just in case I want to peek at recent metrics).
- Keeps issues that were closed less than 30 days ago.
- Deletes everything else—quietly, efficiently, and without breaking a sweat.
It’s also configurable when triggered manually, with inputs for dry_run
, days_to_keep
, and min_issues_to_keep
. So I can preview deletions before committing them, or tweak the retention period as needed.
📂 Complete Source Code for the Cleanup Workflow
name: 🧹 Cleanup Closed Issues
on:
schedule:
- cron: '0 3 * * 1' # Runs every Monday at 03:00 UTC
workflow_dispatch:
inputs:
dry_run:
description: "Enable dry run mode (preview deletions, no actual delete)"
required: false
default: "false"
type: choice
options:
- "true"
- "false"
days_to_keep:
description: "Number of days to retain closed issues"
required: false
default: "30"
type: string
min_issues_to_keep:
description: "Minimum number of closed issues to keep"
required: false
default: "6"
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
issues: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Install GitHub CLI
run: sudo apt-get install --yes gh
- name: Delete old closed issues
env:
GH_TOKEN: ${{ secrets.GH_FINEGRAINED_PAT }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
DAYS_TO_KEEP: ${{ github.event.inputs.days_to_keep || '30' }}
MIN_ISSUES_TO_KEEP: ${{ github.event.inputs.min_issues_to_keep || '6' }}
REPO: ${{ github.repository }}
run: |
NOW=$(date -u +%s)
THRESHOLD_DATE=$(date -u -d "${DAYS_TO_KEEP} days ago" +%s)
echo "Only consider issues older than ${THRESHOLD_DATE}"
echo "::group::Checking GitHub API Rate Limits..."
RATE_LIMIT=$(gh api /rate_limit --jq '.rate.remaining')
echo "Remaining API requests: ${RATE_LIMIT}"
if [[ "${RATE_LIMIT}" -lt 10 ]]; then
echo "⚠️ Low API limit detected. Sleeping for a while..."
sleep 60
fi
echo "::endgroup::"
echo "Fetching ALL closed issues from ${REPO}..."
CLOSED_ISSUES=$(gh issue list --repo "${REPO}" --state closed --limit 1000 --json number,closedAt)
if [ "${CLOSED_ISSUES}" = "[]" ]; then
echo "✅ No closed issues found. Exiting."
exit 0
fi
ISSUES_TO_DELETE=$(echo "${CLOSED_ISSUES}" | jq -r \
--argjson now "${NOW}" \
--argjson limit "${MIN_ISSUES_TO_KEEP}" \
--argjson threshold "${THRESHOLD_DATE}" '
.[:-(if length < $limit then 0 else $limit end)]
| map(select(
(.closedAt | type == "string") and
((.closedAt | fromdateiso8601) < $threshold)
))
| .[].number
' || echo "")
if [ -z "${ISSUES_TO_DELETE}" ]; then
echo "✅ No issues to delete. Exiting."
exit 0
fi
echo "::group::Issues to delete:"
echo "${ISSUES_TO_DELETE}"
echo "::endgroup::"
if [ "${DRY_RUN}" = "true" ]; then
echo "🛑 DRY RUN ENABLED: Issues will NOT be deleted."
exit 0
fi
echo "⏳ Deleting issues..."
echo "${ISSUES_TO_DELETE}" \
| xargs -I {} -P 5 gh issue delete "{}" --repo "${REPO}" --yes
DELETED_COUNT=$(echo "${ISSUES_TO_DELETE}" | wc -l)
REMAINING_ISSUES=$(gh issue list --repo "${REPO}" --state closed --limit 100 | wc -l)
echo "::group::✅ Issue cleanup completed!"
echo "📌 Deleted Issues: ${DELETED_COUNT}"
echo "📌 Remaining Closed Issues: ${REMAINING_ISSUES}"
echo "::endgroup::"
{
echo "### 🗑️ GitHub Issue Cleanup Summary"
echo "- **Deleted Issues**: ${DELETED_COUNT}"
echo "- **Remaining Closed Issues**: ${REMAINING_ISSUES}"
} >> "$GITHUB_STEP_SUMMARY"
🛠️ Technical Design Choices Behind the Cleanup Workflow
Cleaning up old GitHub issues may seem trivial, but doing it well requires a few careful decisions. Here’s why I built the workflow the way I did:
Why GitHub CLI (gh
)?
While I could have used raw REST API calls or GraphQL, the GitHub CLI (gh
) provides a nice balance of power and simplicity:
- It handles authentication and pagination under the hood.
- Supports JSON output and filtering directly with
--json
and--jq
. - Provides convenient commands like
gh issue list
andgh issue delete
that make the script readable. - Comes pre-installed on GitHub runners or can be installed easily.
Example fetching closed issues:
gh issue list --repo "$REPO" --state closed --limit 1000 --json number,closedAt
No messy headers or tokens, just straightforward commands.
Filtering with jq
I use jq
to:
- Retain a minimum number of issues to keep (
min_issues_to_keep
). - Keep issues closed more recently than the retention period (
days_to_keep
). - Parse and compare issue closed timestamps with precision.
- Exclude pull requests from deletion by checking the presence of the
pull_request
field.
The jq filter looks like this:
jq -r --argjson now "$NOW" --argjson limit "$MIN_ISSUES_TO_KEEP" --argjson threshold "$THRESHOLD_DATE" '
.[:-(if length < $limit then 0 else $limit end)]
| map(select(
(.closedAt | type == "string") and
((.closedAt | fromdateiso8601) < $threshold)
))
| .[].number
'
Secure Authentication with Fine-Grained PAT
Because deleting issues is a destructive operation, the workflow uses a Fine-Grained Personal Access Token (PAT) with the narrowest possible scopes:
Issues: Read and Write
- Limited to the repository in question
The token is securely stored as a GitHub Secret (GH_FINEGRAINED_PAT
).
Note: Pull requests are not deleted because they are filtered out and the CLI won’t delete PRs via the issues API.
Dry Run for Safety
Before deleting anything, I can run the workflow in dry_run
mode to preview what would be deleted:
inputs:
dry_run:
description: "Enable dry run mode (preview deletions, no actual delete)"
default: "false"
This lets me double-check without risking accidental data loss.
Parallel Deletion
Deletion happens in parallel to speed things up:
echo "$ISSUES_TO_DELETE" | xargs -I {} -P 5 gh issue delete "{}" --repo "$REPO" --yes
Up to 5 deletions run concurrently — handy when cleaning dozens of old issues.
User-Friendly Output
The workflow uses GitHub Actions’ logging groups and step summaries to give a clean, collapsible UI:
echo "::group::Issues to delete:"
echo "$ISSUES_TO_DELETE"
echo "::endgroup::"
And a markdown summary is generated for quick reference in the Actions UI.
Why Bother?
I’m not deleting old issues because of disk space or API limits — GitHub doesn’t charge for that. It’s about:
- Reducing clutter so my issue list stays manageable.
- Making it easier to find recent, relevant information.
- Automating maintenance to free my brain for other things.
- Keeping my tooling neat and tidy, which is its own kind of joy.
Steal It, Adapt It, Use It
If you’re generating temporary issues or ephemeral data in GitHub Issues, consider using a cleanup workflow like this one.
It’s simple, secure, and effective.
Because sometimes, good housekeeping is the best feature.
🧼✨ Happy coding (and cleaning)!