From 5a2dbf85e6c64bad568010320b0dc40447482575 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 14:31:40 +0000 Subject: [PATCH 1/2] feature(branch_sweep): sweep the rest of the organism from the Brain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mind and Brain now sweep themselves, but a repo's GITHUB_TOKEN reaches only that repo — so covering the other 18 in Scope would mean 18 copies of the same workflow, drifting apart the moment one is edited. PAT_PYAUTOLABS already exists for cross-repo work (spawn_drift.yml, arxiv_papers.yml), so the sweep is written once here and pointed at each repo in turn. The gate is structural, not procedural. Deleting branches across 18 repos on one click is not something a report should be able to talk anyone into: - `mode=delete` REFUSES to run without an explicit `repos` list. There is no delete-everything form of this workflow; the all-repos path is audit-only. - A target outside branch_sweep_set.txt fails the whole run — including a run that also names valid repos. Fail closed, not partially. - The scheduled run is audit-only regardless of input. Why that is stricter here than for Mind/Brain: those are solo agent repos. PyAutoFit, PyAutoArray, PyAutoGalaxy, PyAutoLens and the workspaces take pull requests from outside contributors, and the per-repo skill's protection for that case — never enumerate origin-only collaborator branches — cannot hold in a workflow, where every branch it sees IS origin-only. A human reading the audit per repo is the substitute, so the mechanism makes that step unskippable rather than trusting a runbook. (Fork PRs are unaffected; their heads live in the fork.) branch_sweep_set.txt is policy and so lives in the Brain, not in repos.yaml — the body map's own header says identity there, per-organ policy with the organ. It carries its exclusions with reasons: the skill's two Never-touched repos, the assistants and publication surfaces, a different owner whose PAT scope is unverified, and Mind/Brain themselves, which would otherwise end up with two sweepers on different credentials. Tests pin the boundary rather than the happy path: the Never-touched and self-sweeping exclusions each fail a test if someone adds them back, and every entry is checked against the body map so a typo cannot surface as a mid-sweep clone failure. The gate's eight cases (including valid-plus-forbidden) were exercised directly against the set file before this landed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KwqicJpMqmcT5RVbyNwdKq --- .github/workflows/branch_sweep_all.yml | 150 +++++++++++++++++++++++++ bin/branch_sweep_set.txt | 51 +++++++++ skills/repo_cleanup/reference.md | 24 +++- tests/test_branch_sweep_set.py | 78 +++++++++++++ 4 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/branch_sweep_all.yml create mode 100644 bin/branch_sweep_set.txt create mode 100644 tests/test_branch_sweep_set.py diff --git a/.github/workflows/branch_sweep_all.yml b/.github/workflows/branch_sweep_all.yml new file mode 100644 index 0000000..eb7ee9a --- /dev/null +++ b/.github/workflows/branch_sweep_all.yml @@ -0,0 +1,150 @@ +name: Branch Sweep (org-wide) + +# The same sweep as branch_sweep.yml, run from the Brain across the repos in +# bin/branch_sweep_set.txt 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 repo in branch_sweep_set.txt (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 + repo set) + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - 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 + + allowed=$(grep -vE '^\s*(#|$)' bin/branch_sweep_set.txt | xargs) + 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 in bin/branch_sweep_set.txt." + echo "::error::Add it there in a reviewed change if it should be sweepable." + 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_set.txt b/bin/branch_sweep_set.txt new file mode 100644 index 0000000..616ab46 --- /dev/null +++ b/bin/branch_sweep_set.txt @@ -0,0 +1,51 @@ +# branch_sweep_set.txt — which repos the org-wide branch sweep may touch. +# +# This is POLICY, so it lives in the Brain. PyAutoMind/repos.yaml is the body +# map — repo *identity* (GitHub home, category, role) — and deliberately holds +# no per-organ policy. "Which repos may have branches deleted" is exactly the +# kind of decision repos.yaml's own header says to keep with the organ that +# owns it. +# +# The set mirrors skills/repo_cleanup/SKILL.md "Scope". Keep the two in step: +# the skill is the prose, this file is what actually executes. +# +# Format: one `owner/repo` per line. `#` comments and blank lines ignored. +# +# DELIBERATELY ABSENT, and why — do not add these without a human decision: +# +# euclid_strong_lens_modeling_pipeline on the skill's Never-touched list +# autolens_assistant on the skill's Never-touched list +# z_projects* / z_staging / bad / priors not in the body map; never swept +# Jammy2211/* different owner; PAT scope unverified +# *_assistant (autocti/autofit/autogalaxy) science workspaces, not dev repos +# PyAutoScientist, pyautolabs.github.io publication surfaces, swept by hand +# +# PyAutoMind and PyAutoBrain are absent on purpose too: each hosts its own +# branch_sweep.yml and sweeps itself with its own GITHUB_TOKEN. Listing them +# here would give the same repo two sweepers with different credentials. + +# --- libraries ----------------------------------------------------------- +PyAutoLabs/PyAutoNerves +PyAutoLabs/PyAutoFit +PyAutoLabs/PyAutoArray +PyAutoLabs/PyAutoGalaxy +PyAutoLabs/PyAutoLens +PyAutoLabs/PyAutoHands + +# --- workspaces ---------------------------------------------------------- +PyAutoLabs/autofit_workspace +PyAutoLabs/autogalaxy_workspace +PyAutoLabs/autolens_workspace +PyAutoLabs/autocti_workspace +PyAutoLabs/autoreduce_workspace + +# --- workspace variants -------------------------------------------------- +PyAutoLabs/autofit_workspace_test +PyAutoLabs/autogalaxy_workspace_test +PyAutoLabs/autolens_workspace_test +PyAutoLabs/autocti_workspace_test +PyAutoLabs/autofit_workspace_developer +PyAutoLabs/autolens_workspace_developer + +# --- tutorials ----------------------------------------------------------- +PyAutoLabs/HowToLens diff --git a/skills/repo_cleanup/reference.md b/skills/repo_cleanup/reference.md index 87ce11c..7c3df65 100644 --- a/skills/repo_cleanup/reference.md +++ b/skills/repo_cleanup/reference.md @@ -134,10 +134,26 @@ 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 repo in Scope** is swept centrally instead, by PyAutoBrain's +`branch_sweep_all.yml` over `bin/branch_sweep_set.txt` 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 outside `branch_sweep_set.txt` fails the whole run**, including a + run that also names valid repos. Widening the set is a reviewed change to + that file, never a dispatch input. + +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_set.py b/tests/test_branch_sweep_set.py new file mode 100644 index 0000000..1f93d87 --- /dev/null +++ b/tests/test_branch_sweep_set.py @@ -0,0 +1,78 @@ +"""tests/test_branch_sweep_set.py — the org-wide sweep's target list. + +`bin/branch_sweep_set.txt` is the only thing standing between the org-wide +sweep and a repo nobody meant to touch: the workflow refuses any target not in +this file. That makes the file a safety boundary, and a safety boundary that +drifts silently from the prose describing it is worth less than none. + +So: shape (every line resolves to a real repo), and the two exclusions that +carry a reason — the skill's Never-touched entries, and the two repos that +sweep themselves. +""" + +from __future__ import annotations + +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +SET_FILE = BRAIN_HOME / "bin" / "branch_sweep_set.txt" + +# skills/repo_cleanup/SKILL.md → "Never touched". Named here so a future +# addition to the sweep set has to argue with a test, not just a comment. +NEVER_TOUCHED = { + "PyAutoLabs/euclid_strong_lens_modeling_pipeline", + "PyAutoLabs/autolens_assistant", +} + +# Each hosts its own branch_sweep.yml and sweeps itself with its own +# GITHUB_TOKEN; listing them centrally would give one repo two sweepers. +SELF_SWEEPING = {"PyAutoLabs/PyAutoMind", "PyAutoLabs/PyAutoBrain"} + + +def _entries() -> list[str]: + lines = SET_FILE.read_text().splitlines() + return [s for line in lines if (s := line.strip()) and not s.startswith("#")] + + +def test_every_entry_is_a_well_formed_slug(): + for slug in _entries(): + owner, sep, repo = slug.partition("/") + assert sep and owner and repo and "/" not in repo, f"malformed entry: {slug!r}" + + +def test_no_duplicates(): + entries = _entries() + assert len(entries) == len(set(entries)), "a repo is listed twice" + + +def test_never_touched_repos_are_absent(): + """The skill's Never-touched list is a decision, not a default.""" + listed = set(_entries()) + assert not (listed & NEVER_TOUCHED), ( + "these are on skills/repo_cleanup/SKILL.md's Never-touched list and must " + f"not be swept centrally: {sorted(listed & NEVER_TOUCHED)}" + ) + + +def test_self_sweeping_repos_are_absent(): + listed = set(_entries()) + assert not (listed & SELF_SWEEPING), ( + "these host their own branch_sweep.yml; sweeping them centrally too " + f"gives one repo two sweepers on different credentials: {sorted(listed & SELF_SWEEPING)}" + ) + + +def test_every_entry_exists_in_the_body_map(): + """A slug that names no real repo would fail at clone time, mid-sweep.""" + import yaml + + path = BRAIN_HOME.parent / "PyAutoMind" / "repos.yaml" + if not path.is_file(): + return # body map not checked out here + + known = {entry["github"] for entry in yaml.safe_load(path.read_text())["repos"].values()} + unknown = [slug for slug in _entries() if slug not in known] + assert not unknown, ( + "these are not in PyAutoMind/repos.yaml, so they either do not exist or " + f"the body map is stale — resolve before sweeping them: {unknown}" + ) From ad10b9534f97b21ec5867c85e91f676d4640fadf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:10:12 +0000 Subject: [PATCH 2/2] refactor(branch_sweep): derive the sweep targets, do not list them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI rejected the first version, and it was right to. The tenant firewall keeps instance facts — satellite repo names, GitHub owners — out of the framework organs, so they stay adoptable as a config-diff fork. A file listing the organism's repos is exactly that leak. Worse, the first version had smuggled it past: the firewall scans only .py and .sh, so a .txt list was not clean, only invisible. That is a hole to close, not a technique to keep. The hygiene conductor had already solved this, and said so in the allowlist entry it deleted — "derives its repo sets from the body map, so it names no instance fact at all". Same answer here. Policy is expressed as CATEGORIES, which every organism has; which repos fill them is whatever that organism's body map says. Both exclusions now fall out of the categories instead of being named twice: the skill's two Never-touched repos live in categories this does not sweep, so no rule has to know them. The Mind and the Brain drop out by organ ROLE, not name — each hosts its own branch_sweep.yml, and sweeping them centrally too would put two sweepers on one repo with different credentials. Roles are generic; an adopting fork has a Mind and a Brain whatever it calls them. The derived set is 25 repos where the hand-list was 18. The difference is not scope creep but the absence of arbitrariness: the hand-list had quietly omitted repos indistinguishable from the ones it included. A principled boundary includes them; the audit-first gate is what keeps that safe. Tests use a synthetic body map throughout. Necessary — this file is .py under an organ, so the firewall scans it and a fixture naming a real repo would be the same leak — but also better: it pins the contract (categories in, slugs out, unknown category fails closed) rather than today's roster. Verified the way the first version was not: `repos_sync.py --check` in full, locally, all twelve checks green, rather than assuming pytest was the gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KwqicJpMqmcT5RVbyNwdKq --- .github/workflows/branch_sweep_all.yml | 35 +++++-- bin/branch_sweep_set.txt | 51 ---------- bin/branch_sweep_targets.py | 84 ++++++++++++++++ skills/repo_cleanup/reference.md | 23 +++-- tests/test_branch_sweep_set.py | 78 --------------- tests/test_branch_sweep_targets.py | 129 +++++++++++++++++++++++++ 6 files changed, 257 insertions(+), 143 deletions(-) delete mode 100644 bin/branch_sweep_set.txt create mode 100644 bin/branch_sweep_targets.py delete mode 100644 tests/test_branch_sweep_set.py create mode 100644 tests/test_branch_sweep_targets.py diff --git a/.github/workflows/branch_sweep_all.yml b/.github/workflows/branch_sweep_all.yml index eb7ee9a..c7f12b2 100644 --- a/.github/workflows/branch_sweep_all.yml +++ b/.github/workflows/branch_sweep_all.yml @@ -1,7 +1,7 @@ name: Branch Sweep (org-wide) -# The same sweep as branch_sweep.yml, run from the Brain across the repos in -# bin/branch_sweep_set.txt instead of against this repo alone. +# 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 @@ -30,7 +30,7 @@ on: workflow_dispatch: inputs: repos: - description: "Space/comma-separated owner/repo list. Empty = every repo in branch_sweep_set.txt (audit only)." + description: "Space/comma-separated owner/repo list. Empty = every sweepable repo (audit only)." type: string default: "" mode: @@ -58,11 +58,23 @@ jobs: sweep: runs-on: ubuntu-latest steps: - - name: Check out PyAutoBrain (sweep logic + repo set) + - 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: | @@ -80,7 +92,14 @@ jobs: exit 1 fi - allowed=$(grep -vE '^\s*(#|$)' bin/branch_sweep_set.txt | xargs) + # 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 @@ -88,8 +107,10 @@ jobs: for r in $requested; do case " $allowed " in *" $r "*) targets="$targets $r" ;; - *) echo "::error::'$r' is not in bin/branch_sweep_set.txt." - echo "::error::Add it there in a reviewed change if it should be sweepable." + *) 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 diff --git a/bin/branch_sweep_set.txt b/bin/branch_sweep_set.txt deleted file mode 100644 index 616ab46..0000000 --- a/bin/branch_sweep_set.txt +++ /dev/null @@ -1,51 +0,0 @@ -# branch_sweep_set.txt — which repos the org-wide branch sweep may touch. -# -# This is POLICY, so it lives in the Brain. PyAutoMind/repos.yaml is the body -# map — repo *identity* (GitHub home, category, role) — and deliberately holds -# no per-organ policy. "Which repos may have branches deleted" is exactly the -# kind of decision repos.yaml's own header says to keep with the organ that -# owns it. -# -# The set mirrors skills/repo_cleanup/SKILL.md "Scope". Keep the two in step: -# the skill is the prose, this file is what actually executes. -# -# Format: one `owner/repo` per line. `#` comments and blank lines ignored. -# -# DELIBERATELY ABSENT, and why — do not add these without a human decision: -# -# euclid_strong_lens_modeling_pipeline on the skill's Never-touched list -# autolens_assistant on the skill's Never-touched list -# z_projects* / z_staging / bad / priors not in the body map; never swept -# Jammy2211/* different owner; PAT scope unverified -# *_assistant (autocti/autofit/autogalaxy) science workspaces, not dev repos -# PyAutoScientist, pyautolabs.github.io publication surfaces, swept by hand -# -# PyAutoMind and PyAutoBrain are absent on purpose too: each hosts its own -# branch_sweep.yml and sweeps itself with its own GITHUB_TOKEN. Listing them -# here would give the same repo two sweepers with different credentials. - -# --- libraries ----------------------------------------------------------- -PyAutoLabs/PyAutoNerves -PyAutoLabs/PyAutoFit -PyAutoLabs/PyAutoArray -PyAutoLabs/PyAutoGalaxy -PyAutoLabs/PyAutoLens -PyAutoLabs/PyAutoHands - -# --- workspaces ---------------------------------------------------------- -PyAutoLabs/autofit_workspace -PyAutoLabs/autogalaxy_workspace -PyAutoLabs/autolens_workspace -PyAutoLabs/autocti_workspace -PyAutoLabs/autoreduce_workspace - -# --- workspace variants -------------------------------------------------- -PyAutoLabs/autofit_workspace_test -PyAutoLabs/autogalaxy_workspace_test -PyAutoLabs/autolens_workspace_test -PyAutoLabs/autocti_workspace_test -PyAutoLabs/autofit_workspace_developer -PyAutoLabs/autolens_workspace_developer - -# --- tutorials ----------------------------------------------------------- -PyAutoLabs/HowToLens 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 7c3df65..c83519b 100644 --- a/skills/repo_cleanup/reference.md +++ b/skills/repo_cleanup/reference.md @@ -137,17 +137,26 @@ workflow `GITHUB_TOKEN` that does have `contents: write`: `branch_sweep.yml` currently exists in **PyAutoMind** and **PyAutoBrain**, which sweep themselves with their own `GITHUB_TOKEN`. -**Every other repo in Scope** is swept centrally instead, by PyAutoBrain's -`branch_sweep_all.yml` over `bin/branch_sweep_set.txt` using `PAT_PYAUTOLABS`. -Same dispatch/poll/report loop, with `repos` naming the targets. Two things -about it are not negotiable from the chat side: +**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 outside `branch_sweep_set.txt` fails the whole run**, including a - run that also names valid repos. Widening the set is a reviewed change to - that file, never a dispatch input. +- **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 — diff --git a/tests/test_branch_sweep_set.py b/tests/test_branch_sweep_set.py deleted file mode 100644 index 1f93d87..0000000 --- a/tests/test_branch_sweep_set.py +++ /dev/null @@ -1,78 +0,0 @@ -"""tests/test_branch_sweep_set.py — the org-wide sweep's target list. - -`bin/branch_sweep_set.txt` is the only thing standing between the org-wide -sweep and a repo nobody meant to touch: the workflow refuses any target not in -this file. That makes the file a safety boundary, and a safety boundary that -drifts silently from the prose describing it is worth less than none. - -So: shape (every line resolves to a real repo), and the two exclusions that -carry a reason — the skill's Never-touched entries, and the two repos that -sweep themselves. -""" - -from __future__ import annotations - -from pathlib import Path - -BRAIN_HOME = Path(__file__).resolve().parents[1] -SET_FILE = BRAIN_HOME / "bin" / "branch_sweep_set.txt" - -# skills/repo_cleanup/SKILL.md → "Never touched". Named here so a future -# addition to the sweep set has to argue with a test, not just a comment. -NEVER_TOUCHED = { - "PyAutoLabs/euclid_strong_lens_modeling_pipeline", - "PyAutoLabs/autolens_assistant", -} - -# Each hosts its own branch_sweep.yml and sweeps itself with its own -# GITHUB_TOKEN; listing them centrally would give one repo two sweepers. -SELF_SWEEPING = {"PyAutoLabs/PyAutoMind", "PyAutoLabs/PyAutoBrain"} - - -def _entries() -> list[str]: - lines = SET_FILE.read_text().splitlines() - return [s for line in lines if (s := line.strip()) and not s.startswith("#")] - - -def test_every_entry_is_a_well_formed_slug(): - for slug in _entries(): - owner, sep, repo = slug.partition("/") - assert sep and owner and repo and "/" not in repo, f"malformed entry: {slug!r}" - - -def test_no_duplicates(): - entries = _entries() - assert len(entries) == len(set(entries)), "a repo is listed twice" - - -def test_never_touched_repos_are_absent(): - """The skill's Never-touched list is a decision, not a default.""" - listed = set(_entries()) - assert not (listed & NEVER_TOUCHED), ( - "these are on skills/repo_cleanup/SKILL.md's Never-touched list and must " - f"not be swept centrally: {sorted(listed & NEVER_TOUCHED)}" - ) - - -def test_self_sweeping_repos_are_absent(): - listed = set(_entries()) - assert not (listed & SELF_SWEEPING), ( - "these host their own branch_sweep.yml; sweeping them centrally too " - f"gives one repo two sweepers on different credentials: {sorted(listed & SELF_SWEEPING)}" - ) - - -def test_every_entry_exists_in_the_body_map(): - """A slug that names no real repo would fail at clone time, mid-sweep.""" - import yaml - - path = BRAIN_HOME.parent / "PyAutoMind" / "repos.yaml" - if not path.is_file(): - return # body map not checked out here - - known = {entry["github"] for entry in yaml.safe_load(path.read_text())["repos"].values()} - unknown = [slug for slug in _entries() if slug not in known] - assert not unknown, ( - "these are not in PyAutoMind/repos.yaml, so they either do not exist or " - f"the body map is stale — resolve before sweeping them: {unknown}" - ) 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