Skip to content
Merged
Show file tree
Hide file tree
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
151 changes: 151 additions & 0 deletions .github/workflows/_stale-pull-requests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
name: stale-pull-requests

on:
workflow_call:
inputs:
dry-run:
description: Report eligible pull requests without closing or deleting them.
required: false
default: true
type: boolean

permissions:
contents: write
pull-requests: write

jobs:
stale:
runs-on: ubuntu-24.04
steps:
- name: Close inactive pull requests and delete recoverable branches
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRY_RUN: ${{ inputs.dry-run }}
with:
script: |
const inactiveForMs = 30 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - inactiveForMs;
const dryRun = process.env.DRY_RUN === 'true';
const exemptLabels = new Set(['do-not-close', 'blocked', 'security']);
const pullRequests = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'asc',
per_page: 100,
});
const { data: repository } = await github.rest.repos.get({
owner: context.repo.owner,
repo: context.repo.repo,
});

for (const { number } of pullRequests) {
try {
const [{ data: pullRequest }, { data: issue }] = await Promise.all([
github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: number,
}),
github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
}),
]);
const hasExemptLabel = issue.labels.some(
({ name }) => exemptLabels.has(name.toLowerCase()),
);

if (
pullRequest.state !== 'open' ||
pullRequest.draft ||
issue.milestone ||
issue.assignees.length > 0 ||
hasExemptLabel ||
Date.parse(pullRequest.updated_at) > cutoff
) {
continue;
}

if (dryRun) {
core.info(`Would close inactive PR #${number}: ${pullRequest.title}`);
continue;
}

await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: number,
state: 'closed',
});
const { data: closedPullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: number,
});
if (closedPullRequest.state !== 'closed') {
core.warning(`Skipping #${number}: the pull request did not close.`);
continue;
}

const branch = closedPullRequest.head.ref;
if (closedPullRequest.head.repo?.full_name !== repository.full_name) {
core.info(`Skipping branch deletion for #${number}: head branch is not in this repository.`);
continue;
}
if (branch === repository.default_branch) {
core.info(`Skipping branch deletion for #${number}: head branch is the default branch.`);
continue;
}

let branchDetails;
try {
({ data: branchDetails } = await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch,
}));
} catch (error) {
core.warning(`Skipping branch deletion for #${number}: cannot inspect ${branch}: ${error.message}`);
continue;
}
if (branchDetails.protected) {
core.info(`Skipping branch deletion for #${number}: ${branch} is protected.`);
continue;
}

const [{ data: openHeadPullRequests }, { data: openBasePullRequests }] =
await Promise.all([
github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${branch}`,
per_page: 1,
}),
github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
base: branch,
per_page: 1,
}),
]);
if (openHeadPullRequests.length > 0 || openBasePullRequests.length > 0) {
core.info(`Skipping branch deletion for #${number}: ${branch} is used by an open pull request.`);
continue;
}

await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${branch}`,
});
core.info(`Closed PR #${number} and deleted recoverable head branch ${branch}.`);
} catch (error) {
core.warning(`Skipping PR #${number} after an API error: ${error.message}`);
}
}
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,49 @@ pre-commit drift fails the `test` job and the pull request branch must be
updated manually. Fork pull requests never receive secrets and always fail on
drift.

### Stale pull request lifecycle

`tfroot-github` owns the scheduled caller at
`.github/workflows/stale-pull-requests.yml`; do not hand-maintain that path in
a consumer repository. The reusable callee is
`_stale-pull-requests.yml`.

```yaml
name: stale-pull-requests

on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
stale:
uses: makeitworkcloud/shared-workflows/.github/workflows/_stale-pull-requests.yml@main
with:
dry-run: true
```

The caller must be on the consumer's default branch for scheduled execution.
It accepts no secrets. The workflow reports only to its Actions log, and when
`dry-run` is `false`, closes non-draft, unassigned, unmilestoned pull requests
whose `pullRequest.updated_at` is at least 30 days old. Labels `do-not-close`,
`blocked`, and `security` are exempt.

After a successful close, it deletes only an unprotected same-repository head
branch that is neither the default branch nor used as the head or base of any
open pull request. GitHub's closed-pull-request **Restore branch** path is the
recovery mechanism. Enable live mode only after a reviewed dry-run pilot.

## Available Workflows

| Workflow | Description |
|---|---|
| `opentofu.yml` | OpenTofu/Terraform CI/CD with PR validation and an environment-gated apply on every push to `main` |
| `_stale-pull-requests.yml` | Dry-run-first reusable lifecycle for closing pull requests inactive for at least 30 days and deleting only recoverable eligible head branches. |

Same-repository PRs run tests and a credentialed plan; fork PRs run tests only. A push to `main` runs tests followed by a fresh apply, which does not reuse the PR plan. The apply job uses the caller's `environment` input (default `production`); repository owners must configure that GitHub Environment with the required protection rules.

Expand Down