Skip to content

cortex: link every active project to its GitHub issue #66

cortex: link every active project to its GitHub issue

cortex: link every active project to its GitHub issue #66

Workflow file for this run

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"