diff --git a/.github/workflows/branch_sweep_all.yml b/.github/workflows/branch_sweep_all.yml new file mode 100644 index 0000000..c7f12b2 --- /dev/null +++ b/.github/workflows/branch_sweep_all.yml @@ -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; } diff --git a/bin/branch_sweep_targets.py b/bin/branch_sweep_targets.py new file mode 100644 index 0000000..5a50742 --- /dev/null +++ b/bin/branch_sweep_targets.py @@ -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 `/` 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 # 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} ", 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)) diff --git a/skills/repo_cleanup/reference.md b/skills/repo_cleanup/reference.md index 87ce11c..c83519b 100644 --- a/skills/repo_cleanup/reference.md +++ b/skills/repo_cleanup/reference.md @@ -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. diff --git a/tests/test_branch_sweep_targets.py b/tests/test_branch_sweep_targets.py new file mode 100644 index 0000000..8505601 --- /dev/null +++ b/tests/test_branch_sweep_targets.py @@ -0,0 +1,129 @@ +"""tests/test_branch_sweep_targets.py — the org-wide sweep's target boundary. + +The workflow refuses any target this module does not yield, so this is the +line between "a repo whose merged branches get deleted" and "a repo nobody +meant to touch". + +Every fixture here uses invented repo names. That is not squeamishness: this +file is `.py` under a framework organ, so the tenant firewall scans it, and an +assertion naming a real satellite repo would be the same leak the module was +rewritten to avoid. Testing against a synthetic body map also tests the actual +contract — categories in, slugs out — rather than today's repo roster. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +_spec = importlib.util.spec_from_file_location( + "branch_sweep_targets", BRAIN_HOME / "bin" / "branch_sweep_targets.py" +) +mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(mod) + + +def _body_map(entries: dict) -> dict: + return {"repos": entries} + + +def test_sweepable_categories_are_included(): + body = _body_map( + { + "Lib": {"github": "Org/Lib", "category": "library"}, + "Ws": {"github": "Org/Ws", "category": "workspace"}, + "WsTest": {"github": "Org/WsTest", "category": "workspace_test"}, + "WsDev": {"github": "Org/WsDev", "category": "workspace_developer"}, + "Howto": {"github": "Org/Howto", "category": "howto"}, + } + ) + assert mod.targets(body) == [ + "Org/Lib", + "Org/Ws", + "Org/WsTest", + "Org/WsDev", + "Org/Howto", + ] + + +def test_non_development_categories_are_excluded(): + """assistant/pipeline/project/admin are where the Never-touched repos live.""" + body = _body_map( + { + "Keep": {"github": "Org/Keep", "category": "library"}, + "Sci": {"github": "Org/Sci", "category": "assistant"}, + "Pipe": {"github": "Org/Pipe", "category": "pipeline"}, + "Site": {"github": "Org/Site", "category": "project"}, + "Admin": {"github": "Other/Admin", "category": "admin"}, + } + ) + assert mod.targets(body) == ["Org/Keep"] + + +def test_self_sweeping_organs_are_excluded_by_role_not_name(): + """The Mind and the Brain host their own sweeper; two would collide.""" + body = _body_map( + { + "TheMind": {"github": "Org/TheMind", "category": "organ", "organ": "Mind"}, + "TheBrain": {"github": "Org/TheBrain", "category": "organ", "organ": "Brain"}, + "TheHeart": {"github": "Org/TheHeart", "category": "organ", "organ": "Heart"}, + } + ) + assert mod.targets(body) == ["Org/TheHeart"] + + +def test_unknown_category_is_excluded_not_swept(): + """A category nobody has classified yet must fail closed.""" + body = _body_map( + { + "New": {"github": "Org/New", "category": "some_future_kind"}, + "Lib": {"github": "Org/Lib", "category": "library"}, + } + ) + assert mod.targets(body) == ["Org/Lib"] + + +def test_missing_category_is_excluded(): + body = _body_map({"Odd": {"github": "Org/Odd"}, "Lib": {"github": "Org/Lib", "category": "library"}}) + assert mod.targets(body) == ["Org/Lib"] + + +def test_cli_prints_one_slug_per_line(tmp_path): + import subprocess + import sys + + import yaml + + path = tmp_path / "repos.yaml" + path.write_text( + yaml.safe_dump( + _body_map( + { + "Lib": {"github": "Org/Lib", "category": "library"}, + "Sci": {"github": "Org/Sci", "category": "assistant"}, + } + ) + ) + ) + proc = subprocess.run( + [sys.executable, str(BRAIN_HOME / "bin" / "branch_sweep_targets.py"), str(path)], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.split() == ["Org/Lib"] + + +def test_cli_fails_loudly_on_a_missing_body_map(tmp_path): + """Silently sweeping nothing would read as a clean run.""" + import subprocess + import sys + + proc = subprocess.run( + [sys.executable, str(BRAIN_HOME / "bin" / "branch_sweep_targets.py"), str(tmp_path / "nope.yaml")], + capture_output=True, + text=True, + ) + assert proc.returncode == 1 + assert "no body map" in proc.stderr