Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
# It also handles lifecycle slash commands for managing stale labels.
# For more information, see: https://github.com/actions/stale
#
# Security: Actions are pinned to full commit SHA to prevent supply chain attacks.
# To update, check releases and update both the SHA and version comment.
#
# Debug mode:
# - Lifecycle commands: Set DEBUG_ONLY to 'true' in the lifecycle-commands job env
# - Stale action: Set debug-only to true in the stale job configuration
name: Mark stale issues and pull requests

on:
schedule:
- cron: '30 1 * * *' # Daily at 1:30 AM UTC
issue_comment:
types: [created]

jobs:
lifecycle-commands:
runs-on: ubuntu-latest
if: github.event_name == 'issue_comment'
permissions:
issues: write
pull-requests: write

steps:
- name: Handle lifecycle commands
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
# Set to 'true' to test without making actual changes
DEBUG_ONLY: 'true'
with:
script: |
const comment = context.payload.comment.body.toLowerCase().trim();
const debugOnly = process.env.DEBUG_ONLY === 'true';

if (debugOnly) {
console.log('🔍 DEBUG MODE: No changes will be made');
}

// Define commands and their required permissions
const commands = {
'/lifecycle frozen': { label: 'lifecycle/frozen', requiresWrite: true },
'/lifecycle stale': { label: 'lifecycle/stale', requiresWrite: true },
'/lifecycle active': { action: 'remove-stale', requiresWrite: false },
'/remove-lifecycle frozen': { action: 'remove-frozen', requiresWrite: true },
'/remove-lifecycle stale': { action: 'remove-stale', requiresWrite: false }
};

// Check if comment contains a lifecycle command
const commandKey = Object.keys(commands).find(cmd =>
comment === cmd || comment.startsWith(cmd + ' ')
);

if (!commandKey) {
console.log('No lifecycle command found in comment');
return;
}

const commandConfig = commands[commandKey];
const issue_number = context.issue.number;

// Check user permissions for restricted commands
if (commandConfig.requiresWrite) {
if (debugOnly) {
console.log(`Would check permissions for user ${context.payload.comment.user.login} for ${commandKey}`);
} else {
try {
const { data: userPermission } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});

const hasWriteAccess = ['admin', 'write', 'maintain'].includes(userPermission.permission);

if (!hasWriteAccess) {
console.log(`User ${context.payload.comment.user.login} does not have permission for ${commandKey}`);
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: '-1'
});
return;
}
console.log(`User ${context.payload.comment.user.login} has ${userPermission.permission} access`);
} catch (error) {
console.log('Error checking permissions:', error.message);
return;
}
}
}

// Handle remove commands
if (commandConfig.action && commandConfig.action.startsWith('remove-')) {
const labelToRemove = commandConfig.action === 'remove-stale' ? 'lifecycle/stale' : 'lifecycle/frozen';

if (debugOnly) {
console.log(`Would remove ${labelToRemove} label from issue #${issue_number}`);
console.log('Would react with 👍 to comment');
} else {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
name: labelToRemove
});
console.log(`Removed ${labelToRemove} label`);

await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: '+1'
});
} catch (error) {
console.log(`Label ${labelToRemove} not found or already removed`);
}
}
}
// Handle add label commands
else if (commandConfig.label) {
if (debugOnly) {
console.log(`Would add ${commandConfig.label} label to issue #${issue_number}`);
console.log('Would react with 👍 to comment');
} else {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
labels: [commandConfig.label]
});
console.log(`Added ${commandConfig.label} label`);

await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: '+1'
});
} catch (error) {
console.log(`Error adding label: ${error.message}`);
}
}
}

stale:
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
permissions:
issues: write
pull-requests: write

steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
ascending: false
operations-per-run: 30

# Exempt labels - issues/PRs with these labels will never be marked stale
exempt-issue-labels: 'kind/help wanted,status/need-more-info,status/needs-analysis,lifecycle/frozen'
exempt-pr-labels: 'kind/help wanted,status/need-more-info,status/needs-analysis,lifecycle/frozen'

# Use lifecycle/stale label to match existing convention
stale-issue-label: 'lifecycle/stale'
stale-pr-label: 'lifecycle/stale'

# Stale messages
stale-issue-message: |
There hasn't been any activity on this issue for a long time. If the problem is still relevant, **add a comment** to keep it open. Otherwise, this issue will be automatically closed in 14 days.

**To remove the stale label:** Comment `/lifecycle active`
**To freeze (requires write access):** Comment `/lifecycle frozen`
stale-pr-message: |
Thanks for the PR. We'd like to make our product docs better, but haven't been able to review all the suggestions. As our docs change often and quickly diverge, we do not have the bandwidth to review and rebase old PRs.

If the updates are still relevant, please **add a comment** and review our [contribution guidelines](https://docs.docker.com/contribute/overview/) to rebase your PR against the latest version of the docs. This helps our maintainers focus on active contributions. If there's no activity, this PR will be closed in 30 days.

**To remove the stale label:** Comment `/lifecycle active`
**To freeze (requires write access):** Comment `/lifecycle frozen`

# Close messages
close-issue-message: |
Closing this issue as there hasn't been any activity for a long time.

If the problem is still relevant, please **open a new issue** and complete the issue template so we can capture the details required to investigate further. This helps our maintainers focus on active issues.
close-pr-message: |
Closing this PR as there hasn't been any activity for a long time.

If the updates are still relevant, please review our [contribution guidelines](https://docs.docker.com/contribute/overview/) and **create a new PR** against the latest version of our docs.

# Timing configuration NOTE: If you change days-before-issue-close or
# days-before-pr-close, also update the hardcoded values in the
# stale-issue-message and stale-pr-message above to match.
days-before-issue-stale: 180 # 6 months
days-before-pr-stale: 180 # 6 months
days-before-issue-close: 14 # 2 weeks after stale
days-before-pr-close: 30 # 1 month after stale

# Debug mode - set to false when ready for production
# When true, no actual changes will be made (dry-run for testing)
debug-only: true