|
| 1 | +name: Mind Ledger Merge |
| 2 | + |
| 3 | +# Lands PyAutoMind's own ledger work without anyone having to ask for it. |
| 4 | +# |
| 5 | +# THE PROBLEM. A branch-scoped session — the phone, claude.ai/code, any |
| 6 | +# `claude/**` flow — pushes its Mind changes to a feature branch and they stop |
| 7 | +# there. `prompt_sync.sh` pushes HEAD on purpose (a cloud session must not push |
| 8 | +# straight to main), but nothing downstream moves the branch on: no workflow so |
| 9 | +# much as *looks* at a `claude/**` push, because lifecycle_drift, |
| 10 | +# dashboard_refresh, firewall_gate and spawn_drift all trigger on `push: main` |
| 11 | +# or `pull_request` only. So a filed prompt, a shipped task moved to |
| 12 | +# `complete/`, a regenerated dashboard — all of it waits for a human to write an |
| 13 | +# explicit "merge that branch" prompt, and the dashboard renders a stale |
| 14 | +# backlog until they do. |
| 15 | +# |
| 16 | +# WHY A WORKFLOW AND NOT THE SESSION. branch_sweep.yml's reasoning, again: a |
| 17 | +# workflow's GITHUB_TOKEN is a *different* credential from the session's, and |
| 18 | +# this repo already trusts it with `contents: write` (dashboard_refresh.yml |
| 19 | +# commits to main with it). Running the merge here means the ledger lands from |
| 20 | +# any surface that can push — including a chat on a phone — and it lands |
| 21 | +# whether or not the session that pushed it ever comes back. |
| 22 | +# |
| 23 | +# WHAT IT WILL AND WILL NOT MERGE. Only a branch whose whole diff is *ledger*: |
| 24 | +# `draft/`, `active/`, `complete/`, the root registry files and the generated |
| 25 | +# dashboard pages. The line is drawn by scripts/ledger_merge.py — a script, so |
| 26 | +# it is testable and a session can predict the verdict — and it is DEFAULT |
| 27 | +# DENY: anything under `scripts/`, `tests/`, `.github/`, `skills/`, `policy/`, |
| 28 | +# `docs/`, `repos.yaml`, the prose pages, or any path nobody has classified, |
| 29 | +# stops the merge and leaves the branch for a human. This workflow's own file |
| 30 | +# is on the code side of that line, which is the intended self-consistency: it |
| 31 | +# cannot merge a change to itself. |
| 32 | +# |
| 33 | +# NOT A REVIEW BYPASS. Review adds nothing to a moved prompt file; it adds |
| 34 | +# everything to a changed script. The gate encodes precisely that distinction |
| 35 | +# and nothing else. |
| 36 | + |
| 37 | +on: |
| 38 | + push: |
| 39 | + branches: ["claude/**"] |
| 40 | + workflow_dispatch: |
| 41 | + inputs: |
| 42 | + branch: |
| 43 | + description: "Branch to judge (default: the caller's ref)" |
| 44 | + type: string |
| 45 | + default: "" |
| 46 | + mode: |
| 47 | + description: "audit = classify and report only · merge = land it" |
| 48 | + type: choice |
| 49 | + options: [audit, merge] |
| 50 | + default: audit |
| 51 | + delete_branch: |
| 52 | + description: "Delete the branch after a direct (non-PR) merge" |
| 53 | + type: boolean |
| 54 | + default: true |
| 55 | + |
| 56 | +# contents: write → the merge push to main and the branch delete. |
| 57 | +# pull-requests: write → merge through an open PR when the branch has one, so |
| 58 | +# the PR records as MERGED rather than being orphaned by a direct push. |
| 59 | +# actions: write → re-dispatch the self-healing workflows on main. A push made |
| 60 | +# with GITHUB_TOKEN triggers nothing, so without this the generated pages |
| 61 | +# stay stale until the nightly cron (dashboard_refresh.yml learned this the |
| 62 | +# hard way on 2026-08-21). |
| 63 | +permissions: |
| 64 | + contents: write |
| 65 | + pull-requests: write |
| 66 | + actions: write |
| 67 | + |
| 68 | +# Every run of this workflow ends in a push to main, so runs must not overlap: |
| 69 | +# two branches merging concurrently would race, and the loser would spend its |
| 70 | +# three attempts re-merging. Queue rather than cancel — a cancelled run can |
| 71 | +# leave a branch merged and undeleted. |
| 72 | +concurrency: |
| 73 | + group: mind-ledger-merge |
| 74 | + cancel-in-progress: false |
| 75 | + |
| 76 | +jobs: |
| 77 | + merge: |
| 78 | + # Never in a fork or a spawned template: this job writes to main, and an |
| 79 | + # adopter re-adds that deliberately (spawn.py DROPs this file for the same |
| 80 | + # reason it DROPs dashboard_refresh.yml). |
| 81 | + if: github.repository == 'PyAutoLabs/PyAutoMind' && github.event.deleted != true |
| 82 | + runs-on: ubuntu-latest |
| 83 | + steps: |
| 84 | + - name: Resolve the branch |
| 85 | + id: target |
| 86 | + run: | |
| 87 | + BRANCH="${{ inputs.branch }}" |
| 88 | + [ -n "$BRANCH" ] || BRANCH="${{ github.ref_name }}" |
| 89 | + case "$BRANCH" in |
| 90 | + main|"") echo "::error::refusing to operate on '$BRANCH'"; exit 1 ;; |
| 91 | + archive/condemned/*) echo "::error::'$BRANCH' is a Gut transit ref"; exit 1 ;; |
| 92 | + esac |
| 93 | + MODE="${{ inputs.mode }}" |
| 94 | + # A push is the automatic door and merges; the manual door defaults to |
| 95 | + # audit, so a human dispatching it to look never merges by accident. |
| 96 | + [ "${{ github.event_name }}" = "push" ] && MODE="merge" |
| 97 | + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" |
| 98 | + echo "mode=$MODE" >> "$GITHUB_OUTPUT" |
| 99 | + echo "Branch: \`$BRANCH\` · mode: \`$MODE\`" >> "$GITHUB_STEP_SUMMARY" |
| 100 | +
|
| 101 | + - uses: actions/checkout@v4 |
| 102 | + with: |
| 103 | + ref: ${{ steps.target.outputs.branch }} |
| 104 | + # Full history: the classifier diffs against the merge base with main, |
| 105 | + # and `merge-base --is-ancestor` cannot answer on a shallow clone — |
| 106 | + # it reports "not an ancestor" for ancestry that is merely absent. |
| 107 | + fetch-depth: 0 |
| 108 | + path: PyAutoMind |
| 109 | + |
| 110 | + # The dashboard renderer lives with the intake conductor, not here — the |
| 111 | + # Mind holds the state, the Brain reasons over it (ORGANISM.md). |
| 112 | + - uses: actions/checkout@v4 |
| 113 | + with: |
| 114 | + repository: PyAutoLabs/PyAutoBrain |
| 115 | + path: PyAutoBrain |
| 116 | + |
| 117 | + - name: Is there anything to merge? |
| 118 | + id: ahead |
| 119 | + working-directory: PyAutoMind |
| 120 | + run: | |
| 121 | + git fetch --quiet origin main |
| 122 | + HEAD_SHA=$(git rev-parse HEAD) |
| 123 | + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" |
| 124 | + if git merge-base --is-ancestor "$HEAD_SHA" origin/main; then |
| 125 | + echo "already in main — nothing to merge" >> "$GITHUB_STEP_SUMMARY" |
| 126 | + echo "merged=already" >> "$GITHUB_OUTPUT" |
| 127 | + else |
| 128 | + echo "merged=no" >> "$GITHUB_OUTPUT" |
| 129 | + fi |
| 130 | +
|
| 131 | + - name: Classify the diff — ledger, or code? |
| 132 | + id: classify |
| 133 | + if: steps.ahead.outputs.merged == 'no' |
| 134 | + working-directory: PyAutoMind |
| 135 | + run: | |
| 136 | + set +e |
| 137 | + OUT=$(python3 scripts/ledger_merge.py classify --base origin/main) |
| 138 | + RC=$? |
| 139 | + set -e |
| 140 | + echo "$OUT" |
| 141 | + { |
| 142 | + echo "" |
| 143 | + echo "<details><summary>Classification</summary>" |
| 144 | + echo "" |
| 145 | + echo '```' |
| 146 | + echo "$OUT" |
| 147 | + echo '```' |
| 148 | + echo "" |
| 149 | + echo "</details>" |
| 150 | + } >> "$GITHUB_STEP_SUMMARY" |
| 151 | + # Exit 2 is the gate failing to run, which is not "a human's turn" — |
| 152 | + # it is a broken gate, and it must fail the job rather than read as a |
| 153 | + # quiet decline to merge. |
| 154 | + if [ "$RC" -eq 2 ]; then |
| 155 | + echo "::error::ledger_merge.py could not classify the diff" |
| 156 | + exit 1 |
| 157 | + fi |
| 158 | + if [ "$RC" -ne 0 ]; then |
| 159 | + echo "verdict=code" >> "$GITHUB_OUTPUT" |
| 160 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 161 | + echo "**Left for a human** — this branch changes code, not just the ledger." >> "$GITHUB_STEP_SUMMARY" |
| 162 | + else |
| 163 | + echo "verdict=ledger" >> "$GITHUB_OUTPUT" |
| 164 | + fi |
| 165 | +
|
| 166 | + # Structural drift BLOCKS. `lifecycle.py check` catches a real |
| 167 | + # inconsistency — a prompt in `active/` with no `active.md` entry, a |
| 168 | + # record whose front matter contradicts the ledger — and nothing heals |
| 169 | + # that automatically. Merging it would put the contradiction on main. |
| 170 | + - name: Structural drift (blocking) |
| 171 | + if: steps.classify.outputs.verdict == 'ledger' |
| 172 | + working-directory: PyAutoMind |
| 173 | + run: python3 scripts/lifecycle.py check |
| 174 | + |
| 175 | + # Generated-page staleness DOES NOT block. `complete/index.md`, the |
| 176 | + # registry contents blocks and the dashboard pages are all renders with |
| 177 | + # self-healing workflows on main, so a stale render is a reason to heal |
| 178 | + # after the merge, never a reason to strand the ledger. Reported, not |
| 179 | + # enforced. |
| 180 | + - name: Generated-page freshness (reported, healed after the merge) |
| 181 | + if: steps.classify.outputs.verdict == 'ledger' |
| 182 | + continue-on-error: true |
| 183 | + working-directory: PyAutoMind |
| 184 | + run: | |
| 185 | + STALE="" |
| 186 | + python3 scripts/lifecycle.py index --check || STALE="$STALE complete/index.md" |
| 187 | + python3 scripts/registry_toc.py --check || STALE="$STALE registry-contents" |
| 188 | + python3 ../PyAutoBrain/agents/conductors/intake/_intake.py --mind . dashboard --check \ |
| 189 | + || STALE="$STALE dashboard" |
| 190 | + if [ -n "$STALE" ]; then |
| 191 | + echo "stale renders (will be healed on main):$STALE" >> "$GITHUB_STEP_SUMMARY" |
| 192 | + fi |
| 193 | +
|
| 194 | + - name: Merge into main |
| 195 | + id: merge |
| 196 | + if: steps.classify.outputs.verdict == 'ledger' && steps.target.outputs.mode == 'merge' |
| 197 | + working-directory: PyAutoMind |
| 198 | + env: |
| 199 | + GH_TOKEN: ${{ github.token }} |
| 200 | + BRANCH: ${{ steps.target.outputs.branch }} |
| 201 | + HEAD_SHA: ${{ steps.ahead.outputs.head_sha }} |
| 202 | + run: | |
| 203 | + git config user.name "github-actions[bot]" |
| 204 | + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
| 205 | +
|
| 206 | + # An open PR is merged THROUGH the PR, so it records as MERGED and |
| 207 | + # its review thread closes properly. A direct push would land the |
| 208 | + # same commits and leave the PR reading "closed", which is a lie |
| 209 | + # about what happened to the work. |
| 210 | + PR=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') |
| 211 | + if [ -n "$PR" ]; then |
| 212 | + echo "open PR #$PR on this branch — merging through it" |
| 213 | + gh pr merge "$PR" --merge |
| 214 | + echo "via=pr#$PR" >> "$GITHUB_OUTPUT" |
| 215 | + echo "Merged **#$PR** (\`$BRANCH\` → main)." >> "$GITHUB_STEP_SUMMARY" |
| 216 | + exit 0 |
| 217 | + fi |
| 218 | +
|
| 219 | + # No PR: merge the ref directly. Each attempt rebuilds on the current |
| 220 | + # tip of main, so a concurrent push never needs a rebase — the same |
| 221 | + # convergence the self-heal loops use. |
| 222 | + for attempt in 1 2 3; do |
| 223 | + git fetch --quiet origin main |
| 224 | + if git merge-base --is-ancestor "$HEAD_SHA" origin/main; then |
| 225 | + echo "landed by a concurrent run" |
| 226 | + echo "via=concurrent" >> "$GITHUB_OUTPUT" |
| 227 | + exit 0 |
| 228 | + fi |
| 229 | + git checkout -B _ledger_merge origin/main |
| 230 | + if ! git merge --no-ff "$HEAD_SHA" \ |
| 231 | + -m "mind: auto-merge ledger branch $BRANCH" \ |
| 232 | + -m "Ledger-only diff (scripts/ledger_merge.py), structural checks clean." ; then |
| 233 | + git merge --abort || true |
| 234 | + echo "::error::'$BRANCH' conflicts with main — resolve it by hand" |
| 235 | + echo "**Conflict** — \`$BRANCH\` does not merge cleanly; left for a human." >> "$GITHUB_STEP_SUMMARY" |
| 236 | + exit 1 |
| 237 | + fi |
| 238 | + if git push origin HEAD:main; then |
| 239 | + echo "merged on attempt $attempt" |
| 240 | + echo "via=direct" >> "$GITHUB_OUTPUT" |
| 241 | + echo "Merged \`$BRANCH\` → main (no PR; direct merge commit)." >> "$GITHUB_STEP_SUMMARY" |
| 242 | + exit 0 |
| 243 | + fi |
| 244 | + echo "push rejected (attempt $attempt) — retrying on the new tip of main" |
| 245 | + done |
| 246 | + echo "::error::could not push the merge after 3 attempts" |
| 247 | + exit 1 |
| 248 | +
|
| 249 | + # A PR merge deletes its own head where the repo has "Automatically |
| 250 | + # delete head branches" on; a DIRECT merge has no such hook, which is how |
| 251 | + # 188 provably-spent branches piled up before branch_sweep.yml. Delete it |
| 252 | + # here, on the same proof branch_sweep uses — main must actually contain |
| 253 | + # the head sha — so a failed or partial merge never loses a branch. |
| 254 | + - name: Delete the merged branch |
| 255 | + if: steps.merge.outputs.via != '' && inputs.delete_branch != false |
| 256 | + working-directory: PyAutoMind |
| 257 | + env: |
| 258 | + BRANCH: ${{ steps.target.outputs.branch }} |
| 259 | + HEAD_SHA: ${{ steps.ahead.outputs.head_sha }} |
| 260 | + run: | |
| 261 | + git fetch --quiet origin main |
| 262 | + if ! git merge-base --is-ancestor "$HEAD_SHA" origin/main; then |
| 263 | + echo "::warning::main does not contain $HEAD_SHA — keeping '$BRANCH'" |
| 264 | + exit 0 |
| 265 | + fi |
| 266 | + if git push origin --delete "$BRANCH"; then |
| 267 | + echo "Deleted \`$BRANCH\`." >> "$GITHUB_STEP_SUMMARY" |
| 268 | + else |
| 269 | + # Already gone (a PR merge with auto-delete on) is the common case |
| 270 | + # and is not a failure — branch_sweep.yml collects anything else. |
| 271 | + echo "::warning::could not delete '$BRANCH' — it may already be gone" |
| 272 | + fi |
| 273 | +
|
| 274 | + # The merge push above was made with GITHUB_TOKEN, which triggers no |
| 275 | + # workflows, so main's self-healing legs must be asked explicitly or the |
| 276 | + # generated pages sit stale until the nightly cron. |
| 277 | + - name: Heal the generated pages on main |
| 278 | + if: steps.merge.outputs.via != '' |
| 279 | + env: |
| 280 | + GH_TOKEN: ${{ github.token }} |
| 281 | + run: | |
| 282 | + for wf in dashboard_refresh.yml lifecycle_drift.yml; do |
| 283 | + gh workflow run "$wf" --repo "${{ github.repository }}" --ref main \ |
| 284 | + || echo "::warning::could not dispatch $wf — its next scheduled run will heal main" |
| 285 | + done |
0 commit comments