diff --git a/.github/workflows/branch_archive.yml b/.github/workflows/branch_archive.yml new file mode 100644 index 0000000..93a4bcb --- /dev/null +++ b/.github/workflows/branch_archive.yml @@ -0,0 +1,186 @@ +name: Branch Archive (to the Gut) + +# Move a branch that must NOT simply be deleted into PyAutoGut, then remove it +# from the repo it came from. +# +# WHY THIS IS NOT THE SWEEP. branch_sweep deletes branches whose content is +# provably already in main — spent work, recoverable from main forever. This is +# the opposite case: a branch worth keeping precisely BECAUSE main does not +# contain it. The motivating example is PyAutoHands' `master`, 442 commits from +# 2021-2022 orphaned by a 2023 "history reset", sharing no ancestor with main +# and pinned by no tag. Deleting it would have made those commits unreachable. +# +# So the two must never share a code path: the sweep's question is "is this +# already in main?", and this workflow's is "is this safely in the Gut yet?". +# +# THE ORDERING IS THE WHOLE POINT. Archive, VERIFY the ref exists on the Gut +# remote, and only then delete. Not archive-and-assume: a push that reports +# success but lands nothing, or lands under a different name, would otherwise +# be followed by an irreversible delete. The verification is a separate +# `ls-remote` against the Gut, not a reading of the push's own exit code. +# +# The archive itself is delegated to `pyauto-gut archive`, not reimplemented +# here. PyAutoGut is the organ that holds and voids; the Brain only drives it +# (the Heart ↔ vitals template). Reimplementing the push would put the Gut's +# namespace convention in two places. +# +# Filing the condemned.md entry is deliberately NOT done here — that index is +# Mind state, written by the hygiene conductor's condemn pass with a human +# deciding the transit clock. Some material (original project history) should +# be held indefinitely rather than swept, and that is a judgement, not a step. + +on: + workflow_dispatch: + inputs: + repo: + description: "owner/repo the branch lives in (must be a sweepable repo)" + type: string + required: true + branch: + description: "Branch to archive, e.g. master" + type: string + required: true + name: + description: "Name under archive/condemned/ in the Gut, e.g. pyautohands-pre-2023-history" + type: string + required: true + delete_after: + description: "Delete the source branch once the archive is verified" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: branch-archive + cancel-in-progress: false + +jobs: + archive: + runs-on: ubuntu-latest + steps: + - name: Check out PyAutoBrain (target policy) + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Check out PyAutoMind (the body map) + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/PyAutoMind + path: .mind + fetch-depth: 1 + + - name: Check out PyAutoGut (the organ that holds) + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/PyAutoGut + path: .gut + fetch-depth: 1 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install --quiet pyyaml + + - name: Archive, verify, then delete + env: + PAT: ${{ secrets.PAT_PYAUTOLABS }} + run: | + set -euo pipefail + repo='${{ inputs.repo }}' + branch='${{ inputs.branch }}' + name='${{ inputs.name }}' + owner='${{ github.repository_owner }}' + + [ -n "${PAT:-}" ] || { echo "::error::PAT_PYAUTOLABS is not set."; exit 1; } + + # Same boundary as the sweep: a repo the body map does not class as a + # development repo is not one this may touch. + allowed=$(python3 bin/branch_sweep_targets.py .mind/repos.yaml | xargs) + case " $allowed " in + *" $repo "*) ;; + *) echo "::error::'$repo' is not a sweepable repo — see bin/branch_sweep_targets.py."; exit 1 ;; + esac + + # The archive name lands in a shared namespace; keep it a plain slug + # so it cannot climb out of archive/condemned/ or collide by accident. + case "$name" in + *[!a-zA-Z0-9._-]*|""|.*) echo "::error::archive name must be a plain slug (a-z 0-9 . _ -)."; exit 1 ;; + esac + + src="https://x-access-token:${PAT}@github.com/${repo}.git" + gut="https://x-access-token:${PAT}@github.com/${owner}/PyAutoGut.git" + ref="refs/heads/archive/condemned/${name}" + + # Refuse to delete a repo's default branch, whatever it is called. + default=$(git ls-remote --symref "$src" HEAD | sed -n 's|^ref: refs/heads/\([^\t]*\)\tHEAD|\1|p') + if [ "$branch" = "$default" ]; then + echo "::error::'$branch' is the default branch of $repo. Refusing." + exit 1 + fi + + git config --global user.email "actions@github.com" + git config --global user.name "PyAuto Branch Archive" + + echo "::group::Fetch $repo@$branch" + cd .gut + git remote set-url origin "$gut" + git fetch --quiet "$src" "refs/heads/${branch}:refs/heads/__to_archive__" + sha=$(git rev-parse __to_archive__) + count=$(git rev-list --count __to_archive__) + echo " $repo@$branch = $sha ($count commits)" + echo "::endgroup::" + + echo "::group::Archive into the Gut" + # The Gut owns its own namespace and its own refusal-to-overwrite. + PYAUTO_GUT_REMOTE=origin bin/pyauto-gut archive __to_archive__ "$name" + echo "::endgroup::" + + echo "::group::Verify the archive actually landed" + # Deliberately a fresh question to the remote, not the push's exit + # code: the delete below is irreversible and must not rest on an + # assumption about what just happened. + landed=$(git ls-remote origin "$ref" | awk '{print $1}') + if [ -z "$landed" ]; then + echo "::error::$ref is not on the Gut after the push. NOT deleting anything." + exit 1 + fi + if [ "$landed" != "$sha" ]; then + echo "::error::$ref is $landed on the Gut but the branch was $sha. NOT deleting anything." + exit 1 + fi + echo " verified $ref = $landed" + echo "::endgroup::" + + { + echo "## Archived to the Gut" + echo + echo "| | |" + echo "|---|---|" + echo "| source | \`$repo@$branch\` |" + echo "| commits | $count |" + echo "| sha | \`$sha\` |" + echo "| archive-ref | \`$ref\` |" + } >> "$GITHUB_STEP_SUMMARY" + + if [ '${{ inputs.delete_after }}' != 'true' ]; then + echo "delete_after not set — the source branch is untouched." + echo >> "$GITHUB_STEP_SUMMARY" + echo "_Source branch left in place (\`delete_after\` was not set)._" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "::group::Delete $repo@$branch" + git push "$src" --delete "$branch" + echo " deleted $repo@$branch" + echo "::endgroup::" + + echo >> "$GITHUB_STEP_SUMMARY" + echo "Source branch deleted. Recover with:" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "pyauto-gut recover $name" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo >> "$GITHUB_STEP_SUMMARY" + echo "Now file the \`condemned.md\` entry in PyAutoMind — this workflow does not." >> "$GITHUB_STEP_SUMMARY" diff --git a/skills/repo_cleanup/reference.md b/skills/repo_cleanup/reference.md index c83519b..c444a5c 100644 --- a/skills/repo_cleanup/reference.md +++ b/skills/repo_cleanup/reference.md @@ -158,6 +158,31 @@ Never-touched repos fall out with their categories — `autolens_assistant` is a 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). +## Branches that must not simply be deleted + +A branch the sweep classes as *unmerged* is not automatically live work. It may +be history that `main` no longer contains and nothing else pins — the 2026-08-25 +org-wide audit found one: a `master` carrying 442 commits from 2021-2022, +orphaned by a later "history reset", sharing **no common ancestor** with `main` +and referenced by no tag. Deleting that is not cleanup, it is loss. + +Tell the two apart before proposing anything: + +```bash +git merge-base origin/main origin/ # exit 1 + no output = disjoint histories +git tag --contains $(git rev-parse origin/) # empty = nothing else pins it +``` + +Disjoint or unpinned → route it to the Gut instead of the delete bucket: +dispatch `branch_archive.yml` (PyAutoBrain) with `repo`, `branch`, `name`, and +`delete_after`. It archives via `pyauto-gut archive`, **verifies the ref landed +on the Gut with a fresh `ls-remote`**, and only then deletes the source branch — +never on the strength of the push's own exit code. + +It does not file the `condemned.md` entry. That is Mind state and a judgement: +material like original project history should be held **undated** (no +`sweep-after`), so no later sweep ever voids it. + 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