--- key: ci.docs-only-skip-steps title: 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416) status: active since: '2026-07-17' supersedes: none superseded-by: none rule: 'A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != ''true''`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt.' signals: 'docs-only CI skip, required-context branch protection, step-level gating vs job-level `if:` · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-detect-docs-only.sh` · issues: #416, #418, #420, #398' mechanics: '`docs/ci-cd.md` → "Docs-only skip"' --- A change touching only `docs/**` or `*.md` ran the entire `docker-build.yml` matrix (`test`, `migrations` incl. its `mysql:8.4` service, `functional-e2e`, `format`, `api-docs`) — ~9 min of warm CI to validate Markdown. `docker-build.yml` had no path filtering. **Why not `paths-ignore` / an `if:`-skipped job — the trap.** `main`'s branch protection requires two contexts *by name* (`Build & test (.NET)`, `EF migration integrity (SQLite + MySql)`). If a docs-only PR produced **no run** for them, those contexts never report and the PR can **never merge** — the naive fix bricks docs PRs instead of speeding them. A probe (throwaway PR #418) confirmed that on Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`** (a distinct state, not `success`); how branch protection treats a `skipped` *required* context is not something we rely on. **The decision.** Each heavy job (`test`, `migrations`, `functional-e2e`, `build`) runs `scripts/ci-detect-docs-only.sh` as its first post-checkout step (`id: detect`) and gates every real step on `if: steps.detect.outputs.docs_only != 'true'`. The job **always runs** and reports `success` in seconds on docs-only — so the two required contexts report unconditionally (safe by construction). Non-required jobs may skip freely (production proves a `skipped` non-required context doesn't block merge — `build` is `skipped` on every PR), so `build` skips its image steps on a **docs-only push to `main`** (docs aren't in the image); tag builds force `docs_only=false` so a release is never skipped. `api-docs`/`format` already self-short-circuit; `docs-reminder`/ `decisions-guard`/`ci-image-pin` keep running. **Detection biases toward running MORE.** `docs_only=true` only when *every* changed path is docs; any code path, a tag build, a non-merge push, or an undeterminable diff → `false` (run everything). A false `true` would skip real tests on a code change (a correctness bug); a false `false` merely wastes CI. Trade-off accepted: `migrations`' `mysql` service still starts on a docs-only run (a `services:` container starts with the job regardless of step `if:`), but the 787-migration replay — the expensive part — is skipped. Two adjacent redundancies are deliberately **out of scope**: the whole matrix re-running on a PR and again on the merge-to-`main` over identical code (#420), and the within-run triple `dotnet build` (#398). Full mechanism in `ci-cd.md` → "Docs-only skip".