GitHub·6 min read·

Stale PR and Issue Cleanup with Scheduled Automation

Dead issues and abandoned PRs quietly rot a repository. Use scheduled stale automation to mark, then close, so your queue stays honest and focused.

NB

Netbay Infrastructure Team

Netbay Engineering

On this page

Backlogs rot from the bottom. The issue opened in April and never answered, the feature request "still relevant maybe," the pull request that went to draft and never came back. The rot costs real attention: old issues resurface in search with stale context, abandoned PRs convince new contributors to rebase ghosts, and the queue slowly teaches everyone to ignore it because it is mostly noise. Scheduled stale automation gives the queue a self-respecting definition of done.

What "stale" means, precisely

Stale has to be defined by activity, not age. An issue with a busy discussion is not stale at three months of open time; a PR untouched for three weeks, cluttering the list, is. The rule of thumb: activity means comments and state changes, not mere existence in the repository. That distinction is what keeps automation from declaring "open since 2024" as somehow an emergency.

Run older-than thresholds on both kinds of work, with different clocks. Pull requests decay faster and deserve tighter numbers than issues — theirs is a short-lived artifact tied to a diff that is steadily drifting from main.

The stale workflow

The reference implementation is the actions/stale workflow, triggerable on a schedule. A scheduler fires a single cron thread on owned runner, and the workflow dispatches itself:

yaml
name: stale
on:
  schedule:
    - cron: '30 6 * * 1'   # 06:30 UTC every Monday
  workflow_dispatch:
permissions:
  issues: write
  pull-requests: write
jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v9
        with:
          days-before-issue-stale: 60
          days-before-issue-close: 14
          stale-issue-message: >-
            This issue has had no activity in 60 days. It will be closed
            in 14 days unless something changes.
          close-issue-message: Closing due to inactivity. Comment to reopen.
          days-before-pr-stale: 30
          days-before-pr-close: 7
          stale-pr-label: stale-pr
          exempt-issue-labels: 'security,roadmap,pinned,good first issue'
          exempt-pr-labels: 'security,dependencies,draft'
          operations-per-run: 400

The two clocks are the entire design: first the stale label appears with a message, then a second threshold closes the thing. Nobody wakes up to a shutdown on a fresh ticket, and nobody presides over an execution by hand.

stale timeline opened stale marker + message + label closed if still quiet 60d issues 30d PRs 14d issues 7d PRs exempt labels interrupt security, roadmap, good first issue open PR reopen with a comment the queue learns to self-sort scheduled run, two thresholds, exempt lists

Exemptions are the policy work

The labeled exemptions are where a stale policy is really written. Bad automation closes the wrong things because no list protects them. Decide those lists once, deliberately: security-related issues and PRs for dependency bumps should not die on a clock, roadmap items are owned by planning, and good-first-issue entries are valuable as inventory, not as a queue. The "draft" exemption for PRs matters because a draft is already a self-managed state; automation should not knock on it. Match the lists to how your team actually works — labels that mark an intentional conclusion, like "blocked" or "waiting-on-third-party", are exactly the ones worth shielding, because they describe a decision the clock simply cannot see.

Watch the first week of runs

Stale automation has a sharp start. The first run will mark the oldest ten percent of your backlog, and the semannual cleanup will surface every ticket that should have been closed two years ago. That is the feature — but only if you treat the first run as a dry run:

bash
# the backlog's bottom, oldest-first, before trusting a close pattern
gh issue list --state open --json number,title,updatedAt   --jq 'sort_by(.updatedAt)[0:10][] | "(.number) (.updatedAt[:10]) (.title)"'

Run with "debug-only: true" on the branch first run to see what would happen without doing it; tune thresholds for a cycle; then let it run and keep the Cron schedule honest. A workflow that pings only when humans broke something is how stale arrives without grief.

The trend line is the real deliverable. Track how many items the sweep labels and closes each month: a healthy queue shows an early spike as the backlog clears itself, then a low, flat line as automation becomes plain maintenance. If the close count stays high month after month, that is not the bot misbehaving — it is the repository reporting that issues are being filed but not owned, and the ownership conversation, not the cron line, is where the effort belongs.

The habit, not the tool

The finish line is a queue where every item has one of three states: someone owns it, it is parked with a label, or it is closed. Stale automation does the bookkeeping; the team decides park versus own. Done right, search results get more truthful by the quarter, and new contributors stop inheriting ghosts.

Takeaway: stale automation is two clocks and an exemption list, running on a schedule, with a dry-run first week. Give your queue a definition of done — and run the scheduler on something that stays on — a Lucknow DC01 VPS from Netbay, deployable in under 60 seconds at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance