--- key: ci.cancelled-is-not-a-verdict title: 2026-07-21 — `cancelled` is not `failure`; a cancelled run is no verdict (#542) status: active since: '2026-07-21' supersedes: none superseded-by: none rule: 'Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. THE COMBINED COMMIT-STATUS ENDPOINT CANNOT EXPRESS THIS: `GET /repos/{o}/{r}/commits/{sha}/status` has states `success`/`failure`/`pending`/`error` and NO `cancelled`, so it reports a cancelled job as `failure`. Anything polling that endpoint — which is what a CI monitor naturally polls, because it is the per-sha view the merge gate reads — must resolve the job-level `conclusion` via `actions/runs/{id}/jobs` before reporting a red.' signals: 'conclusion cancelled · run-level vs job-level conclusion · pre-cancel genuine failure · CI monitor state != pending · phantom failure · commit-status endpoint has no cancelled state · combined status reports cancelled as failure · resolve job conclusion before reporting a red · paths: n/a · issues: #542, #790' mechanics: Gitea Actions run/job API; monitor logic, e.g. `fail=[j for j in jobs if j['conclusion']=='failure']; canc=[j for j in jobs if j['conclusion']=='cancelled']`. --- The operator cancels runs by hand — they are the only party who can (see `ci.batch-pushes-no-cancel-route`) — and a run-level `conclusion: cancelled` means the run produced **no verdict** about your diff. Two traps follow. First, a run whose *overall* state is `failure` may hold a **genuine job failure that happened before the cancel**: check job-level `conclusion` plus timestamps rather than attributing the red to the cancel. Second, a cancelled run tells you nothing, so never claim green on one. A monitor that only asks "is state != pending" will report a cancelled run as a failure and send the next session debugging a phantom. Split the two counts explicitly. **The endpoint most monitors poll cannot express the distinction at all.** `commits/{sha}/status` is the per-sha view — the one the merge gate reads and the natural thing to watch a PR head with — and its vocabulary is `success`/`failure`/`pending`/`error`. A cancelled job arrives there as `failure`. So "split the counts" is not implementable against that endpoint: the job-level `conclusion` has to be fetched from `actions/runs/{id}/jobs`, with the run id taken from the status entry's `target_url`. Measured on ersatztv#790 (2026-08-22): three jobs reported `failure` on a head where everything that ran had passed. They had been auto-cancelled by the author's own next push. The cause is usually self-inflicted, which is the other half of the cost `ci.batch-pushes-no-cancel-route` and `process.local-gate-before-push` describe — an early push does not merely waste a runner slot, it manufactures reds that look like they belong to the diff.