From 8aac511eae5bbb559de8c45ac41acf1975be7a91 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 21:45:11 -0400 Subject: [PATCH] feat(stale): add stale action Signed-off-by: Yordis Prieto --- .github/release-please-config.json | 4 + .github/release-please-manifest.json | 3 +- actions/stale/README.md | 129 +++++++++++++++++++++++++++ actions/stale/action.yml | 100 +++++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 actions/stale/README.md create mode 100644 actions/stale/action.yml diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 0a38fad..c08dfee 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -27,6 +27,10 @@ "actions/release-please": { "component": "release-please", "initial-version": "0.0.1" + }, + "actions/stale": { + "component": "stale", + "initial-version": "0.0.1" } }, "plugins": [ diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json index e5fbb89..a50c229 100644 --- a/.github/release-please-manifest.json +++ b/.github/release-please-manifest.json @@ -1,4 +1,5 @@ { "actions/semconv/pull-request": "0.0.1", - "actions/release-please": "0.0.2" + "actions/release-please": "0.0.2", + "actions/stale": "0.0.1" } diff --git a/actions/stale/README.md b/actions/stale/README.md new file mode 100644 index 0000000..f777e90 --- /dev/null +++ b/actions/stale/README.md @@ -0,0 +1,129 @@ +# stale + +Marks issues and pull requests that have gone quiet, then closes them if they +stay quiet. A thin, opinionated wrapper around +[`actions/stale`](https://github.com/actions/stale). + +## Usage + +The caller owns the schedule. + +```yaml +name: Stale + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +permissions: {} + +jobs: + stale: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + issues: write + pull-requests: write + steps: + - uses: TrogonStack/github-actions/actions/stale@ # vX.Y.Z +``` + +`actions: write` is not optional. Upstream keeps its progress in the Actions +cache so a run capped by `operations-per-run` resumes where the last one +stopped, and managing that cache entry needs the permission. Without it the +cache delete fails with a 403, every run replays the same prefix of the +backlog, and anything past that prefix is never closed. + +Run it once with `debug-only: true` before pointing it at a repository with +years of backlog, so the first real run is not a surprise. + +## Labels + +| Label | Meaning | +| --- | --- | +| `stale:discard` | Applied by this action. Any activity removes it. | +| `stale:keep` | Applied by a human. Exempts the issue or pull request forever. | + +Neither label needs to exist beforehand; `stale:discard` is created on first +use. Create `stale:keep` yourself so it is available in the label picker. + +The names are not inputs. One vocabulary across every repository is the reason +this action exists, and a rename in one repository is the drift it was built to +remove. + +## Inputs + +| Input | Default | Description | +| --- | --- | --- | +| `days-before-stale` | `60` | Days without activity before marking stale. | +| `days-before-close` | `7` | Days after marking before closing. `-1` marks but never closes. | +| `operations-per-run` | `100` | API operations budget for one run. A run that hits the cap resumes from where it stopped. | +| `debug-only` | `false` | Report what would happen and change nothing. | +| `token` | `github.token` | Needs write access to actions, issues, and pull requests. | + +## Fixed behaviour + +These are not inputs, on purpose. + +- The label names are `stale:discard` and `stale:keep` everywhere. +- Activity removes the stale label. Activity is the entire signal the action + runs on, so it has to be able to clear it. +- Issues close as `not_planned`, which avoids the completed badge on something + nobody did. +- Anything on a milestone is exempt. Work on a milestone is planned work, + however quiet it has gone. +- Oldest first, so a capped operations budget is spent on the worst offenders + rather than on whatever is newest. +- Branches are never deleted. Deleting someone else's branch on a timer is not + ours to do. + +If you need one of these to differ, that is a conversation about the default +rather than an input to add. + +## Outputs + +| Output | Description | +| --- | --- | +| `staled-issues-prs` | JSON array of what was marked stale this run. | +| `closed-issues-prs` | JSON array of what was closed this run. | + +## Messages + +Closing is not a verdict, and the posted messages say so. What they ask for +differs by kind, on purpose. + +An issue is closed because nobody is building it, so the message asks for a +pull request. It deliberately does not offer "leave a comment" as the way to +revive one: a comment clears the stale label without moving the issue any +closer to done, which is how a backlog gets kept alive without getting +shipped. + +A pull request is already the contribution, so the message asks for a commit +instead, and points at `stale:keep` for the case where the hold-up is a review +on our side rather than the author. + +Keep that split if you change the wording. + +## Bumping + +A comment clears the stale label and buys another full cycle, on issues and on +pull requests alike. That is upstream behaviour and it is not configurable: + +- `remove-stale-when-updated` controls whether an update removes the label, and + a comment counts as an update. +- Turning it off does not help. The close gate in `actions/stale` is + `if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow)`, so any + human comment after the label blocks closing whatever that option is set to. +- `ignore-pr-updates` measures from `created_at` instead of `updated_at`, which + would mark every long lived pull request stale no matter how much work it is + getting. + +So a determined bump wins, and the messages say plainly that it is a limitation +rather than the intended path: on a pull request the thing that counts is a +commit, and a bump leaves the work exactly as unfinished as it was. + +Making commits the real currency would mean taking the pull request lifecycle +off upstream and measuring it from the head commit date. That is a bigger +change than this action wants to be today. diff --git a/actions/stale/action.yml b/actions/stale/action.yml new file mode 100644 index 0000000..b834c55 --- /dev/null +++ b/actions/stale/action.yml @@ -0,0 +1,100 @@ +name: Stale +description: >- + Marks issues and pull requests that have gone quiet, then closes them if they + stay quiet. +author: TrogonStack + +inputs: + days-before-stale: + description: Days without activity before an issue or pull request is marked stale. + required: false + default: '60' + days-before-close: + description: >- + Days to wait after marking stale before closing. Set to -1 to mark stale + but never close. + required: false + default: '7' + operations-per-run: + description: >- + API operations budget for a single run. Raise it for a busy repository, + lower it to stay well inside the rate limit. + required: false + default: '100' + debug-only: + description: >- + Report what would happen without commenting, labelling, or closing + anything. Worth one run before enabling this on an old repository. + required: false + default: 'false' + token: + description: >- + Token used to comment, label, and close. Needs write access to issues and + pull requests. + required: false + default: ${{ github.token }} + +outputs: + staled-issues-prs: + description: JSON array of the issues and pull requests marked stale this run. + value: ${{ steps.stale.outputs.staled-issues-prs }} + closed-issues-prs: + description: JSON array of the issues and pull requests closed this run. + value: ${{ steps.stale.outputs.closed-issues-prs }} + +runs: + using: composite + steps: + - name: Mark and close stale issues and pull requests + id: stale + uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + repo-token: ${{ inputs.token }} + + days-before-stale: ${{ inputs.days-before-stale }} + days-before-close: ${{ inputs.days-before-close }} + + # The label names are deliberately not inputs: one vocabulary across + # every repository is the reason this action exists. + stale-issue-label: 'stale:discard' + stale-pr-label: 'stale:discard' + exempt-issue-labels: 'stale:keep' + exempt-pr-labels: 'stale:keep' + + stale-issue-message: | + No activity for ${{ inputs.days-before-stale }} days. Labelled `stale:discard`, closes in ${{ inputs.days-before-close }} days. Closing is not a no. + + - Do: send a pull request. Add `stale:keep` if you are working on it. + - Don't: comment to keep it alive. That resets the clock without getting it built. + + stale-pr-message: | + No activity for ${{ inputs.days-before-stale }} days. Labelled `stale:discard`, closes in ${{ inputs.days-before-close }} days. + + - Do: push a commit. It clears the label. If it is blocked on us, say what it is waiting on and add `stale:keep`. + - Don't: bump it. That clears the label too, but the work stays unfinished. Let it close and reopen when you can finish. + + close-issue-message: | + Closed as inactive, not rejected. Still readable, still reopenable. + + - Do: send a pull request and link it here. No need to reopen first. + + close-pr-message: | + Closed as inactive. The branch is untouched, nothing is lost. + + - Do: reopen it when you are ready to push, or open a fresh pull request from the same branch. + - Don't: reopen it to say you will get to it soon. That just restarts the countdown. + + operations-per-run: ${{ inputs.operations-per-run }} + debug-only: ${{ inputs.debug-only }} + + # Opinions this action does not hand back to the caller. + # Activity is the whole signal, so activity has to clear the label. + remove-stale-when-updated: true + # "not_planned" avoids the completed badge on something nobody did. + close-issue-reason: not_planned + # Work on a milestone is planned work, however quiet it has gone. + exempt-all-milestones: true + # Oldest first, so the operations budget is spent on the worst offenders. + ascending: true + # Deleting someone else's branch on a timer is not ours to do. + delete-branch: false