Stale Issue Management #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Stale Issue Management | |
| # Trigger: daily schedule | |
| # Scans open issues with no activity for 30+ days and closes those stale 60+ days. | |
| # | |
| # Protected labels — issues carrying ANY of these are NEVER touched: | |
| # priority:p0 | priority:p1 | security-review | |
| # | |
| # Exclusion is enforced in four overlapping layers (defense in depth): | |
| # 1. Search query — protected-label issues are excluded from the query result set. | |
| # 2. Code guard — every candidate is skipped at the start of the loop body if a | |
| # protected label is still present (catches search-index lag). | |
| # 3. Structural — add-labels only allows [stale]; close requires [stale] already | |
| # present, guaranteeing at least one warning cycle before close. | |
| # 4. Post-check — the close-stale job re-fetches every candidate's labels via the | |
| # API immediately before closing and aborts if a protected label is | |
| # found. This layer survives any other failure. | |
| on: | |
| schedule: | |
| - cron: '0 9 * * *' # Daily at 09:00 UTC | |
| workflow_dispatch: | |
| # Protected labels, thresholds, and workflow configuration defined at workflow level. | |
| env: | |
| PROTECTED_LABELS: '["priority:p0","priority:p1","security-review"]' | |
| STALE_DAYS: '30' | |
| STALE_CLOSE_DAYS: '60' | |
| permissions: | |
| issues: write | |
| contents: read | |
| jobs: | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Job 1 — Warn issues inactive for 30+ days | |
| # Adds the `stale` label and posts a "last chance" comment. | |
| # Maximum 10 issues per run. | |
| # ────────────────────────────────────────────────────────────────────────── | |
| warn-stale: | |
| name: Warn stale issues (30+ days) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Warn stale issues | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| // Layer 2 — code guard: these labels make an issue untouchable regardless of | |
| // how it reached the candidate list. | |
| // PROTECTED_LABELS is defined at workflow level and shared with close-stale. | |
| const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS); | |
| const MAX_WARN = 20; | |
| const STALE_DAYS = parseInt(process.env.STALE_DAYS, 10); | |
| const STALE_CLOSE_DAYS = parseInt(process.env.STALE_CLOSE_DAYS, 10); | |
| const now = new Date(); | |
| const cutoff = new Date(now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000); | |
| const cutoffDate = cutoff.toISOString().split('T')[0]; | |
| // Layer 1 — search query: protected-label issues excluded at query time. | |
| // Exclude clauses are generated from PROTECTED_LABELS so the query always | |
| // stays in sync with the code guard above. | |
| const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`); | |
| const query = [ | |
| `repo:${context.repo.owner}/${context.repo.repo}`, | |
| 'is:issue', | |
| 'is:open', | |
| ...excludeClauses, | |
| '-label:stale', | |
| `updated:<${cutoffDate}` | |
| ].join(' '); | |
| core.info(`Warn query: ${query}`); | |
| const result = await github.rest.search.issuesAndPullRequests({ | |
| q: query, | |
| sort: 'updated', | |
| order: 'asc', | |
| per_page: MAX_WARN | |
| }); | |
| const candidates = result.data.items; | |
| core.info(`Found ${candidates.length} candidate(s) to warn`); | |
| let warned = 0; | |
| for (const issue of candidates) { | |
| if (warned >= MAX_WARN) break; | |
| // Layer 2 — code guard: skip if a protected label is present in the | |
| // search result payload (guards against search-index staleness). | |
| const labels = issue.labels.map(l => l.name); | |
| const hasProtected = PROTECTED_LABELS.some(l => labels.includes(l)); | |
| if (hasProtected) { | |
| core.info(`Skipping #${issue.number} — protected label present`); | |
| continue; | |
| } | |
| // Layer 3 — structural: only `stale` may be added by this workflow. | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| labels: ['stale'] | |
| }); | |
| const inactiveDays = Math.floor( | |
| (now - new Date(issue.updated_at)) / (24 * 60 * 60 * 1000) | |
| ); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| body: [ | |
| `⚠️ **Stale issue notice** — This issue has had no activity for **${inactiveDays} day(s)**.`, | |
| '', | |
| `It will be automatically closed after **${STALE_CLOSE_DAYS} days** of inactivity.`, | |
| '', | |
| 'To keep this issue open:', | |
| '- Leave a comment describing the current status, or', | |
| '- Remove the `stale` label.', | |
| '', | |
| '> This notice was posted automatically by the stale issue workflow.' | |
| ].join('\n') | |
| }); | |
| core.info(`Warned #${issue.number} (inactive ${inactiveDays} days)`); | |
| warned++; | |
| } | |
| core.info(`Warn run complete — warned ${warned} issue(s)`); | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Job 2 — Close issues inactive for 60+ days that already carry `stale` | |
| # Maximum 5 issues per run. Runs after warn-stale to avoid racing on issues | |
| # that were just warned in the same run. | |
| # ────────────────────────────────────────────────────────────────────────── | |
| close-stale: | |
| name: Close stale issues (60+ days) | |
| runs-on: ubuntu-latest | |
| needs: warn-stale | |
| steps: | |
| - name: Close warned stale issues | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| // Layer 2 — code guard. | |
| // PROTECTED_LABELS is defined at workflow level and shared with warn-stale. | |
| const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS); | |
| const MAX_CLOSE = 10; | |
| const STALE_CLOSE_DAYS = parseInt(process.env.STALE_CLOSE_DAYS, 10); | |
| const now = new Date(); | |
| const cutoff = new Date(now.getTime() - STALE_CLOSE_DAYS * 24 * 60 * 60 * 1000); | |
| const cutoffDate = cutoff.toISOString().split('T')[0]; | |
| // Layer 1 — search query: protected-label issues excluded at query time. | |
| // Layer 3 — structural: `label:stale` is required; issues without it are | |
| // never returned (guarantees at least one warning cycle before close). | |
| // Exclude clauses are generated from PROTECTED_LABELS so the query always | |
| // stays in sync with the code guard above. | |
| const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`); | |
| const query = [ | |
| `repo:${context.repo.owner}/${context.repo.repo}`, | |
| 'is:issue', | |
| 'is:open', | |
| 'label:stale', | |
| ...excludeClauses, | |
| `updated:<${cutoffDate}` | |
| ].join(' '); | |
| core.info(`Close query: ${query}`); | |
| const result = await github.rest.search.issuesAndPullRequests({ | |
| q: query, | |
| sort: 'updated', | |
| order: 'asc', | |
| per_page: MAX_CLOSE | |
| }); | |
| const candidates = result.data.items; | |
| core.info(`Found ${candidates.length} candidate(s) to close`); | |
| let closed = 0; | |
| for (const issue of candidates) { | |
| if (closed >= MAX_CLOSE) break; | |
| // Layer 4 — deterministic post-check: re-fetch labels from the API | |
| // immediately before closing. This is the only layer that survives a | |
| // fully compromised upstream step — it always reads live data. | |
| const fresh = await github.rest.issues.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number | |
| }); | |
| const freshLabels = fresh.data.labels.map(l => l.name); | |
| // Abort close if any protected label is present on the live issue. | |
| const hasProtected = PROTECTED_LABELS.some(l => freshLabels.includes(l)); | |
| if (hasProtected) { | |
| const hit = freshLabels.filter(l => PROTECTED_LABELS.includes(l)); | |
| core.warning( | |
| `Skipping close of #${issue.number} — protected label(s) detected: ${hit.join(', ')}` | |
| ); | |
| continue; | |
| } | |
| // Layer 3 — structural: stale label must be present on the live issue. | |
| if (!freshLabels.includes('stale')) { | |
| core.info(`Skipping #${issue.number} — stale label was removed`); | |
| continue; | |
| } | |
| // All four layers passed — safe to close. | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| state: 'closed', | |
| state_reason: 'not_planned' | |
| }); | |
| // Remove the stale label now that the issue is closed. | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| name: 'stale' | |
| }); | |
| const inactiveDays = Math.floor( | |
| (now - new Date(fresh.data.updated_at)) / (24 * 60 * 60 * 1000) | |
| ); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| body: [ | |
| `🔒 **Closing as stale** — This issue has had no activity for **${inactiveDays} day(s)**.`, | |
| '', | |
| 'It has been automatically closed as `not_planned`.', | |
| '', | |
| 'If this issue is still relevant, please:', | |
| '1. Reopen it, and', | |
| '2. Add a comment describing the current status.', | |
| '', | |
| '> This action was taken automatically by the stale issue workflow.' | |
| ].join('\n') | |
| }); | |
| core.info(`Closed #${issue.number} (inactive ${inactiveDays} days)`); | |
| closed++; | |
| } | |
| core.info(`Close run complete — closed ${closed} issue(s)`); |