Stale Work Policy
On this page
TL;DR
A weekly scheduled CI job (triage:stale in .gitlab-ci.yml) flags work that has been idle past these thresholds:
| Surface | Threshold | What happens |
|---|---|---|
Issue |
30 days no activity |
Auto-labeled |
Draft MR |
14 days no activity |
Auto-labeled |
Remote branch |
60 days no commits |
Listed in the weekly report; never auto-deleted |
A markdown report (stale-report.md) is uploaded as a CI artifact each run, retained for 30 days.
Clearing ~stale
When your work gets flagged, you have three responses:
-
Keep going — push a commit, comment with status, or just remove the label. The job is idempotent; it won’t re-flag until the relevant clock resets.
-
Pause it — comment explaining why it’s paused and what would unpause it. The label remains; the comment is the audit trail.
-
Drop it — close the issue or MR. State the reason in the closing comment so future readers don’t re-litigate.
Removing the ~stale label is sufficient on its own. The job re-evaluates inactivity from the most recent activity, not the last label change — so dropping the label without engaging won’t bury the issue.
Why the thresholds
-
Issues 30 days: long enough to absorb a sprint cycle without false positives, short enough that a forgotten issue surfaces before it’s invisible.
-
Draft MRs 14 days: drafts represent in-flight work; two weeks without a push usually means the author has moved on or hit a blocker. Earlier than that punishes thoughtful work; later than that lets context decay.
-
Branches 60 days: branches outlive their MRs sometimes (post-merge cleanup gaps, force-push experiments). Two months is generous enough that the branch has clearly served its purpose.
These are tunable via env vars on the scheduled pipeline (ISSUE_INACTIVE_DAYS, DRAFT_MR_INACTIVE_DAYS, BRANCH_INACTIVE_DAYS). Don’t tune them lower without team consensus — false positives hurt trust.
Why we don’t auto-close
GitLab’s built-in stale rules can auto-close issues. CRAIG’s policy is flag, never close:
-
Auto-close discards context. A stale issue with a thread of comments is a candidate for re-prioritization, not a candidate for the trash.
-
Closing != solving. An auto-closed issue still represents a real gap; closing it just moves the problem off the dashboard.
-
Reversibility matters. Labels are a one-line fix; reopening + re-attributing a closed issue takes more work.
The CI job posts one nudge comment per detection. If the work genuinely should close, a human closes it.
Why we don’t auto-delete branches
For the same reason. A 60-day-old branch might be:
-
A long-running feature that paused (rare but legitimate)
-
A force-pushed branch that someone is mid-rebase on
-
A revert candidate someone is keeping around as evidence
The weekly report lists candidates; the maintainer decides per-branch. After a year, branches without an associated open MR are usually safe to delete via the GitLab UI’s bulk-delete. Default main and protected branches are skipped automatically.
When the job didn’t fire
If you expected to be flagged and weren’t:
-
Check the pipeline schedule at
Settings > CI/CD > Schedules— schedules can be paused without removal. -
Confirm
GITLAB_TOKENis set on the schedule (project- or group-scoped, withapi+read_repositoryscopes). -
Look at the most recent scheduled pipeline; the
triage:stalejob’s log shows what it found.
If you were flagged but think the threshold is wrong: the issue/MR should have some activity since the cutoff date in the report. If GitLab’s "updated_at" is stale because of a hidden quirk (e.g., system notes don’t bump it the way comments do), file an issue against the script — that’s a real bug, not policy.
Local invocation
The script lives at tools/triage/stale.sh and runs locally with the same GITLAB_TOKEN your glab CLI uses:
GITLAB_TOKEN=glpat-... DRY_RUN=1 bash tools/triage/stale.sh
DRY_RUN=1 skips the label/comment writes; the report is generated regardless. Use this to preview what the next scheduled run will do.
Implementation
-
Script:
tools/triage/stale.sh(bash + curl + python3, no Rust dependency — runs in a 4MB Alpine image) -
CI job:
triage:stalein.gitlab-ci.yml, gated to$CI_PIPELINE_SOURCE == "schedule" -
Schedule: created via the GitLab UI (CI/CD > Schedules). Recommended cron:
0 13 * * 1(Mondays 13:00 UTC ≈ 8 AM Atlanta) -
Token: a masked CI variable
GITLAB_TOKENon the schedule, scoped toapi+read_repository -
Label:
~stale(#A8A8A8); GitLab is the source of truth — the script never assumes the label exists, but doesn’t create it either (a missing label surfaces as an API error in the CI log)