ci: replace actions/stale with bot-aware mark-stale script (#6477)

* ci: replace actions/stale with bot-aware mark-stale script

actions/stale uses updatedAt to measure inactivity, so any bot event
(e.g. the /remove-reviewer acknowledgment comment) resets the stale
countdown even when there has been no meaningful human activity for
months.  PR #6332 was last touched by a human on 2026-04-02 but was
not flagged because a reviewer-removal on 2026-06-11 refreshed the
timestamp.

Replace the actions/stale step with a custom mark-stale.js script
(same pattern as alert-stale.js) that only counts non-bot comments,
non-bot review submissions, and commits as meaningful activity.  The
script also removes the stale label if such activity occurs after the
label was applied.

* ci: fix draft-stale keepalive for external contributors

Two bugs with the keepalive checkbox on draft-stale PRs:

1. External contributors (fork authors) lack write access to edit the
   bot's comment, so clicking the checkbox silently fails for them.
   Fix: also treat a new non-bot comment posted after the keepalive
   comment as a sufficient keepalive signal.

2. The stale label could take up to 24 hours to be removed (daily cron
   only). Fix: add an issue_comment.created trigger so draft-pr-policy
   fires immediately when someone comments on a stale draft PR.
   mark-stale and alert-stale are guarded to only run on
   schedule/workflow_dispatch, not on every comment.

Also fix lastRealActivityAt to exclude bot comments (matching
mark-stale.js), so the keepalive and "Thanks for confirming" bot
comments don't count as real activity when measuring staleness.
This commit is contained in:
Scot Breitenfeld
2026-06-23 14:03:12 -05:00
committed by GitHub
parent 0f1dfcc08e
commit 884ce02101
3 changed files with 212 additions and 31 deletions
+70 -14
View File
@@ -3,6 +3,8 @@
// CI-triggered push or unrelated comment shouldn't make an abandoned draft
// look "fresh". Checking a box is also easier to discover and use than
// remembering an exact phrase to comment.
'use strict';
const LABEL = "draft-stale";
const STALE_DAYS = 60;
const KEEPALIVE_MARKER = "<!-- draft-stale-keepalive -->";
@@ -36,6 +38,7 @@ async function findKeepAliveComment(github, owner, repo, issue_number) {
// pr.updated_at is bumped by metadata-only changes (reviewer requested/removed, labels,
// milestone, assignee, etc.), which would let a draft dodge the staleness check forever
// without any real work happening. Use the latest commit/comment/review activity instead.
// Bot comments are excluded — they represent automated activity, not real human progress.
async function lastRealActivityAt(github, owner, repo, pr) {
const timestamps = [new Date(pr.created_at).getTime()];
@@ -46,23 +49,85 @@ async function lastRealActivityAt(github, owner, repo, pr) {
}
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
for (const c of comments) timestamps.push(new Date(c.created_at).getTime());
for (const c of comments) {
if (c.user?.type !== "Bot") timestamps.push(new Date(c.created_at).getTime());
}
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100 });
for (const r of reviews) {
if (r.submitted_at) timestamps.push(new Date(r.submitted_at).getTime());
if (r.submitted_at && r.user?.type !== "Bot") timestamps.push(new Date(r.submitted_at).getTime());
}
const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 });
for (const rc of reviewComments) timestamps.push(new Date(rc.created_at).getTime());
for (const rc of reviewComments) {
if (rc.user?.type !== "Bot") timestamps.push(new Date(rc.created_at).getTime());
}
return Math.max(...timestamps);
}
// Returns true and removes the label if the PR has a keepalive signal:
// (a) the checkbox in the bot's keepalive comment is checked, OR
// (b) a non-bot human posted a comment after the keepalive comment was created.
// Case (b) handles external contributors who lack write access to edit the bot's comment.
async function processStaleSignals(github, owner, repo, pr) {
const keepAliveComment = await findKeepAliveComment(github, owner, repo, pr.number);
const checkboxChecked = keepAliveComment && KEEPALIVE_CHECKED_RE.test(keepAliveComment.body);
let humanCommentAfterStale = false;
if (!checkboxChecked && keepAliveComment) {
const staleDate = new Date(keepAliveComment.created_at);
const allComments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
for (const c of allComments) {
if (c.user?.type !== "Bot" && new Date(c.created_at) > staleDate) {
humanCommentAfterStale = true;
break;
}
}
}
if (checkboxChecked || humanCommentAfterStale) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }).catch(() => {});
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: "Thanks for confirming — removing the stale label.",
});
return true;
}
return false;
}
async function runDraftPolicy({ github, context, core }) {
const { owner, repo } = context.repo;
await ensureLabel(github, owner, repo);
// Fast path for issue_comment: only check the PR that received the comment.
// This avoids scanning all open PRs on every comment event.
if (context.eventName === "issue_comment") {
const issue = context.payload.issue;
// issue_comment fires for issues too; skip non-PRs (PRs have a pull_request field).
if (!issue?.pull_request) return;
let prData;
try {
({ data: prData } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number }));
} catch (e) {
return;
}
if (!prData.draft) return;
const labelNames = prData.labels.map((l) => l.name);
if (!labelNames.includes(LABEL)) return;
const removed = await processStaleSignals(github, owner, repo, prData);
core.info(`draft-pr-policy: PR #${issue.number} — ${removed ? "un-staled" : "still stale"} (issue_comment trigger)`);
return;
}
// Full scan path for schedule / workflow_dispatch.
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
const drafts = prs.filter((pr) => pr.draft);
@@ -81,23 +146,14 @@ async function runDraftPolicy({ github, context, core }) {
`${KEEPALIVE_MARKER}\n` +
`This draft has had no activity for ${STALE_DAYS} days and has been marked \`${LABEL}\`.\n\n` +
`${KEEPALIVE_CHECKBOX}\n\n` +
`Checking the box is the only thing that resets this -- a commit or other automated update alone won't. ` +
`Checking the box or posting a new comment resets this — a commit or other automated update alone won't. ` +
`Otherwise it will be flagged for maintainer review.`,
});
}
continue;
}
const keepAliveComment = await findKeepAliveComment(github, owner, repo, pr.number);
if (keepAliveComment && KEEPALIVE_CHECKED_RE.test(keepAliveComment.body)) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }).catch(() => {});
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: "Thanks for confirming — removing the stale label.",
});
}
await processStaleSignals(github, owner, repo, pr);
}
core.info(`draft-pr-policy: checked ${drafts.length} draft PR(s)`);
+125
View File
@@ -0,0 +1,125 @@
// Marks open, non-draft PRs as stale based on last *meaningful* activity:
// non-bot comments, review submissions, or commits pushed to the branch.
// Bot-only events (e.g. /remove-reviewer acknowledgment comments, CI status
// posts) do not reset the stale countdown.
const DAYS_BEFORE_STALE = 30;
const STALE_LABEL = "stale";
const EXEMPT_LABELS = ["pinned", "security"];
const STALE_MESSAGE =
"This pull request has had no activity for 30 days and has been marked stale. " +
"Push a commit or comment to keep it open, or it will be flagged for maintainer review.";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const daysSince = (iso) => (Date.now() - new Date(iso).getTime()) / MS_PER_DAY;
async function ensureLabel(github, owner, repo, name) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
owner,
repo,
name,
color: "ededed",
description: "No meaningful activity for 30+ days",
});
}
}
async function lastMeaningfulActivity(github, owner, repo, pr) {
let latest = new Date(pr.created_at);
// Comments from non-bot users only
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
});
for (const c of comments) {
if (c.user?.type === "Bot") continue;
const d = new Date(c.created_at);
if (d > latest) latest = d;
}
// Reviews from non-bot users only
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
for (const r of reviews) {
if (r.user?.type === "Bot") continue;
const d = new Date(r.submitted_at);
if (d > latest) latest = d;
}
// Commits pushed to the branch
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
for (const c of commits) {
const d = new Date(c.commit.committer?.date || c.commit.author?.date);
if (d > latest) latest = d;
}
return latest;
}
async function runMarkStale({ github, context, core }) {
const { owner, repo } = context.repo;
await ensureLabel(github, owner, repo, STALE_LABEL);
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});
let marked = 0,
unStaled = 0;
for (const pr of prs) {
if (pr.draft) continue;
const labelNames = pr.labels.map((l) => (typeof l === "string" ? l : l.name));
const exempt = EXEMPT_LABELS.some((l) => labelNames.includes(l));
const alreadyStale = labelNames.includes(STALE_LABEL);
const lastActive = await lastMeaningfulActivity(github, owner, repo, pr);
const days = daysSince(lastActive.toISOString());
if (alreadyStale && days < DAYS_BEFORE_STALE) {
// Meaningful human activity after stale was applied — remove the label
await github.rest.issues
.removeLabel({ owner, repo, issue_number: pr.number, name: STALE_LABEL })
.catch(() => {});
core.info(`Un-staled PR #${pr.number} (last meaningful activity ${Math.floor(days)} days ago)`);
unStaled++;
} else if (!alreadyStale && !exempt && days >= DAYS_BEFORE_STALE) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [STALE_LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: STALE_MESSAGE,
});
core.info(`Marked PR #${pr.number} as stale (${Math.floor(days)} days since last meaningful activity)`);
marked++;
}
}
core.info(`mark-stale: marked=${marked} un-staled=${unStaled}`);
}
module.exports = { runMarkStale };
+17 -17
View File
@@ -1,10 +1,10 @@
# Flags inactive PRs instead of letting them sit indefinitely. Issues are
# intentionally untouched by this workflow.
# Ready (non-draft) PRs use actions/stale for labeling. Draft PRs get a
# longer window and only reset by checking a keep-alive box in the bot's
# own comment, since a stale draft can otherwise look "fresh" from CI
# pushes alone. Nothing is ever auto-closed: once a PR has been stale past
# its alert threshold, the assignee (falling back to requested reviewers,
# Staleness is measured by the last *meaningful* activity: non-bot comments,
# review submissions, or commits — bot-only events (e.g. /remove-reviewer
# acknowledgments) do not reset the countdown. Draft PRs get a longer window
# handled separately. Nothing is ever auto-closed: once a PR has been stale
# past its alert threshold, the assignee (falling back to requested reviewers,
# then the author) is pinged to decide whether to close it or keep it open.
name: Stale PR policy
@@ -12,29 +12,29 @@ on:
workflow_dispatch:
schedule:
- cron: "0 6 * * *"
issue_comment:
types: [created]
permissions:
contents: read
jobs:
mark-stale:
if: github.repository_owner == 'HDFGroup'
if: github.repository_owner == 'HDFGroup' && github.event_name != 'issue_comment'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
exempt-draft-pr: true
days-before-issue-stale: -1 # issues are out of scope for this workflow
days-before-pr-stale: 30
days-before-close: -1 # never auto-close; see alert-stale job
stale-pr-label: stale
exempt-pr-labels: "pinned,security"
stale-pr-message: >
This pull request has had no activity for 30 days and has been marked stale.
Push a commit or comment to keep it open, or it will be flagged for maintainer review.
persist-credentials: false
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const { runMarkStale } = require('${{ github.workspace }}/.github/scripts/mark-stale.js');
await runMarkStale({ github, context, core });
draft-pr-policy:
if: github.repository_owner == 'HDFGroup'
@@ -55,7 +55,7 @@ jobs:
alert-stale:
needs: [mark-stale, draft-pr-policy]
if: github.repository_owner == 'HDFGroup'
if: github.repository_owner == 'HDFGroup' && github.event_name != 'issue_comment'
runs-on: ubuntu-latest
permissions:
contents: read