Repository navigation
cortex: link every active project to its GitHub issue #66
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Cortex Ledger Merge | |
| # Lands PyAutoCortex's own ledger work without anyone having to ask for it. | |
| # | |
| # THE PROBLEM. A branch-scoped session — the phone, claude.ai/code, any | |
| # `claude/**` flow — pushes a run recorded, an entry logged, a check-in to a feature | |
| # branch and it stops there: nothing downstream looks at a `claude/**` push. | |
| # The Mind learned this first (mind_ledger_merge.yml); the Cortex is born with | |
| # the answer. | |
| # | |
| # WHY A WORKFLOW AND NOT THE SESSION. A workflow's GITHUB_TOKEN is a different | |
| # credential from the session's, and this repo trusts it with `contents: | |
| # write`. Running the merge here means the ledger lands from any surface that | |
| # can push — including a chat on a phone — whether or not the session that | |
| # pushed it ever comes back. | |
| # | |
| # WHAT IT WILL AND WILL NOT MERGE. Only a branch whose whole diff is *ledger*: | |
| # `projects/`, `checkin.yaml`, the two generated pages. The line is drawn by | |
| # scripts/ledger_merge.py — a script, so it is testable and a session can | |
| # predict the verdict — and it is DEFAULT DENY: anything under `scripts/`, | |
| # `tests/`, `.github/`, `policy/`, `docs/`, `projects.yaml`, the prose pages, | |
| # or any path nobody has classified, stops the merge and leaves the branch for | |
| # a human. So does ANY change under `archive/` — the frozen 2026-08/09 task, | |
| # ruling and batch ledgers, which nothing writes any more. This workflow's own file is on the code side, which is the | |
| # intended self-consistency: it cannot merge a change to itself. | |
| # | |
| # THE TRIAL-MERGE CHECK. `cortex.py check` is run on the branch tip (so audit | |
| # mode reports something) and AGAIN on the merged tree, after `git merge | |
| # --no-ff` and before `git push`. That second run is what catches the | |
| # same-ledger race: two branches that each edit one project's ledger pass | |
| # alone and can fail merged. A failing check resets the trial merge and leaves the | |
| # branch for a human. | |
| on: | |
| push: | |
| branches: ["claude/**"] | |
| workflow_dispatch: | |
| inputs: | |
| branch: | |
| description: "Branch to judge (default: the caller's ref)" | |
| type: string | |
| default: "" | |
| mode: | |
| description: "audit = classify and report only · merge = land it" | |
| type: choice | |
| options: [audit, merge] | |
| default: audit | |
| delete_branch: | |
| description: "Delete the branch after a direct (non-PR) merge" | |
| type: boolean | |
| default: true | |
| # contents: write → the merge push to main and the branch delete. | |
| # pull-requests: write → merge through an open PR when the branch has one, so | |
| # the PR records as MERGED rather than being orphaned by a direct push. | |
| # actions: write → re-dispatch cortex_check.yml on main. A push made with | |
| # GITHUB_TOKEN triggers nothing, so without this main's check never runs on | |
| # the merge commit. | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: write | |
| # Every run of this workflow ends in a push to main, so runs must not overlap: | |
| # two branches merging concurrently would race, and the loser would spend its | |
| # three attempts re-merging. Queue rather than cancel — a cancelled run can | |
| # leave a branch merged and undeleted. | |
| # | |
| # The group is shared by EVERY workflow here that can push to main — | |
| # dashboard_refresh.yml's self-heal carries the same name — because the race is | |
| # not between two merges, it is between two writers of the tip. One queue, and | |
| # each writer sees a settled main. | |
| concurrency: | |
| group: cortex-main-writers | |
| cancel-in-progress: false | |
| jobs: | |
| merge: | |
| # Never in a fork: this job writes to main. | |
| if: github.repository == 'PyAutoLabs/PyAutoCortex' && github.event.deleted != true | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Resolve the branch | |
| id: target | |
| run: | | |
| BRANCH="${{ inputs.branch }}" | |
| [ -n "$BRANCH" ] || BRANCH="${{ github.ref_name }}" | |
| case "$BRANCH" in | |
| main|"") echo "::error::refusing to operate on '$BRANCH'"; exit 1 ;; | |
| archive/condemned/*) echo "::error::'$BRANCH' is a Gut transit ref"; exit 1 ;; | |
| esac | |
| MODE="${{ inputs.mode }}" | |
| # A push is the automatic door and merges; the manual door defaults to | |
| # audit, so a human dispatching it to look never merges by accident. | |
| [ "${{ github.event_name }}" = "push" ] && MODE="merge" | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "mode=$MODE" >> "$GITHUB_OUTPUT" | |
| echo "Branch: \`$BRANCH\` · mode: \`$MODE\`" >> "$GITHUB_STEP_SUMMARY" | |
| - uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ steps.target.outputs.branch }} | |
| # Full history: the classifier diffs against the merge base with main, | |
| # and `merge-base --is-ancestor` cannot answer on a shallow clone — | |
| # it reports "not an ancestor" for ancestry that is merely absent. | |
| fetch-depth: 0 | |
| - name: Is there anything to merge? | |
| id: ahead | |
| run: | | |
| git fetch --quiet origin main | |
| HEAD_SHA=$(git rev-parse HEAD) | |
| echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" | |
| if git merge-base --is-ancestor "$HEAD_SHA" origin/main; then | |
| echo "already in main — nothing to merge" >> "$GITHUB_STEP_SUMMARY" | |
| echo "merged=already" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "merged=no" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Classify the diff — ledger, or code? | |
| id: classify | |
| if: steps.ahead.outputs.merged == 'no' | |
| run: | | |
| set +e | |
| OUT=$(python3 scripts/ledger_merge.py classify --base origin/main) | |
| RC=$? | |
| set -e | |
| echo "$OUT" | |
| { | |
| echo "" | |
| echo "<details><summary>Classification</summary>" | |
| echo "" | |
| echo '```' | |
| echo "$OUT" | |
| echo '```' | |
| echo "" | |
| echo "</details>" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Exit 2 is the gate failing to run, which is not "a human's turn" — | |
| # it is a broken gate, and it must fail the job rather than read as a | |
| # quiet decline to merge. | |
| if [ "$RC" -eq 2 ]; then | |
| echo "::error::ledger_merge.py could not classify the diff" | |
| exit 1 | |
| fi | |
| if [ "$RC" -ne 0 ]; then | |
| echo "verdict=code" >> "$GITHUB_OUTPUT" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**Left for a human** — this branch changes code, or touches the archive, not just the ledger." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "verdict=ledger" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Structural drift on the branch tip BLOCKS. A run line that does not | |
| # parse, a log that is not newest-first, an active project with no | |
| # ledger — nothing heals that automatically, and merging it would put the | |
| # contradiction on main. Also the only check audit mode gets to report. | |
| # `cortex.py` reads `projects.yaml` with PyYAML — its one dependency. | |
| - name: dependencies | |
| if: steps.classify.outputs.verdict == 'ledger' | |
| run: python3 -m pip install --quiet pyyaml | |
| - name: Structural drift on the branch (blocking) | |
| if: steps.classify.outputs.verdict == 'ledger' | |
| run: python3 scripts/cortex.py check | |
| - name: Merge into main | |
| id: merge | |
| if: steps.classify.outputs.verdict == 'ledger' && steps.target.outputs.mode == 'merge' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BRANCH: ${{ steps.target.outputs.branch }} | |
| HEAD_SHA: ${{ steps.ahead.outputs.head_sha }} | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| # An open PR is merged THROUGH the PR, so it records as MERGED and | |
| # its review thread closes properly. A direct push would land the | |
| # same commits and leave the PR reading "closed", which is a lie | |
| # about what happened to the work. The trial-merge check still runs | |
| # first, on a local merge of the same head, so a PR cannot bypass it. | |
| PR=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') | |
| # No PR, or a PR: either way, build the merge locally first and run | |
| # the check on the MERGED tree. Each attempt rebuilds on the current | |
| # tip of main, so a concurrent push never needs a rebase — the same | |
| # convergence the Mind's self-heal loops use. | |
| for attempt in 1 2 3; do | |
| git fetch --quiet origin main | |
| if git merge-base --is-ancestor "$HEAD_SHA" origin/main; then | |
| echo "landed by a concurrent run" | |
| echo "via=concurrent" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| git checkout -B _ledger_merge origin/main | |
| if ! git merge --no-ff "$HEAD_SHA" \ | |
| -m "cortex: auto-merge ledger branch $BRANCH" \ | |
| -m "Ledger-only diff (scripts/ledger_merge.py), cortex check clean on the merged tree." ; then | |
| git merge --abort || true | |
| echo "::error::'$BRANCH' conflicts with main — resolve it by hand" | |
| echo "**Conflict** — \`$BRANCH\` does not merge cleanly; left for a human." >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| # THE TRIAL-MERGE CHECK. The branch passed alone; does it pass | |
| # merged? This is where two branches that both wrote the same | |
| # project's ledger are caught. | |
| if ! python3 scripts/cortex.py check; then | |
| git reset --hard origin/main | |
| echo "::error::'$BRANCH' merges cleanly but 'cortex.py check' fails on the merged tree — left for a human" | |
| echo "**Blocked** — \`$BRANCH\` fails \`cortex.py check\` once merged with main (two branches wrote the same ledger?); left for a human." >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| if [ -n "$PR" ]; then | |
| echo "open PR #$PR on this branch — merging through it" | |
| gh pr merge "$PR" --merge | |
| echo "via=pr#$PR" >> "$GITHUB_OUTPUT" | |
| echo "Merged **#$PR** (\`$BRANCH\` → main)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| if git push origin HEAD:main; then | |
| echo "merged on attempt $attempt" | |
| echo "via=direct" >> "$GITHUB_OUTPUT" | |
| echo "Merged \`$BRANCH\` → main (no PR; direct merge commit)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| echo "push rejected (attempt $attempt) — retrying on the new tip of main" | |
| done | |
| echo "::error::could not push the merge after 3 attempts" | |
| exit 1 | |
| # A PR merge deletes its own head where the repo has "Automatically | |
| # delete head branches" on; a DIRECT merge has no such hook. Delete it | |
| # here, on the same proof branch_sweep uses — main must actually contain | |
| # the head sha — so a failed or partial merge never loses a branch. | |
| - name: Delete the merged branch | |
| if: steps.merge.outputs.via != '' && inputs.delete_branch != false | |
| env: | |
| BRANCH: ${{ steps.target.outputs.branch }} | |
| HEAD_SHA: ${{ steps.ahead.outputs.head_sha }} | |
| run: | | |
| git fetch --quiet origin main | |
| if ! git merge-base --is-ancestor "$HEAD_SHA" origin/main; then | |
| echo "::warning::main does not contain $HEAD_SHA — keeping '$BRANCH'" | |
| exit 0 | |
| fi | |
| if git push origin --delete "$BRANCH"; then | |
| echo "Deleted \`$BRANCH\`." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| # Already gone (a PR merge with auto-delete on) is the common case | |
| # and is not a failure. | |
| echo "::warning::could not delete '$BRANCH' — it may already be gone" | |
| fi | |
| # The merge push above was made with GITHUB_TOKEN, which triggers no | |
| # workflows, so main's check must be asked explicitly or the merge | |
| # commit is never checked on main. | |
| - name: Run the check on main | |
| if: steps.merge.outputs.via != '' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh workflow run cortex_check.yml --repo "${{ github.repository }}" --ref main \ | |
| || echo "::warning::could not dispatch cortex_check.yml — its next push-triggered run will check main" |