diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4736e3d..b83292e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,15 @@ # a pushed FILE is readable by anyone — including the agent repairing its # own red build — via raw.githubusercontent.com//diagnostics/ # ci-diagnostics.log. do not remove this step: it is the self-repair loop. +# +# it publishes ON FAILURE ONLY, and it can never fail this job. both were +# learned the hard way on 2026-08-26: the step ran `if: always()`, so a green +# run overwrote the failure log the next reader needed, and the force-push to +# a single branch RACED between the push- and pull_request-triggered runs of +# the same commit. the loser was rejected ("cannot lock ref +# 'refs/heads/diagnostics'") and took the whole verify job red with it — a +# diagnostic breaking the thing it exists to observe, with the gate itself +# green three steps above. name: ci on: @@ -76,8 +85,11 @@ jobs: # the self-repair loop, made concrete: whatever failed above, its real # output lands on the diagnostics branch as a plain file. also mirrored # into the job summary for humans reading the run page. + # + # `failure()`, not `always()`: a green run has nothing a reader wants, + # and publishing one CLOBBERS the failure log that is the whole point. - name: publish diagnostics - if: always() + if: failure() run: | LOG=ci-diagnostics.log { @@ -111,8 +123,21 @@ jobs: git rm -rfq . || true git add "$LOG" git commit -m "ci diagnostics for ${GITHUB_SHA}" --allow-empty - git push --force \ - "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - "HEAD:diagnostics" + + # this push must never decide whether the build is green. two runs + # of the same commit (push + pull_request) can reach here together + # and one loses the ref lock; retry, then give up LOUDLY but with + # exit 0. a lost diagnostics log costs a reader one click into the + # job log below it. a red gate over one costs a landing. + REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + for attempt in 1 2 3; do + if git push --force "$REMOTE" "HEAD:diagnostics"; then + echo "diagnostics published on attempt ${attempt}" + exit 0 + fi + echo "diagnostics push lost the ref lock (attempt ${attempt}); retrying" + sleep $((attempt * 5)) + done + echo "::warning::could not publish ci-diagnostics.log after 3 attempts; read the job log above instead. the gate result is unaffected." env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/memory/status.md b/memory/status.md index 263bdd8..32644e0 100644 --- a/memory/status.md +++ b/memory/status.md @@ -7,6 +7,47 @@ > (agi/rsi gradient) and the constitution now governs every run. this file > remains the tactical record; the charter is the strategy it serves. +## landed this run (ci: a diagnostic must not break what it observes — agent/ci-diagnostics-must-not-fail-the-gate) + +found the honest way: a pr went red with a GREEN gate. `verify` on the +pull_request event failed at `publish diagnostics`, three steps after the +wasm check, the test gate and clippy had all passed. + +cause: that step ran `if: always()` and force-pushed to a single +`diagnostics` branch, so the push- and pull_request-triggered runs of the +SAME commit reached it together, one lost the ref lock ("cannot lock ref +'refs/heads/diagnostics': is at X but expected Y"), and its non-zero exit +took the whole job with it. + +two defects, both fixed: + +- **it published on success.** the workflow's own header says "on any + failure this workflow publishes"; the code said `always()`. so every + green run OVERWROTE the failure log the next reader needed — the same + words-vs-code gap item 3 named as this project's recurring failure mode. + now `if: failure()`. +- **it could fail the job.** the push is retried three times with backoff, + then gives up with a `::warning::` and exit 0. a lost diagnostics log + costs a reader one click into the job log printed directly above it; a + red gate over one costs a landing. + +D9's shape applied to ci: an escape hatch that only works when things are +fine is not an escape hatch, and a diagnostic that breaks the thing it +observes is not a diagnostic. pinned by +`the_diagnostics_publisher_cannot_turn_a_green_gate_red` in +tests/ci_gate.rs and verified in BOTH directions — flipping the step back +to `always()` reds the guard. + +`workflow_source()` now normalizes \r\n: this checkout has +core.autocrlf=true, and every multi-line shape guard would otherwise go red +locally while ci passed — the trap boot_reconcile fell into in PR #16, now +recurring exactly as the board predicted it would. + +LESSON for reading a red pr here: `ci` runs TWICE per pr (push and +pull_request). when one is green and the other is red on the same sha, the +difference is not the code — look at which STEP failed before assuming the +gate caught something. + ## landed this run (item 8c: the agent rewrites its own reasoning policy — agent/cognitive-swap-tool) eighth "keep going", stacked on 8b because github actions was in a major diff --git a/tests/ci_gate.rs b/tests/ci_gate.rs index 84c1260..ab0821e 100644 --- a/tests/ci_gate.rs +++ b/tests/ci_gate.rs @@ -67,7 +67,11 @@ fn the_shared_gate_uses_filesystem_discovery_not_a_list() { /// these assertions are load-bearing. fn workflow_source() -> Option { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".github/workflows/ci.yml"); - std::fs::read_to_string(path).ok() + // normalized: this repo is edited on a checkout with core.autocrlf=true, + // and every shape guard below would go red on \r\n while ci passed. + std::fs::read_to_string(path) + .ok() + .map(|t| t.replace("\r\n", "\n")) } #[test] @@ -191,3 +195,56 @@ fn the_e2e_smoke_asserts_boot_not_mere_reachability() { misleading 'worker never announced ready'." ); } + +/// the diagnostics publisher must not be able to fail the gate, and must +/// only run when there is something to publish. +/// +/// both halves are scar tissue from 2026-08-26. the step ran `if: always()` +/// and force-pushed to one branch, so the push- and pull_request-triggered +/// runs of the SAME commit reached it together, one lost the ref lock +/// ("cannot lock ref 'refs/heads/diagnostics'"), and its non-zero exit took +/// the whole verify job red — with the gate itself green three steps above. +/// a green run also overwrote the failure log the next reader needed. +/// +/// this is D9's shape applied to ci: a diagnostic that breaks the thing it +/// observes is not a diagnostic. +#[test] +fn the_diagnostics_publisher_cannot_turn_a_green_gate_red() { + let Some(wf) = workflow_source() else { + eprintln!("SKIP: .github/workflows/ci.yml not present in this checkout"); + return; + }; + + let step = wf + .split("- name: publish diagnostics") + .nth(1) + .expect("ci.yml has no `publish diagnostics` step — it is the self-repair loop"); + + assert!( + step.contains("if: failure()"), + "the diagnostics step must run only on failure. `always()` publishes \ + green runs, which CLOBBERS the failure log that is the entire point \ + of the diagnostics branch." + ); + assert!( + !step.contains("if: always()"), + "the diagnostics step is back on `always()`" + ); + + // the push is the racy part: it must be retried and it must not be the + // last word on the step's exit status. + assert!( + step.contains("for attempt in"), + "the diagnostics push is not retried; a lost ref lock between two \ + concurrent runs of the same commit will fail the job again." + ); + assert!( + step.contains("::warning::"), + "a diagnostics push that gives up must say so loudly (D4) rather than \ + failing silently or fatally." + ); + assert!( + !step.trim_end().ends_with("\"HEAD:diagnostics\""), + "the step ends on a bare push: its exit status decides the gate again" + ); +}