---
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".
