Skip to content

cortex: regenerated board and remote-sessions block #82

cortex: regenerated board and remote-sessions block

cortex: regenerated board and remote-sessions block #82

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
# issues: write → a branch that cannot land is written on an issue.
issues: 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
# Built without committing: the two generated pages are the one
# conflict every ledger branch can produce (each re-renders them),
# and a render is never a reason to strand a ledger — take main's
# copy, let the merged tree be re-rendered on main by
# dashboard_refresh.yml. Anything else unmerged is a human's.
if ! git merge --no-ff --no-commit "$HEAD_SHA" >/dev/null 2>&1; then
UNMERGED=$(git diff --name-only --diff-filter=U)
OTHER=$(echo "$UNMERGED" | grep -vE '^(dashboard\.md|dashboard\.html)$' || true)
if [ -n "$OTHER" ]; then
git merge --abort || true
echo "'$BRANCH' conflicts with main on: $(echo $OTHER)" > /tmp/ledger_merge_reason
echo "::error::'$BRANCH' conflicts with main beyond the generated pages — resolve it by hand"
echo "**Conflict** — \`$BRANCH\` does not merge cleanly (\`$(echo $OTHER)\`); left for a human." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
for f in $UNMERGED; do git checkout --ours -- "$f" && git add -- "$f"; done
echo "generated pages conflicted — took main's copy; dashboard_refresh.yml re-renders on main"
fi
git commit --quiet --no-verify \
-m "cortex: auto-merge ledger branch $BRANCH" \
-m "Ledger-only diff (scripts/ledger_merge.py), cortex check clean on the merged tree."
# 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 "'$BRANCH' merges cleanly but 'cortex.py check' fails on the merged tree (two branches wrote the same ledger?)" > /tmp/ledger_merge_reason
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.
# A branch found ALREADY in main gets the same treatment: 38 of them had
# accumulated by 2026-09-17, each merged by a run whose delete never
# fired, and every later push found "nothing to merge" and left it.
- name: Delete the merged branch
if: (steps.merge.outputs.via != '' || steps.ahead.outputs.merged == 'already') && 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
# A branch that cannot land is written down where a human looks: one
# open issue per branch, updated on every further attempt, closed by the
# run that finally lands it. A red run nobody is subscribed to is how
# the Mind lost six completion records (2026-09-17).
- name: Leave a note a human will see
if: failure() && steps.ahead.outputs.merged == 'no'
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.target.outputs.branch }}
run: |
REASON=$(cat /tmp/ledger_merge_reason 2>/dev/null || echo "a step failed — see the run log")
RUN="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
TITLE="ledger merge: \`$BRANCH\` needs a human"
BODY=$(printf '%s\n\nRun: %s\nBranch: https://github.com/%s/tree/%s\n\nMerge it by hand and push to main; this issue closes itself when the branch lands.' \
"$REASON" "$RUN" "${{ github.repository }}" "$BRANCH")
gh label create ledger-merge --color d93f0b --description "a ledger branch the auto-merge could not land" --force >/dev/null 2>&1 || true
N=$(gh issue list --state open --label ledger-merge --search "in:title \"$BRANCH\"" --json number --jq '.[0].number // empty')
if [ -n "$N" ]; then
gh issue comment "$N" --body "$BODY"
else
gh issue create --title "$TITLE" --label ledger-merge --body "$BODY"
fi
- name: Close the note once the branch has landed
if: steps.merge.outputs.via != '' || steps.ahead.outputs.merged == 'already'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.target.outputs.branch }}
run: |
for N in $(gh issue list --state open --label ledger-merge --search "in:title \"$BRANCH\"" --json number --jq '.[].number'); do
gh issue close "$N" --comment "Landed: \`$BRANCH\` is in main." --reason completed
done
# 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"