ROOT CAUSE (red-CI promotions 0.66.28/29): the required-checks
'Publish protected-branch gate' step read /tmp/need_* files that no step
ever wrote and echoed 'All required CI checks passed' UNCONDITIONALLY.
With if: always() the needs gate is overridden, so the job ran on red
needs and published success — deploy_qualify correctly trusted it and
promoted red commits (24413e4, fd6e9b4, de30cd9; status timelines in the
closure evidence).
FIX: publish step now runs only when every needs.*.result == 'success';
companion fail-closed step exits 1 otherwise, so the required-checks
context reports failure instead of lying.
DEFENSE-IN-DEPTH: deploy_qualify verdict is now the AND of two
independent legs — (1) newest required-checks observation for the exact
SHA, (2) the latest COMPLETED ci.yml@refs/heads/master run for that exact
SHA with every job concluded success (fan-out resolved by path, run
resolved by head_sha, jobs envelope unwrapped — no aggregate-status
fallback). Any pending/missing/disagreement = not deployable.
CANARY: gitea_pipeline now asserts the verdict evaluates needs results
and fails closed — the class locked shut. 20 qualifier tests with
fixtures from the real incident shapes; 1402 suite green.
The embedded multi-line python in the workflow run blocks terminated the
YAML literal scalars — Gitea could not parse deploy.yml at all (dispatch
500: 'could not find expected :'), which is why no deploy runs appeared.
Logic now lives in scripts/deploy_qualify.py + deploy_stage.py; the
workflow is pure shell + script calls.