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
171 changes: 171 additions & 0 deletions .github/workflows/branch_sweep_all.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
name: Branch Sweep (org-wide)

# The same sweep as branch_sweep.yml, run from the Brain across the organism's
# development repos instead of against this repo alone.
#
# WHY CENTRAL. A repo's own GITHUB_TOKEN reaches only that repo, so a per-repo
# sweeper means one workflow file per repo — 19 of them, drifting apart the
# moment one is edited. PAT_PYAUTOLABS already exists for exactly this kind of
# cross-repo work (spawn_drift.yml and arxiv_papers.yml use it), so the sweep
# is written once here and pointed at each repo in turn.
#
# THE GATE IS STRUCTURAL, NOT PROCEDURAL. Deleting branches across 19 repos on
# one click is not something a report should be able to talk you into, so:
#
# * `mode: delete` REQUIRES an explicit `repos` list. There is no
# delete-everything form of this workflow — the "all repos" path is
# audit-only, enforced below, not merely discouraged in a runbook.
# * The scheduled run is audit-only, like the per-repo sweeper.
#
# That matters more here than in a solo repo. PyAutoFit, PyAutoArray,
# PyAutoGalaxy, PyAutoLens and the workspaces take pull requests from outside
# contributors. The per-repo skill's rule for that ("never enumerate
# origin-only collaborator branches") cannot hold in a workflow, where every
# branch is origin-only — so a human reading the audit per repo IS the
# substitute for it. Do not automate that step away.
#
# Fork PRs are unaffected either way: their heads live in the fork, not here.

on:
workflow_dispatch:
inputs:
repos:
description: "Space/comma-separated owner/repo list. Empty = every sweepable repo (audit only)."
type: string
default: ""
mode:
description: "audit = report only · delete = remove (requires an explicit repos list)"
type: choice
options: [audit, delete]
default: audit
limit:
description: "Max branches to delete per repo (0 = no cap). Ignored in audit mode."
type: string
default: "0"
schedule:
# Weekly, audit-only. Staggered an hour after the per-repo sweepers so the
# two do not contend for the same API budget.
- cron: "40 5 * * 0"

permissions:
contents: read

concurrency:
group: branch-sweep-all
cancel-in-progress: false

jobs:
sweep:
runs-on: ubuntu-latest
steps:
- name: Check out PyAutoBrain (sweep logic)
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Check out PyAutoMind (the body map the targets derive from)
uses: actions/checkout@v4
with:
repository: ${{ github.repository_owner }}/PyAutoMind
path: .mind
fetch-depth: 1

- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install --quiet pyyaml

- name: Resolve targets and enforce the delete gate
id: plan
run: |
set -euo pipefail
mode='${{ inputs.mode }}'
[ '${{ github.event_name }}' = 'workflow_dispatch' ] || mode=audit
mode="${mode:-audit}"

requested=$(printf '%s' '${{ inputs.repos }}' | tr ',' ' ' | xargs || true)

if [ "$mode" = "delete" ] && [ -z "$requested" ]; then
echo "::error::mode=delete requires an explicit repos list."
echo "::error::Sweeping every repo in one unattended run is not a supported operation —"
echo "::error::run mode=audit first, read it, then name the repos to act on."
exit 1
fi

# Derived from the body map, never listed here — see
# bin/branch_sweep_targets.py for why naming repos in organ code is
# the leak the tenant firewall exists to catch.
allowed=$(python3 bin/branch_sweep_targets.py .mind/repos.yaml | xargs)
if [ -z "$allowed" ]; then
echo "::error::no sweepable repos derived from the body map — refusing to continue."
exit 1
fi
if [ -z "$requested" ]; then
targets="$allowed"
else
targets=""
for r in $requested; do
case " $allowed " in
*" $r "*) targets="$targets $r" ;;
*) echo "::error::'$r' is not a sweepable repo."
echo "::error::Sweepability comes from its category in the body map"
echo "::error::(PyAutoMind/repos.yaml) plus bin/branch_sweep_targets.py."
echo "::error::Changing either is a reviewed change, not a dispatch input."
exit 1 ;;
esac
done
fi

echo "mode=$mode" >> "$GITHUB_OUTPUT"
echo "targets=$(echo $targets)" >> "$GITHUB_OUTPUT"
echo "Mode: $mode"
echo "Targets:"; for t in $targets; do echo " $t"; done

- name: Sweep each repo
env:
# Cross-repo work needs more reach than this repo's GITHUB_TOKEN has.
GH_TOKEN: ${{ secrets.PAT_PYAUTOLABS }}
PAT: ${{ secrets.PAT_PYAUTOLABS }}
run: |
set -uo pipefail
mode='${{ steps.plan.outputs.mode }}'
targets='${{ steps.plan.outputs.targets }}'

if [ -z "${PAT:-}" ]; then
echo "::error::PAT_PYAUTOLABS is not set — cannot reach sibling repos."
exit 1
fi

echo "## Branch sweep (org-wide) — \`$mode\`" >> "$GITHUB_STEP_SUMMARY"
echo >> "$GITHUB_STEP_SUMMARY"

failed=0
for slug in $targets; do
name="${slug##*/}"
echo "::group::$slug"
# Full history: containment is an ancestry question and a shallow
# clone answers it wrong in the safe-looking direction.
if ! git clone --quiet "https://x-access-token:${PAT}@github.com/${slug}.git" "work/$name"; then
echo "::warning::clone failed for $slug — skipping"
printf '### %s\n\n_clone failed — skipped_\n\n' "$slug" >> "$GITHUB_STEP_SUMMARY"
failed=1; echo "::endgroup::"; continue
fi

bin/branch_sweep.sh \
--repo "$PWD/work/$name" \
--owner "${slug%%/*}" \
--name "$name" \
--mode "$mode" \
--limit '${{ inputs.limit || 0 }}' 2>&1 | tee "work/$name.log" || failed=1

{
printf '### %s\n\n```\n' "$slug"
cat "work/$name.log"
printf '```\n\n'
} >> "$GITHUB_STEP_SUMMARY"
# The clone carries a credentialed remote; do not leave it lying about.
rm -rf "work/$name"
echo "::endgroup::"
done

[ "$failed" -eq 0 ] || { echo "::error::one or more repos failed — see the groups above"; exit 1; }
84 changes: 84 additions & 0 deletions bin/branch_sweep_targets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Which repos the org-wide branch sweep may touch — derived, never listed.

The obvious implementation is a file listing the repos. It was written that
way first, and the tenant firewall rejected it: the framework organs must stay
adoptable as a config-diff fork, so instance facts (repo names, GitHub owners)
may not appear in Brain code outside the declared config surfaces. A hardcoded
list of `<owner>/<repo>` slugs is precisely that leak — and because the
firewall only scans `.py` and `.sh`, putting the list in a `.txt` did not make
it clean, it only made it invisible. (Nor is prose exempt: the firewall reads
comments too, and rightly — a docstring naming satellite repos is still
something an adopting fork has to rewrite.)

The hygiene conductor already had the answer, recorded in the firewall
allowlist next to the entry it deleted: *"derives its repo sets from the body
map, so it names no instance fact at all."* Same here. Policy is expressed as
**categories**, which every organism has; the repos filling them are whatever
that organism's body map says.

The two exclusions fall out of the categories rather than needing names:

assistant science workspaces, not dev repos — one of the two repos on the
skill's Never-touched list sits here
pipeline the other one
project publication surfaces; swept by hand if at all
admin personal repos, often a different owner whose PAT scope is
unverified

And the Mind and the Brain drop out by *organ role*, not by name: each hosts
its own `branch_sweep.yml` and sweeps itself with its own `GITHUB_TOKEN`.
Sweeping them centrally too would put two sweepers on one repo with different
credentials. "Mind" and "Brain" are organism roles present in any fork, so
naming them here is not an instance fact.

Usage:
branch_sweep_targets.py <path-to-repos.yaml> # one owner/repo per line
"""

from __future__ import annotations

import sys
from pathlib import Path

# Categories whose repos are ordinary development repos: agent-driven, branch
# churn from the dev workflow, nothing a sweep would surprise. Widening this
# set is a reviewed decision — it is the whole boundary.
SWEEPABLE_CATEGORIES = frozenset(
{"organ", "library", "workspace", "workspace_test", "workspace_developer", "howto"}
)

# Organ roles that sweep themselves. Roles, not repo names — an adopting fork
# has a Mind and a Brain too, whatever it calls them.
SELF_SWEEPING_ORGANS = frozenset({"Mind", "Brain"})


def targets(body_map: dict) -> list[str]:
"""The sweepable `owner/repo` slugs, in body-map order."""
out = []
for entry in body_map["repos"].values():
if entry.get("category") not in SWEEPABLE_CATEGORIES:
continue
if entry.get("organ") in SELF_SWEEPING_ORGANS:
continue
out.append(entry["github"])
return out


def main(argv: list[str]) -> int:
if len(argv) != 2:
print(f"usage: {Path(argv[0]).name} <path-to-repos.yaml>", file=sys.stderr)
return 2
import yaml

path = Path(argv[1])
if not path.is_file():
print(f"branch_sweep_targets: no body map at {path}", file=sys.stderr)
return 1
for slug in targets(yaml.safe_load(path.read_text())):
print(slug)
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
33 changes: 29 additions & 4 deletions skills/repo_cleanup/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,35 @@ workflow `GITHUB_TOKEN` that does have `contents: write`:
exactly as for Bucket B locally.
4. Re-dispatch with `mode: delete` (add `limit` for a first cautious batch).

`branch_sweep.yml` currently exists in **PyAutoMind** and **PyAutoBrain**. For a
repo without it, say so and offer to add it rather than falling back to
hand-deletion — the file is repo-independent (it reads `github.repository`), so
adding it is a copy.
`branch_sweep.yml` currently exists in **PyAutoMind** and **PyAutoBrain**, which
sweep themselves with their own `GITHUB_TOKEN`.

**Every other development repo** is swept centrally instead, by PyAutoBrain's
`branch_sweep_all.yml` using `PAT_PYAUTOLABS`. Same dispatch/poll/report loop,
with `repos` naming the targets. Two things about it are not negotiable from
the chat side:

- **`mode: delete` refuses to run without an explicit `repos` list.** There is
no sweep-everything button; the all-repos path is audit-only. Dispatch the
audit, show the human the per-repo split, then name the repos to act on.
- **A target the body map does not make sweepable fails the whole run**,
including a run that also names valid repos. Widening the set is a reviewed
change, never a dispatch input.

Which repos those are is **derived, never listed**: `bin/branch_sweep_targets.py`
reads `PyAutoMind/repos.yaml` and takes the development categories (organ,
library, workspace, workspace_test, workspace_developer, howto), dropping the
Mind and the Brain by organ role because they sweep themselves. The skill's
Never-touched repos fall out with their categories — `autolens_assistant` is an
`assistant`, `euclid_strong_lens_modeling_pipeline` a `pipeline` — so no rule
names them twice. Brain code may not name satellite repos at all (the tenant
firewall enforces this; a hardcoded list was written first and rejected).

That per-repo human read replaces the local sweep's *"never enumerate
origin-only collaborator branches"* rule, which a workflow cannot honour —
every branch it sees is origin-only. The libraries and workspaces take pull
requests from outside contributors, so do not shortcut it. (Fork PRs are
unaffected: their heads live in the fork.)

**The setting that makes this mostly unnecessary.** A repo whose merged PR heads
survive has *Settings → General → "Automatically delete head branches"* off.
Expand Down
Loading
Loading