Stale Check #26
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
# ------------------------------------------------------------------------------------ | |
# Stale Check Workflow | |
# | |
# Purpose: Warn about and close inactive issues and PRs to maintain repository hygiene. | |
# This workflow identifies stale items, marks them with a label, and eventually closes | |
# them if no activity occurs within the configured timeframe. | |
# | |
# Configuration: All settings are loaded from .github/.env.shared for centralized | |
# management across all workflows. | |
# | |
# Triggers: | |
# - Scheduled: Monday-Friday at 08:32 UTC | |
# - Manual: Via workflow_dispatch | |
# | |
# Maintainer: @mrz1836 | |
# | |
# ------------------------------------------------------------------------------------ | |
name: Stale Check | |
# ———————————————————————————————————————————————————————————————— | |
# Trigger Configuration | |
# ———————————————————————————————————————————————————————————————— | |
on: | |
schedule: | |
# ┌─ min ─┬─ hour ─┬─ dom ─┬─ mon ─┬─ dow ─┐ | |
- cron: "0 12 * * 1-5" # 7:00 AM EST (12:00 UTC) | |
workflow_dispatch: # Allow manual triggering | |
# ———————————————————————————————————————————————————————————————— | |
# Permissions | |
# ———————————————————————————————————————————————————————————————— | |
permissions: | |
contents: read | |
# ———————————————————————————————————————————————————————————————— | |
# Concurrency Control | |
# ———————————————————————————————————————————————————————————————— | |
concurrency: | |
group: ${{ github.workflow }}-${{ github.ref }} | |
cancel-in-progress: true | |
jobs: | |
# ---------------------------------------------------------------------------------- | |
# Load Environment Variables from .env.shared | |
# ---------------------------------------------------------------------------------- | |
load-env: | |
name: 🌍 Load Environment Variables | |
runs-on: ubuntu-latest | |
outputs: | |
env-json: ${{ steps.load-env.outputs.env-json }} | |
steps: | |
# ———————————————————————————————————————————————————————————————— | |
# Check out code to access env file | |
# ———————————————————————————————————————————————————————————————— | |
- name: 📥 Checkout code (sparse) | |
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | |
with: | |
sparse-checkout: | | |
.github/.env.shared | |
.github/actions/load-env | |
# ———————————————————————————————————————————————————————————————— | |
# Load and parse environment file | |
# ———————————————————————————————————————————————————————————————— | |
- name: 🌍 Load environment variables | |
uses: ./.github/actions/load-env | |
id: load-env | |
# ---------------------------------------------------------------------------------- | |
# Main Stale Check Job | |
# ---------------------------------------------------------------------------------- | |
stale-check: | |
name: 🧹 Process Stale Items | |
needs: [load-env] | |
runs-on: ubuntu-latest | |
permissions: | |
issues: write # Required to add labels and comments | |
pull-requests: write # Required to add labels and comments on PRs | |
steps: | |
# ———————————————————————————————————————————————————————————————— | |
# Log token configuration | |
# ———————————————————————————————————————————————————————————————— | |
- name: 🔑 Log token configuration | |
env: | |
ENV_JSON: ${{ needs.load-env.outputs.env-json }} | |
run: | | |
PREFERRED_TOKEN=$(echo "$ENV_JSON" | jq -r '.PREFERRED_GITHUB_TOKEN') | |
if [[ "$PREFERRED_TOKEN" == "GH_PAT_TOKEN" && -n "${{ secrets.GH_PAT_TOKEN }}" ]]; then | |
echo "✅ Using Personal Access Token (PAT) for stale check operations" | |
else | |
echo "✅ Using default GITHUB_TOKEN for stale check operations" | |
fi | |
# ———————————————————————————————————————————————————————————————— | |
# Extract environment variables | |
# ———————————————————————————————————————————————————————————————— | |
- name: 🔧 Extract stale configuration | |
id: config | |
env: | |
ENV_JSON: ${{ needs.load-env.outputs.env-json }} | |
run: | | |
echo "🎯 Extracting stale workflow configuration..." | |
# Extract stale-specific variables from JSON | |
DAYS_BEFORE_STALE=$(echo "$ENV_JSON" | jq -r '.STALE_DAYS_BEFORE_STALE') | |
DAYS_BEFORE_CLOSE=$(echo "$ENV_JSON" | jq -r '.STALE_DAYS_BEFORE_CLOSE') | |
STALE_LABEL=$(echo "$ENV_JSON" | jq -r '.STALE_LABEL') | |
EXEMPT_ISSUE_LABELS=$(echo "$ENV_JSON" | jq -r '.STALE_EXEMPT_ISSUE_LABELS') | |
EXEMPT_PR_LABELS=$(echo "$ENV_JSON" | jq -r '.STALE_EXEMPT_PR_LABELS') | |
OPERATIONS_PER_RUN=$(echo "$ENV_JSON" | jq -r '.STALE_OPERATIONS_PER_RUN') | |
# Export to outputs | |
echo "days-before-stale=$DAYS_BEFORE_STALE" >> $GITHUB_OUTPUT | |
echo "days-before-close=$DAYS_BEFORE_CLOSE" >> $GITHUB_OUTPUT | |
echo "stale-label=$STALE_LABEL" >> $GITHUB_OUTPUT | |
echo "exempt-issue-labels=$EXEMPT_ISSUE_LABELS" >> $GITHUB_OUTPUT | |
echo "exempt-pr-labels=$EXEMPT_PR_LABELS" >> $GITHUB_OUTPUT | |
echo "operations-per-run=$OPERATIONS_PER_RUN" >> $GITHUB_OUTPUT | |
echo "✅ Configuration extracted successfully" | |
# ———————————————————————————————————————————————————————————————— | |
# Calculate cutoff dates for stale detection | |
# ———————————————————————————————————————————————————————————————— | |
- name: 📅 Calculate cutoff dates | |
id: dates | |
run: | | |
echo "⏱️ Calculating stale and close cutoff dates..." | |
# Calculate dates for stale marking and closing | |
DAYS_BEFORE_STALE="${{ steps.config.outputs.days-before-stale }}" | |
DAYS_BEFORE_CLOSE="${{ steps.config.outputs.days-before-close }}" | |
stale_date=$(date -d "$DAYS_BEFORE_STALE days ago" --iso-8601) | |
close_date=$(date -d "$(( $DAYS_BEFORE_STALE + $DAYS_BEFORE_CLOSE )) days ago" --iso-8601) | |
echo "stale_cutoff=${stale_date}" >> $GITHUB_OUTPUT | |
echo "close_cutoff=${close_date}" >> $GITHUB_OUTPUT | |
echo "📊 === Stale Check Configuration ===" | |
echo "🔸 Stale cutoff date: ${stale_date} (${DAYS_BEFORE_STALE} days ago)" | |
echo "🔸 Close cutoff date: ${close_date} ($(( ${DAYS_BEFORE_STALE} + ${DAYS_BEFORE_CLOSE} )) days ago)" | |
echo "🔸 Stale label: ${{ steps.config.outputs.stale-label }}" | |
echo "🔸 Operations limit: ${{ steps.config.outputs.operations-per-run }}" | |
echo "✅ Date calculations complete" | |
# ———————————————————————————————————————————————————————————————— | |
# Process issues for stale marking and closing | |
# ———————————————————————————————————————————————————————————————— | |
- name: 📋 Process stale issues | |
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
env: | |
ENV_JSON: ${{ needs.load-env.outputs.env-json }} | |
with: | |
github-token: ${{ secrets.GH_PAT_TOKEN != '' && secrets.GH_PAT_TOKEN || secrets.GITHUB_TOKEN }} | |
script: | | |
const staleCutoff = '${{ steps.dates.outputs.stale_cutoff }}'; | |
const closeCutoff = '${{ steps.dates.outputs.close_cutoff }}'; | |
const staleLabel = '${{ steps.config.outputs.stale-label }}'; | |
const exemptLabels = '${{ steps.config.outputs.exempt-issue-labels }}'.split(',').map(l => l.trim()).filter(l => l); | |
const operationsLimit = parseInt('${{ steps.config.outputs.operations-per-run }}'); | |
const daysBeforeClose = parseInt('${{ steps.config.outputs.days-before-close }}'); | |
const envJson = JSON.parse(process.env.ENV_JSON); | |
const preferredToken = envJson.PREFERRED_GITHUB_TOKEN; | |
const isUsingPAT = preferredToken === 'GH_PAT_TOKEN' && '${{ secrets.GH_PAT_TOKEN }}' !== ''; | |
console.log('📋 === Processing Issues ==='); | |
console.log(`🏷️ Exempt labels: ${exemptLabels.join(', ')}`); | |
console.log(`🔑 Token type: ${isUsingPAT ? 'Personal Access Token (PAT)' : 'Default GITHUB_TOKEN'}`); | |
let operationsCount = 0; | |
let processedCount = 0; | |
let markedStaleCount = 0; | |
let closedCount = 0; | |
// Helper function to check if issue has exempt labels | |
function hasExemptLabel(issue) { | |
const issueLabels = issue.labels.map(label => label.name); | |
return exemptLabels.some(exempt => issueLabels.includes(exempt)); | |
} | |
// Helper function to check if issue is already stale | |
function isAlreadyStale(issue) { | |
return issue.labels.some(label => label.name === staleLabel); | |
} | |
// Get all open issues with pagination | |
const iterator = github.paginate.iterator(github.rest.issues.listForRepo, { | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
state: 'open', | |
sort: 'updated', | |
direction: 'asc', | |
per_page: 100 | |
}); | |
for await (const { data: issues } of iterator) { | |
for (const issue of issues) { | |
// Skip pull requests (they're handled separately) | |
if (issue.pull_request) continue; | |
// Stop if we've hit our operations limit | |
if (operationsCount >= operationsLimit) { | |
console.log(`⚠️ Reached operations limit (${operationsLimit}), stopping`); | |
break; | |
} | |
processedCount++; | |
const updatedAt = new Date(issue.updated_at); | |
const daysSinceUpdate = Math.floor((Date.now() - updatedAt.getTime()) / (1000 * 60 * 60 * 24)); | |
console.log(`🔍 Processing issue #${issue.number}: "${issue.title}" (updated ${daysSinceUpdate} days ago)`); | |
// Skip if issue has exempt labels | |
if (hasExemptLabel(issue)) { | |
console.log(` ⏭️ Skipping: has exempt label`); | |
continue; | |
} | |
const alreadyStale = isAlreadyStale(issue); | |
// Check if issue should be closed (already stale + past close cutoff) | |
if (alreadyStale && updatedAt < new Date(closeCutoff)) { | |
try { | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: issue.number, | |
body: `This issue was automatically closed after **${daysSinceUpdate} days** of inactivity. If this is still relevant, feel free to re-open.` | |
}); | |
await github.rest.issues.update({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: issue.number, | |
state: 'closed' | |
}); | |
console.log(` ✅ Closed issue #${issue.number}`); | |
closedCount++; | |
operationsCount += 2; | |
} catch (error) { | |
console.log(` ❌ Failed to close issue #${issue.number}: ${error.message}`); | |
} | |
} | |
// Check if issue should be marked as stale | |
else if (!alreadyStale && updatedAt < new Date(staleCutoff)) { | |
try { | |
await github.rest.issues.addLabels({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: issue.number, | |
labels: [staleLabel] | |
}); | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: issue.number, | |
body: `This issue has been inactive for **${daysSinceUpdate} days** and will be closed in ${daysBeforeClose} days if no further activity occurs.` | |
}); | |
console.log(` 🏷️ Marked issue #${issue.number} as stale`); | |
markedStaleCount++; | |
operationsCount += 2; | |
} catch (error) { | |
console.log(` ❌ Failed to mark issue #${issue.number} as stale: ${error.message}`); | |
} | |
} | |
else { | |
console.log(` ✅ Issue #${issue.number} is still active`); | |
} | |
} | |
if (operationsCount >= operationsLimit) break; | |
} | |
console.log('\n📊 === Issues Summary ==='); | |
console.log(`✅ Processed: ${processedCount} issues`); | |
console.log(`🏷️ Marked stale: ${markedStaleCount} issues`); | |
console.log(`🔒 Closed: ${closedCount} issues`); | |
console.log(`⚡ Operations used: ${operationsCount}/${operationsLimit}`); | |
# ———————————————————————————————————————————————————————————————— | |
# Process pull requests for stale marking and closing | |
# ———————————————————————————————————————————————————————————————— | |
- name: 🔀 Process stale pull requests | |
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
env: | |
ENV_JSON: ${{ needs.load-env.outputs.env-json }} | |
with: | |
github-token: ${{ secrets.GH_PAT_TOKEN != '' && secrets.GH_PAT_TOKEN || secrets.GITHUB_TOKEN }} | |
script: | | |
const staleCutoff = '${{ steps.dates.outputs.stale_cutoff }}'; | |
const closeCutoff = '${{ steps.dates.outputs.close_cutoff }}'; | |
const staleLabel = '${{ steps.config.outputs.stale-label }}'; | |
const exemptLabels = '${{ steps.config.outputs.exempt-pr-labels }}'.split(',').map(l => l.trim()).filter(l => l); | |
const operationsLimit = parseInt('${{ steps.config.outputs.operations-per-run }}'); | |
const daysBeforeClose = parseInt('${{ steps.config.outputs.days-before-close }}'); | |
console.log('\n🔀 === Processing Pull Requests ==='); | |
console.log(`🏷️ Exempt labels: ${exemptLabels.join(', ')}`); | |
let operationsCount = 0; | |
let processedCount = 0; | |
let markedStaleCount = 0; | |
let closedCount = 0; | |
// Helper functions (same as issues) | |
function hasExemptLabel(pr) { | |
const prLabels = pr.labels.map(label => label.name); | |
return exemptLabels.some(exempt => prLabels.includes(exempt)); | |
} | |
function isAlreadyStale(pr) { | |
return pr.labels.some(label => label.name === staleLabel); | |
} | |
// Get all open pull requests with pagination | |
const iterator = github.paginate.iterator(github.rest.pulls.list, { | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
state: 'open', | |
sort: 'updated', | |
direction: 'asc', | |
per_page: 100 | |
}); | |
for await (const { data: prs } of iterator) { | |
for (const pr of prs) { | |
// Stop if we've hit our operations limit | |
if (operationsCount >= operationsLimit) { | |
console.log(`⚠️ Reached operations limit (${operationsLimit}), stopping`); | |
break; | |
} | |
processedCount++; | |
const updatedAt = new Date(pr.updated_at); | |
const daysSinceUpdate = Math.floor((Date.now() - updatedAt.getTime()) / (1000 * 60 * 60 * 24)); | |
console.log(`🔍 Processing PR #${pr.number}: "${pr.title}" (updated ${daysSinceUpdate} days ago)`); | |
// Skip draft PRs | |
if (pr.draft) { | |
console.log(` ⏭️ Skipping: draft PR`); | |
continue; | |
} | |
// Skip if PR has exempt labels | |
if (hasExemptLabel(pr)) { | |
console.log(` ⏭️ Skipping: has exempt label`); | |
continue; | |
} | |
const alreadyStale = isAlreadyStale(pr); | |
// Check if PR should be closed (already stale + past close cutoff) | |
if (alreadyStale && updatedAt < new Date(closeCutoff)) { | |
try { | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: pr.number, | |
body: `This PR was automatically closed after **${daysSinceUpdate} days** of inactivity. If you plan to resume work, please re-open.` | |
}); | |
await github.rest.pulls.update({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
pull_number: pr.number, | |
state: 'closed' | |
}); | |
console.log(` ✅ Closed PR #${pr.number}`); | |
closedCount++; | |
operationsCount += 2; | |
} catch (error) { | |
console.log(` ❌ Failed to close PR #${pr.number}: ${error.message}`); | |
} | |
} | |
// Check if PR should be marked as stale | |
else if (!alreadyStale && updatedAt < new Date(staleCutoff)) { | |
try { | |
await github.rest.issues.addLabels({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: pr.number, | |
labels: [staleLabel] | |
}); | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: pr.number, | |
body: `This pull request has been inactive for **${daysSinceUpdate} days** and will be closed in ${daysBeforeClose} days if no further activity occurs.` | |
}); | |
console.log(` 🏷️ Marked PR #${pr.number} as stale`); | |
markedStaleCount++; | |
operationsCount += 2; | |
} catch (error) { | |
console.log(` ❌ Failed to mark PR #${pr.number} as stale: ${error.message}`); | |
} | |
} | |
else { | |
console.log(` ✅ PR #${pr.number} is still active`); | |
} | |
} | |
if (operationsCount >= operationsLimit) break; | |
} | |
console.log('\n📊 === Pull Requests Summary ==='); | |
console.log(`✅ Processed: ${processedCount} PRs`); | |
console.log(`🏷️ Marked stale: ${markedStaleCount} PRs`); | |
console.log(`🔒 Closed: ${closedCount} PRs`); | |
console.log(`⚡ Operations used: ${operationsCount}/${operationsLimit}`); | |
# ———————————————————————————————————————————————————————————————— | |
# Clean up stale labels from recently updated items | |
# ———————————————————————————————————————————————————————————————— | |
- name: 🏷️ Remove stale labels from updated items | |
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
with: | |
github-token: ${{ secrets.GH_PAT_TOKEN != '' && secrets.GH_PAT_TOKEN || secrets.GITHUB_TOKEN }} | |
script: | | |
const staleCutoff = new Date('${{ steps.dates.outputs.stale_cutoff }}'); | |
const staleLabel = '${{ steps.config.outputs.stale-label }}'; | |
console.log('\n🏷️ === Cleaning Stale Labels ==='); | |
console.log('🔍 Looking for recently updated items with stale labels...'); | |
let removedCount = 0; | |
let checkedCount = 0; | |
// Helper function to check if item should have stale label removed | |
function shouldRemoveStaleLabel(item) { | |
const updatedAt = new Date(item.updated_at); | |
return updatedAt > staleCutoff; | |
} | |
// Process issues with stale label | |
console.log('📋 Checking issues...'); | |
const issuesIterator = github.paginate.iterator(github.rest.issues.listForRepo, { | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
state: 'open', | |
labels: staleLabel, | |
per_page: 100 | |
}); | |
for await (const { data: issues } of issuesIterator) { | |
for (const issue of issues) { | |
// Skip pull requests (they're handled separately) | |
if (issue.pull_request) continue; | |
checkedCount++; | |
if (shouldRemoveStaleLabel(issue)) { | |
try { | |
await github.rest.issues.removeLabel({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: issue.number, | |
name: staleLabel | |
}); | |
console.log(` ✅ Removed stale label from issue #${issue.number}: "${issue.title}"`); | |
removedCount++; | |
} catch (error) { | |
if (error.status === 404) { | |
console.log(` ℹ️ Label not found on issue #${issue.number} (already removed)`); | |
} else { | |
console.log(` ❌ Failed to remove stale label from issue #${issue.number}: ${error.message}`); | |
} | |
} | |
} | |
} | |
} | |
// Process pull requests with stale label | |
console.log('\n🔀 Checking pull requests...'); | |
const prsIterator = github.paginate.iterator(github.rest.pulls.list, { | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
state: 'open', | |
per_page: 100 | |
}); | |
for await (const { data: prs } of prsIterator) { | |
for (const pr of prs) { | |
// Check if PR has stale label | |
const prDetails = await github.rest.pulls.get({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
pull_number: pr.number | |
}); | |
const hasStaleLabel = prDetails.data.labels.some(label => label.name === staleLabel); | |
if (hasStaleLabel) { | |
checkedCount++; | |
if (shouldRemoveStaleLabel(pr)) { | |
try { | |
await github.rest.issues.removeLabel({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: pr.number, | |
name: staleLabel | |
}); | |
console.log(` ✅ Removed stale label from PR #${pr.number}: "${pr.title}"`); | |
removedCount++; | |
} catch (error) { | |
if (error.status === 404) { | |
console.log(` ℹ️ Label not found on PR #${pr.number} (already removed)`); | |
} else { | |
console.log(` ❌ Failed to remove stale label from PR #${pr.number}: ${error.message}`); | |
} | |
} | |
} | |
} | |
} | |
} | |
console.log(`\n📊 === Label Cleanup Summary ===`); | |
console.log(`🔍 Checked: ${checkedCount} items with stale label`); | |
console.log(`✅ Removed stale labels from: ${removedCount} items`); | |
# ———————————————————————————————————————————————————————————————— | |
# Generate a workflow summary report | |
# ———————————————————————————————————————————————————————————————— | |
- name: 📊 Generate workflow summary | |
env: | |
ENV_JSON: ${{ needs.load-env.outputs.env-json }} | |
run: | | |
echo "🚀 Generating workflow summary..." | |
# Determine which token was used | |
PREFERRED_TOKEN=$(echo "$ENV_JSON" | jq -r '.PREFERRED_GITHUB_TOKEN') | |
if [[ "$PREFERRED_TOKEN" == "GH_PAT_TOKEN" && -n "${{ secrets.GH_PAT_TOKEN }}" ]]; then | |
TOKEN_TYPE="🔑 Personal Access Token (PAT)" | |
else | |
TOKEN_TYPE="🔑 Default GITHUB_TOKEN" | |
fi | |
echo "# 🧹 Stale Check Workflow Summary" >> $GITHUB_STEP_SUMMARY | |
echo "" >> $GITHUB_STEP_SUMMARY | |
echo "**⏰ Completed:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY | |
echo "" >> $GITHUB_STEP_SUMMARY | |
echo "## ⚙️ Configuration" >> $GITHUB_STEP_SUMMARY | |
echo "| Setting | Value |" >> $GITHUB_STEP_SUMMARY | |
echo "|---------|-------|" >> $GITHUB_STEP_SUMMARY | |
echo "| Days before stale | ${{ steps.config.outputs.days-before-stale }} |" >> $GITHUB_STEP_SUMMARY | |
echo "| Days before close | ${{ steps.config.outputs.days-before-close }} |" >> $GITHUB_STEP_SUMMARY | |
echo "| Stale label | ${{ steps.config.outputs.stale-label }} |" >> $GITHUB_STEP_SUMMARY | |
echo "| Operations limit | ${{ steps.config.outputs.operations-per-run }} |" >> $GITHUB_STEP_SUMMARY | |
echo "| Token type | $TOKEN_TYPE |" >> $GITHUB_STEP_SUMMARY | |
echo "" >> $GITHUB_STEP_SUMMARY | |
echo "## 🏷️ Exempt Labels" >> $GITHUB_STEP_SUMMARY | |
echo "- **Issues:** ${{ steps.config.outputs.exempt-issue-labels }}" >> $GITHUB_STEP_SUMMARY | |
echo "- **Pull Requests:** ${{ steps.config.outputs.exempt-pr-labels }}" >> $GITHUB_STEP_SUMMARY | |
echo "" >> $GITHUB_STEP_SUMMARY | |
echo "📋 _Check the job logs above for detailed processing statistics._" >> $GITHUB_STEP_SUMMARY | |
echo "" >> $GITHUB_STEP_SUMMARY | |
echo "✅ **Stale check workflow completed successfully!**" >> $GITHUB_STEP_SUMMARY |