deploy.yaml was the only workflow and ran only on main, so a pushed branch started no run at all: the first time CI saw a change was the push that deployed it. And its `mode: validate` input, offered as "check only", was read by no job — a validate dispatch built and deployed whatever ref it ran on. ci.yaml now runs on every branch but main, on the `rust` runner, with no deploy job, no secrets and no artifact. The gate itself moves into a local composite action, .gitea/actions/gate, which both ci.yaml and deploy.yaml's `build` job use, so the gate a branch passes is the gate main runs rather than a copy that can drift. Its commands are CLAUDE.md's "Run the gate CI runs" verbatim. One deliberate change on the way: the drift check is `git status --porcelain`, as CLAUDE.md and #28 state it, not `git diff --quiet`, which cannot see the new untracked file a new DTO generates. Both deploy jobs now require `github.ref == 'refs/heads/main'` and `inputs.mode != 'validate'` (empty on a push, so the push path and its "what changed" gating are unchanged). A validate dispatch on main runs the build and gate plus a new `validate` job: the read-only checks from the end of deploy-api and deploy-web — API health from bob, reachability from the edge, the chain list through the vhost — against what is already deployed. Those checks are copied rather than shared, because the deploy jobs run from the build artifact without a checkout and restructuring them is a change only a production deploy would test; the comment says they must change together. Closes #28 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj