From 9500cd49ecc6df06416056d143bea8bea0451562 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Wed, 9 Sep 2026 00:18:20 -0600 Subject: [PATCH 1/5] feat: add reusable stale PR lifecycle workflow --- .github/workflows/stale-pull-requests.yml | 97 +++++++++++++++++++++++ README.md | 1 + 2 files changed, 98 insertions(+) create mode 100644 .github/workflows/stale-pull-requests.yml diff --git a/.github/workflows/stale-pull-requests.yml b/.github/workflows/stale-pull-requests.yml new file mode 100644 index 0000000..6ed429e --- /dev/null +++ b/.github/workflows/stale-pull-requests.yml @@ -0,0 +1,97 @@ +--- +name: stale-pull-requests + +on: + workflow_call: + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-24.04 + steps: + # actions/stale only evaluates its close window after it marks a PR stale. + # A zero-day window therefore closes on the following daily run, never + # before the configured 30-day inactivity threshold. + - name: Close inactive pull requests + id: stale + uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11 + with: + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 30 + days-before-pr-close: 0 + stale-pr-label: stale + stale-pr-message: "" + close-pr-message: >- + This pull request was closed after 30 days without activity. Its + same-repository head branch is deleted only when it is eligible for + GitHub's closed-pull-request branch recovery. + exempt-pr-labels: do-not-close,blocked,security + exempt-all-pr-milestones: true + exempt-all-pr-assignees: true + exempt-draft-pr: true + delete-branch: false + operations-per-run: 30 + + - name: Delete recoverable same-repository head branches + if: steps.stale.outputs['closed-issues-prs'] != '[]' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CLOSED_PULL_REQUESTS: ${{ steps.stale.outputs['closed-issues-prs'] }} + with: + script: | + const closed = JSON.parse(process.env.CLOSED_PULL_REQUESTS || '[]'); + const { data: repository } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + + for (const { number } of closed) { + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + }); + const branch = pullRequest.head.ref; + + if (pullRequest.head.repo?.full_name !== repository.full_name) { + core.info(`Skipping #${number}: the head branch is not in this repository.`); + continue; + } + if (branch === repository.default_branch) { + core.info(`Skipping #${number}: the head branch is the default branch.`); + continue; + } + + const { data: branchDetails } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch, + }); + if (branchDetails.protected) { + core.info(`Skipping #${number}: ${branch} is protected.`); + continue; + } + + const { data: openPullRequests } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${branch}`, + }); + if (openPullRequests.length > 0) { + core.info(`Skipping #${number}: ${branch} is referenced by another open pull request.`); + continue; + } + + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}`, + }); + core.info(`Deleted same-repository head branch ${branch} for closed PR #${number}.`); + } diff --git a/README.md b/README.md index 3913afe..3bcc2be 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ drift. | Workflow | Description | |----------|-------------| | `opentofu.yml` | OpenTofu/Terraform CI/CD with PR validation and an apply on every push to `main` | +| `stale-pull-requests.yml` | Closes pull requests after 30 days without activity and deletes only eligible recoverable same-repository 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. From cb6b26b1e1ab7cb4ad03185f2a65ad5220e1edfb Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Wed, 9 Sep 2026 00:32:20 -0600 Subject: [PATCH 2/5] refactor: reserve stale caller workflow path --- .github/workflows/stale-pull-requests.yml | 97 ----------------------- 1 file changed, 97 deletions(-) delete mode 100644 .github/workflows/stale-pull-requests.yml diff --git a/.github/workflows/stale-pull-requests.yml b/.github/workflows/stale-pull-requests.yml deleted file mode 100644 index 6ed429e..0000000 --- a/.github/workflows/stale-pull-requests.yml +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: stale-pull-requests - -on: - workflow_call: - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - stale: - runs-on: ubuntu-24.04 - steps: - # actions/stale only evaluates its close window after it marks a PR stale. - # A zero-day window therefore closes on the following daily run, never - # before the configured 30-day inactivity threshold. - - name: Close inactive pull requests - id: stale - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11 - with: - days-before-issue-stale: -1 - days-before-issue-close: -1 - days-before-pr-stale: 30 - days-before-pr-close: 0 - stale-pr-label: stale - stale-pr-message: "" - close-pr-message: >- - This pull request was closed after 30 days without activity. Its - same-repository head branch is deleted only when it is eligible for - GitHub's closed-pull-request branch recovery. - exempt-pr-labels: do-not-close,blocked,security - exempt-all-pr-milestones: true - exempt-all-pr-assignees: true - exempt-draft-pr: true - delete-branch: false - operations-per-run: 30 - - - name: Delete recoverable same-repository head branches - if: steps.stale.outputs['closed-issues-prs'] != '[]' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - CLOSED_PULL_REQUESTS: ${{ steps.stale.outputs['closed-issues-prs'] }} - with: - script: | - const closed = JSON.parse(process.env.CLOSED_PULL_REQUESTS || '[]'); - const { data: repository } = await github.rest.repos.get({ - owner: context.repo.owner, - repo: context.repo.repo, - }); - - for (const { number } of closed) { - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: number, - }); - const branch = pullRequest.head.ref; - - if (pullRequest.head.repo?.full_name !== repository.full_name) { - core.info(`Skipping #${number}: the head branch is not in this repository.`); - continue; - } - if (branch === repository.default_branch) { - core.info(`Skipping #${number}: the head branch is the default branch.`); - continue; - } - - const { data: branchDetails } = await github.rest.repos.getBranch({ - owner: context.repo.owner, - repo: context.repo.repo, - branch, - }); - if (branchDetails.protected) { - core.info(`Skipping #${number}: ${branch} is protected.`); - continue; - } - - const { data: openPullRequests } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - head: `${context.repo.owner}:${branch}`, - }); - if (openPullRequests.length > 0) { - core.info(`Skipping #${number}: ${branch} is referenced by another open pull request.`); - continue; - } - - await github.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${branch}`, - }); - core.info(`Deleted same-repository head branch ${branch} for closed PR #${number}.`); - } From 062fde67a4d0fd46d4868bcd17653efa5d28af81 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Wed, 9 Sep 2026 00:33:14 -0600 Subject: [PATCH 3/5] fix: enforce stale PR lifecycle safeguards --- .github/workflows/_stale-pull-requests.yml | 151 +++++++++++++++++++++ README.md | 38 +++++- 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/_stale-pull-requests.yml diff --git a/.github/workflows/_stale-pull-requests.yml b/.github/workflows/_stale-pull-requests.yml new file mode 100644 index 0000000..1fb3210 --- /dev/null +++ b/.github/workflows/_stale-pull-requests.yml @@ -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(issue.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}`); + } + } diff --git a/README.md b/README.md index 3bcc2be..344d809 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,48 @@ 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, and when `dry-run` is `false`, +closes non-draft, unassigned, unmilestoned pull requests with no update for at +least 30 days. 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 apply on every push to `main` | -| `stale-pull-requests.yml` | Closes pull requests after 30 days without activity and deletes only eligible recoverable same-repository head branches. | +| `_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. From d47b62eb7a79bde1e8954cad25c01c3fcb6e74d1 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Wed, 9 Sep 2026 00:38:32 -0600 Subject: [PATCH 4/5] docs: clarify stale PR activity source --- .github/workflows/_stale-pull-requests.yml | 2 +- README.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/_stale-pull-requests.yml b/.github/workflows/_stale-pull-requests.yml index 1fb3210..5628cd5 100644 --- a/.github/workflows/_stale-pull-requests.yml +++ b/.github/workflows/_stale-pull-requests.yml @@ -65,7 +65,7 @@ jobs: issue.milestone || issue.assignees.length > 0 || hasExemptLabel || - Date.parse(issue.updated_at) > cutoff + Date.parse(pullRequest.updated_at) > cutoff ) { continue; } diff --git a/README.md b/README.md index 344d809..e8f03ec 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,10 @@ jobs: ``` The caller must be on the consumer's default branch for scheduled execution. -It accepts no secrets. The workflow reports, and when `dry-run` is `false`, -closes non-draft, unassigned, unmilestoned pull requests with no update for at -least 30 days. Labels `do-not-close`, `blocked`, and `security` are exempt. +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 From 26d66bf53ad94392c58c060cf6b8bbd299c59d00 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Wed, 9 Sep 2026 00:48:34 -0600 Subject: [PATCH 5/5] docs: resolve README merge conflict --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e8f03ec..570c10b 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,11 @@ 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 apply on every push to `main` | +|---|---| +| `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. +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. ## Runners @@ -138,5 +138,6 @@ See [images](https://github.com/makeitworkcloud/images) for container source and 1. Grant `id-token: write` in the caller workflow so GitHub OIDC can authenticate the cloud provider. 2. For AWS roots, ensure the default `aws-role-to-assume` exists (`arn:aws:iam::332355796717:role/github-actions-sops-kms`) or pass another role ARN. 3. For GCP roots, pass both `gcp-workload-identity-provider` and `gcp-service-account`; this selects Google Workload Identity Federation instead of AWS credentials. -4. Create caller workflow in `.github/workflows/`. -5. Ensure repository has required files (e.g., `Makefile` with expected targets). +4. Create the caller workflow in `.github/workflows/`. +5. Create the GitHub Environment selected by `environment` (default `production`) and configure its required reviewers and protection rules before allowing a `main` apply. +6. Ensure the repository has required files (e.g., `Makefile` with expected targets).