diff --git a/.github/workflows/lifecycle_drift.yml b/.github/workflows/lifecycle_drift.yml index 2760928d..207c647e 100644 --- a/.github/workflows/lifecycle_drift.yml +++ b/.github/workflows/lifecycle_drift.yml @@ -4,7 +4,15 @@ name: Lifecycle Drift # complete/YYYY/MM invariant and the generated complete/index.md. Fails on: # - lifecycle check drift (a slug in both active.md and complete.md; a # complete/ record with no complete.md entry; a file in two states); -# - a stale complete/index.md (regenerate with `lifecycle.py index --apply`). +# - on pull requests, a stale complete/index.md (regenerate with +# `lifecycle.py index --apply`). +# +# On pushes to main a stale index is SELF-HEALED instead (issue #116): the run +# regenerates it and pushes a bot commit, and fails only if the repair itself +# fails. Mind pushes land directly on main from many concurrent agent +# sessions, so ad-hoc mutations of complete/ (e.g. a manual `git mv`) will +# keep happening — an alarm-only check emailed the human once per push until +# the next `record --apply` happened to regenerate the index. on: push: @@ -24,8 +32,9 @@ on: - "scripts/lifecycle.py" workflow_dispatch: +# contents: write is needed by the self-heal push on main; PR runs never push. permissions: - contents: read + contents: write jobs: drift: @@ -34,5 +43,43 @@ jobs: - uses: actions/checkout@v4 - name: lifecycle check run: python3 scripts/lifecycle.py check - - name: index freshness - run: python3 scripts/lifecycle.py index --check + - name: index freshness (self-healing on push to main) + run: | + if python3 scripts/lifecycle.py index --check; then + exit 0 + fi + if [ "${GITHUB_EVENT_NAME}" != "push" ]; then + echo "::error::complete/index.md is stale — run 'python3 scripts/lifecycle.py index --apply' on this branch and commit the result" + exit 1 + fi + echo "complete/index.md is stale on main — self-healing" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Each attempt rebuilds on the current tip of main, so a concurrent + # push (the usual cause of a rejected push) never needs a rebase or + # conflict resolution — just regenerate and try again. The heal push + # uses the default GITHUB_TOKEN, which does not trigger workflow + # runs, so it cannot loop; convergence is verified before pushing + # regardless. + for attempt in 1 2 3; do + git fetch origin main + git reset --hard FETCH_HEAD + if python3 scripts/lifecycle.py index --check; then + echo "tip of main is already fresh (healed by a concurrent push)" + exit 0 + fi + python3 scripts/lifecycle.py index --apply + if ! python3 scripts/lifecycle.py index --check; then + echo "::error::'index --apply' did not converge — lifecycle.py bug, repair by hand" + exit 1 + fi + git add complete/index.md + git commit -m "lifecycle: self-heal stale complete/index.md" + if git push origin HEAD:main; then + echo "healed on attempt ${attempt}" + exit 0 + fi + echo "push rejected (attempt ${attempt}) — retrying on the new tip of main" + done + echo "::error::could not push the healed index after 3 attempts" + exit 1