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
119 changes: 119 additions & 0 deletions .github/workflows/dead-branch-sweep.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: dead-branch-sweep

# Central, org-wide dead-branch sweeper. Lives here (not as a per-repo
# workflow_call reusable) because "dead branches across the org" is a single
# cross-repo job, not something each consumer repo needs to opt into
# separately — same shape as register-skill.yml.
#
# SAFETY MODEL (see scripts/dead-branch-sweep.sh for the full contract):
# - Only ever DELETES branches that are fully MERGED (checked two ways —
# commit-containment AND merged-PR history, so squash/rebase merges are
# still recognised as merged).
# - NEVER touches the default branch, the mnab set (main/next/before/
# after), anything GitHub itself reports as protected, or any branch
# with an open PR.
# - Unmerged + stale (no open PR, no activity > stale_days) branches are
# REPORT-ONLY in the job summary + JSON artifact — never deleted. That
# silence-plus-no-PR signal is exactly the "stalled but maybe real work"
# case the legibility doctrine says to surface, not erase.
# - Defaults to dry-run. The scheduled (cron) run only arms (actually
# deletes) if the repo variable BRANCH_SWEEP_ARMED is literally "true" —
# an explicit, separate, operator-controlled step outside this PR. A
# manual workflow_dispatch run defaults its own dry_run input to true as
# well, so nothing deletes anything until someone deliberately says so
# twice over (once to arm the schedule, once per manual run if used).
#
# SCOPE NOTE: this reaches only repos the BRANCH_SWEEP_TOKEN can see — i.e.
# alfred-intelligence-owned repos reachable by the fleet identity that owns
# the token. It is not an org-universal or cross-owner sweep; GeGGe01/
# SAVANTERNA/kebab-it-owned repos are out of scope by construction (see
# docs/branch-sweep.md).
#
# ACTION PINNING NOTE: this repo's established convention (72f78e9) leaves
# GitHub-owned actions (actions/checkout et al) on floating major tags —
# only third-party actions get SHA-pinned — because CodeQL's unpinned-tag
# query itself trusts first-party actions/* actions. This workflow is a
# deliberate exception: actions/checkout and actions/upload-artifact are
# SHA-pinned here even though they're first-party, because the Sweep step
# runs with BRANCH_SWEEP_TOKEN (an org-wide contents:write PAT) in the same
# job — a floating tag on a step that shares a job with that token is a
# bigger blast radius than the convention was written for. Bump the SHA
# deliberately (not by just re-adding @v4) when a new checkout/upload-
# artifact major version is needed.

on:
schedule:
# Weekly, off-peak, off-the-hour: Sunday 03:17 UTC.
- cron: "17 3 * * 0"
workflow_dispatch:
inputs:
dry_run:
description: "Dry run — report what WOULD be deleted without deleting."
required: true
default: true
type: boolean
stale_days:
description: "Days of inactivity before an unmerged branch is reported (never deleted)."
required: false
default: "90"
type: string
repos_override:
description: "Optional comma-separated owner/repo list. Empty = enumerate the whole org."
required: false
default: ""
type: string

permissions:
contents: read # only for checking out this repo's own script

jobs:
sweep:
runs-on: ubuntu-latest
steps:
- name: Verify configuration
env:
SWEEP_TOKEN_SET: ${{ secrets.BRANCH_SWEEP_TOKEN != '' }}
run: |
if [[ "$SWEEP_TOKEN_SET" != "true" ]]; then
echo "::error::missing secret BRANCH_SWEEP_TOKEN — see docs/branch-sweep.md"
exit 1
fi

- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Resolve dry-run mode
id: mode
env:
ARMED_VAR: ${{ vars.BRANCH_SWEEP_ARMED }}
IS_SCHEDULE: ${{ github.event_name == 'schedule' }}
DISPATCH_DRY_RUN: ${{ inputs.dry_run }}
run: |
# Schedule runs are dry-run unless the operator has explicitly set
# the BRANCH_SWEEP_ARMED repo/org variable to "true". Manual
# dispatch runs use the input as-is (itself defaulting to true).
if [[ "$IS_SCHEDULE" == "true" ]]; then
if [[ "$ARMED_VAR" == "true" ]]; then
echo "dry_run=false" >> "$GITHUB_OUTPUT"
else
echo "dry_run=true" >> "$GITHUB_OUTPUT"
fi
else
echo "dry_run=${DISPATCH_DRY_RUN}" >> "$GITHUB_OUTPUT"
fi

- name: Sweep
env:
GH_TOKEN: ${{ secrets.BRANCH_SWEEP_TOKEN }}
ORG: alfred-intelligence
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
STALE_DAYS: ${{ inputs.stale_days || '90' }}
REPOS_OVERRIDE: ${{ inputs.repos_override || '' }}
OUTPUT_JSON: ${{ runner.temp }}/sweep-summary.json
run: bash scripts/dead-branch-sweep.sh | tee -a "$GITHUB_STEP_SUMMARY"

- name: Upload JSON summary
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dead-branch-sweep-summary
path: ${{ runner.temp }}/sweep-summary.json
retention-days: 90
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ alfred-intelligence ecosystem.
| [`.github/workflows/dependabot-automerge.yml`](.github/workflows/dependabot-automerge.yml) | Reusable (`workflow_call`) Dependabot auto-merge: minor/patch bumps queue `gh pr merge --auto` once required checks are green; majors are left for a human. Policy: `DECISIONS.md` (S-konservoppnaren). |
| [`scripts/gh-app-installation-token.sh`](scripts/gh-app-installation-token.sh) | Mint a GitHub App installation token outside a workflow (local agent use, e.g. Governator's org-ruleset sweep). |
| [`docs/aifred-governance-app.md`](docs/aifred-governance-app.md) | The read-only `aifred-governance` App: permissions, credential location, how to invoke the mint script. |
| [`.github/workflows/dead-branch-sweep.yml`](.github/workflows/dead-branch-sweep.yml) | Central, org-wide sweep that deletes only fully-merged branches; reports (never deletes) stale unmerged ones. Dry-run by default. |
| [`scripts/dead-branch-sweep.sh`](scripts/dead-branch-sweep.sh) | The sweep logic, callable standalone for local testing. |
| [`docs/branch-sweep.md`](docs/branch-sweep.md) | Safety guarantees, token scope, arming procedure for the dead-branch sweep. |

## Quick start

Expand Down
137 changes: 137 additions & 0 deletions docs/branch-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Dead-branch sweep

`dead-branch-sweep.yml` is a central, org-wide job (not a per-repo
`workflow_call` reusable) that finds and removes dead branches across
`alfred-intelligence`. It lives in this repo the same way
`register-skill.yml` does: one cross-repo job, deployed once, rather than
something each consumer repo opts into separately.

## Safety guarantees (read this before arming it)

- **Deletes ONLY fully-merged branches.** "Merged" is decided two ways,
OR-ed together:
1. Commit-containment: `compare <default>...<branch>` reports `identical`
or `behind` — the branch has no commits the default branch lacks.
2. Merged-PR history: a closed PR exists for that branch head with
`merged_at` set **and** that PR's `head.sha` equals the branch's
*current* tip commit — this is what catches **squash and rebase
merges**, which (1) alone would miss because the base branch gets a
*new* commit SHA, not the branch's original commits. The head-SHA match
is load-bearing, not decorative: GitHub keeps closed-PR records
forever, so without it a *reused* branch name (force-pushed with new,
genuinely-unmerged commits after an earlier PR on that name merged, or
deleted and recreated) would still read as merged off the stale PR
record even though commit-containment correctly says diverged — that
was a real data-loss bug (security review, pre-merge) fixed before this
PR shipped.
- **Never deletes:** the repo's own default branch, the mnab set
(`main`, `next`, `before`, `after`) by name, any branch GitHub's API
itself reports as `protected` (covers release branches and anything
branch-protection-covered beyond the mnab set), or any branch with an
**open** pull request — regardless of merge state.
- **Stale unmerged branches are report-only.** A branch with no open PR,
not merged, and no commit activity in `stale_days` (default 90) is listed
in the job summary and JSON artifact for human triage — never deleted.
Per the legibility doctrine, an unmerged branch nobody opened a PR for is
exactly the "stalled but maybe-real work" signal, not garbage.
- **Dry-run by default, twice over.** The scheduled (cron) run only deletes
anything if the repo variable `BRANCH_SWEEP_ARMED` is literally `true` —
an explicit step outside this workflow's own PR. A manual
`workflow_dispatch` run's `dry_run` input also defaults to `true`. Nothing
deletes until an operator deliberately flips one of those.

## What it does not cover

- **Scope is the token's reach, not the whole org's repos in a legal
sense.** The sweep enumerates repos via `GET /orgs/alfred-intelligence/
repos` using `BRANCH_SWEEP_TOKEN` — it only sees repos that token can
read/write. It never crosses to `GeGGe01`, `SAVANTERNA`, `kebab-it`, or
any other owner; those are out of scope by construction, not by choice
each run.
- It does not touch tags, releases, or anything outside `refs/heads/*`.
- It does not open issues. Report output is the job summary (markdown
tables, human-readable in the Actions run) plus a JSON artifact
(`dead-branch-sweep-summary`, 90-day retention) for anything that wants
to consume it programmatically (e.g. a future lumberjack ingest).

## Configuration

| Name | Type | Scope | Purpose |
|---|---|---|---|
| `BRANCH_SWEEP_TOKEN` | secret | org (recommended) or repo | Token used for every cross-repo API call: list repos/branches, compare, list PRs, delete refs. |
| `BRANCH_SWEEP_ARMED` | variable | org or repo | Must be exactly `true` for the **scheduled** run to actually delete anything. Absent/anything else = dry-run. |

`workflow_dispatch` inputs (`dry_run`, `stale_days`, `repos_override`) are
per-run and don't need any variable set — useful for a supervised first
pass before touching `BRANCH_SWEEP_ARMED` at all.

## Token scope — current state vs. target state

**Current (this PR):** `BRANCH_SWEEP_TOKEN` is a fine-grained PAT owned by
the fleet bot account (`alfred-int-bot`), following the same pattern as
`MARKETPLACE_PAT` (see [`docs/auth.md`](auth.md)) — a bot-owned token kept
in the org (or repo) secret store, least-privilege repository permissions:

- `Contents: Read and write` (branch read/delete)
- `Pull requests: Read` (open-PR check, merged-PR history for squash/rebase
detection — write is never needed)

Set while signed in as the bot account, resource owner = the bot account,
repository access = **All repositories** in `alfred-intelligence` (needed
to reach the whole org from one token; requires org-admin approval for a
member-owned fine-grained PAT with org-wide repo access — an operator
step, same class as any fine-grained-PAT-across-many-repos grant).

**Target state:** this exact permission profile —
`Contents: write` + `Pull requests: read`, org-wide — is inside the
already-decided but not-yet-registered `aifred-maintenance` App's scope
(`.github-private/strategy/meta-apps.md`: "Stale-issue triage,
dependency-bump PRs, lint-fix PRs" — a dead-branch sweep is the same class
of maintenance chore). Once that App is registered (blocked on an
operator-only GitHub UI step, same as `aifred-governance`'s), migrate this
workflow to `actions/create-github-app-token` with
`permission-contents: write` / `permission-pull-requests: read`
(sub-setting the App's own broader declared permissions, per the "Token
issuance pattern" in `meta-apps.md`) instead of a standing PAT. This is a
mechanical swap of the auth step only — the sweep logic
(`scripts/dead-branch-sweep.sh`) does not change.

## Governance note — flagged, not decided here

`DECISIONS.md` has no existing entry authorizing "merged branches get
auto-deleted org-wide." The zero-cost-review-stack and mnab-CI-gate-gradient
decisions govern *what runs and what's required*, not branch lifecycle —
this sweep doesn't fight either (it never touches protected branches or
required-check config, and dry-run-by-default keeps it inert until an
operator arms it). But "delete X automatically, org-wide" is exactly the
shape of decision the governance doc says lives in `DECISIONS.md`, not
something a workflow silently decides for itself. **This PR does not add
that entry** — it ships the mechanism inert (dry-run) and flags that the
operator/governance-owner should add a short `DECISIONS.md` post before
`BRANCH_SWEEP_ARMED` is ever set to `true` anywhere.

**Second gate, not just the first:** the `DECISIONS.md` entry authorizing
org-wide auto-delete is not the *only* precondition for arming. It must ALSO
confirm the merged-branch decision in `scripts/dead-branch-sweep.sh` still
requires a merged PR's `head.sha` to match the branch's current tip (the
fix described above) before `BRANCH_SWEEP_ARMED` is set anywhere — a future
edit to that script that drops the SHA-match check (e.g. "simplify" back to
`any(.[]; .merged_at != null)`) reopens the reused-branch-name data-loss
path even with a clean DECISIONS.md authorization in place. Whoever reviews
the arming request should diff the live script against this contract, not
just check that a governance entry exists.

## Local testing

The script is standalone and callable outside Actions:

```bash
GH_TOKEN=$(gh auth token) \
ORG=alfred-intelligence \
DRY_RUN=true \
STALE_DAYS=90 \
bash scripts/dead-branch-sweep.sh
```

Use `REPOS_OVERRIDE=owner/repo1,owner/repo2` to scope a test run to one or
two repos before trusting it against the whole org.
Loading
Loading