ci(416): skip heavy jobs on docs-only changes without bricking the merge gate #422

Merged
timothy merged 3 commits from ci/416-docs-only-skip into main 2026-07-17 20:10:25 +02:00
4 changed files with 262 additions and 2 deletions
+74 -2
View File
@@ -26,6 +26,16 @@ name: Build ErsatzTV Image
# "CI toolchain image" for the two-step procedure.
#
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
#
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
# `scripts/ci-detect-docs-only.sh` as their first post-checkout step (id: detect) and gate every
# real step on `steps.detect.outputs.docs_only != 'true'`. Crucially they STILL RUN and STILL
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
on:
workflow_dispatch:
@@ -97,7 +107,15 @@ jobs:
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -105,11 +123,13 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
# the SPA's package downloads are project deps, so they stay cached per lockfile.
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -117,36 +137,45 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm ci
- name: Check generated SPA API client
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run check:api
- name: Lint SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run lint
- name: Typecheck SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run typecheck
- name: Test SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm test -- --run
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
if: steps.detect.outputs.docs_only != 'true'
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build --configuration Release --no-restore
- name: Test
if: steps.detect.outputs.docs_only != 'true'
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
@@ -157,6 +186,7 @@ jobs:
# floor later"), so this step is purely informational — continue-on-error keeps a missing
# report or a transient tool-install failure from ever blocking a build.
- name: Coverage summary
if: steps.detect.outputs.docs_only != 'true'
continue-on-error: true
run: |
set -euo pipefail
@@ -202,12 +232,14 @@ jobs:
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
# sits after Coverage summary to include reportgenerator, the job's last real workload.
# cgroup v2 first, v1 fallback.
#
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
- name: Report peak container memory
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
# the same guarantee the Coverage summary step above uses.
if: always()
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
continue-on-error: true
run: |
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
@@ -271,6 +303,12 @@ jobs:
# service reported `mem=0 nanocpus=0`, i.e. unbounded. So every migrations run was adding
# an uncapped MySQL to an already-tight host (ersatztv#406, server-management#604).
#
# NOTE (ersatztv#416): a `services:` container starts whenever the JOB starts, regardless
# of step `if:`. So a docs-only migrations run still spins this mysql (capped, seconds) even
# though the DDL-replay steps below are skipped. Fully skipping the service would require an
# `if:`-skipped job, which we deliberately do NOT do for a required context — the heavy cost
# (the 787-migration replay) is what the step gating removes.
#
# `--memory-swap=2g` is NOT redundant with `--memory=2g` — it is the point. Docker defaults
# an unset `--memory-swap` to *twice* `--memory`, so `--memory=2g` alone would grant 2g RAM
# **plus 2g of swap** (verified on bumblebee: `--memory=2g` alone → memory.max=2147483648
@@ -304,7 +342,14 @@ jobs:
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -312,9 +357,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
- name: Build
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build --configuration Release --no-restore
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
@@ -322,6 +369,7 @@ jobs:
# SQLite is the prod provider; both checks validated locally.
- name: SQLite — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
run: |
set -euo pipefail
echo "::group::SQLite model drift (has-pending-model-changes)"
@@ -337,6 +385,7 @@ jobs:
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
- name: MySql — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
env:
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
@@ -392,7 +441,13 @@ jobs:
with:
fetch-depth: 1
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -400,9 +455,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -410,14 +467,17 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm ci
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run build
- name: Build (Release)
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
@@ -427,6 +487,7 @@ jobs:
# FFmpegLocatorService) and drives curl-only contracts that never transcode.
- name: Boot instance and run functional-E2E harness
if: steps.detect.outputs.docs_only != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
@@ -455,8 +516,16 @@ jobs:
with:
fetch-depth: 0
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
# so skip the build/push/smoke steps — the job still reports success. Tag builds force
# docs_only=false in the script, so a release is never skipped.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Compute version and tags
id: meta
if: steps.detect.outputs.docs_only != 'true'
run: |
SHORT=$(git rev-parse --short HEAD)
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
@@ -479,6 +548,7 @@ jobs:
printf 'tag: %s\n' "${TAGS[@]}"
- name: Set up Docker Buildx
if: steps.detect.outputs.docs_only != 'true'
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
@@ -486,6 +556,7 @@ jobs:
http = true
- name: Login to Gitea registry
if: steps.detect.outputs.docs_only != 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -493,6 +564,7 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
if: steps.detect.outputs.docs_only != 'true'
uses: docker/build-push-action@v6
with:
context: .
@@ -508,7 +580,7 @@ jobs:
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
- name: Smoke + IPTV E2E (assert key endpoints)
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
run: |
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
NAME="etv-smoke-${{ github.run_id }}"
+48
View File
@@ -68,6 +68,9 @@ job — it's Komodo Global Auto Update off the `:prod` tag (see "Cutting a relea
| push tag `v*` | ✅ | ✅ | `:prod` + `:<version>` + `:<short-sha>` |
| `workflow_dispatch` | ✅ | ✅ | only if ref is `main`/`v*`, else build-only (no push) |
A **docs-only** change (see "Docs-only skip" below) reduces every ✅ above to a seconds-long no-op
that still reports its status.
`:latest` is the **test/dev** channel (every `main` commit). Prod's compose **follows the
floating `:prod`** tag (reverted from the 2026-07-07 version pin on 2026-07-11) — never
`:latest`. Both `:prod` and `:<version>` are produced by pushing a `v*` tag; prod tracks
@@ -300,6 +303,51 @@ builds. Out of scope for this first cut (need the scanner subprocess + seeded me
be deterministic — tracked as ersatztv#299 follow-ups): the racy 409 "already-scanning" re-trigger,
the playout-build lock 409, and the genuinely UI-interactive Playwright flows.
### Docs-only skip (ersatztv#416)
A change that touches **only** `docs/**` or `*.md` (anywhere: `README.md`, `CLAUDE.md`, handoff
files) has nothing for the heavy jobs to validate. Before this, such a change ran the entire matrix
— `test`, `migrations` (with its `mysql:8.4` service), `functional-e2e`, `format`, `api-docs` —
~9 min of warm CI for a Markdown edit.
**The mechanism, and why it is shaped this way.** Each heavy job (`test`, `migrations`,
`functional-e2e`, `build`) runs `scripts/ci-detect-docs-only.sh` as its first post-checkout step
(`id: detect`), which emits `docs_only=true|false` to `$GITHUB_OUTPUT`. Every real step in the job
is gated `if: steps.detect.outputs.docs_only != 'true'`. On a docs-only change the job runs only
checkout + detect and **reports `success` in seconds**.
The jobs are **not** `if:`-skipped. That is deliberate and it is the whole trap of this issue:
- `main`'s branch protection requires two checks **by name** — `Build ErsatzTV Image / Build & test
(.NET) (pull_request)` and `Build ErsatzTV Image / EF migration integrity (SQLite + MySql)
(pull_request)`. If a docs-only PR produced **no run** for those (a workflow-level `paths-ignore`,
or an `if:`-skipped job), those contexts would never report and the PR could **never merge** — the
naive fix *bricks* docs PRs rather than speeding them up.
- On Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`**, a distinct
state (verified with a throwaway probe, PR #418) — not `success`. We do **not** rely on how branch
protection treats a `skipped` **required** context. Keeping the job running and gating its *steps*
makes the required context report `success` unconditionally, which is safe by construction.
- Non-required jobs may skip freely: production already proves a `skipped` **non-required** context
does not block merge (`build` is `skipped` on every PR). So `build` skips its image steps on a
**docs-only push to `main`** (docs are not in the image, so there is nothing to rebuild); tag
builds force `docs_only=false` in the script so a release is never skipped.
The 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 resolves to `false`
(run the full matrix). A false `true` would skip real tests on a code change — a correctness bug —
so every ambiguous case runs everything. The `migrations` job's `mysql` service still starts on a
docs-only run (a `services:` container starts with the job regardless of step `if:`), but the
expensive 787-migration replay is skipped; the service is capped and idle for seconds.
`api-docs` and `format` already short-circuit on docs-only changes via their own path detection (no
API path / no `.cs` changed → they pass in ~5s), so they needed no change. `docs-reminder`,
`decisions-guard` and `ci-image-pin` keep running on docs-only changes — the first two are *about*
docs and must.
Not in scope: the separate redundancy of running the **whole matrix on a PR and again on the
merge-to-`main`** over identical code (ersatztv#420), and the within-run triple `dotnet build`
(ersatztv#398).
### `docs-reminder` job (non-blocking, PR-only)
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
+34
View File
@@ -1488,3 +1488,37 @@ and "no single show dominates" — as **one** order, because fair-share is the e
SmartCollection, so its "sources" are members of a single collection, and a `PlaylistItem` cannot even
reference a search query. Nothing here forecloses it — the enumerator reads weights off
`CollectionWithItems` source-agnostically.
## 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416)
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".
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# scripts/ci-detect-docs-only.sh — emit `docs_only=true|false` to $GITHUB_OUTPUT for the
# docker-build.yml heavy-job gate (ersatztv#416). A change is "docs" iff every changed path is
# under docs/ or is a *.md file anywhere. docs_only=true ONLY when EVERY changed path is docs;
# any code path, an undeterminable diff, a tag build, or a non-PR/push event => false (run the
# full matrix).
#
# The bias is ALWAYS toward running MORE, never less: a false 'true' would skip the real
# test/migrations/build work on a code change, so every ambiguous case resolves to
# docs_only=false. It is fine (just wasteful) to run the full matrix on a docs change; it is a
# correctness bug to skip it on a code change.
#
# Why this is the merge-gate-safe half of #416: the two REQUIRED contexts (`Build & test (.NET)`,
# `EF migration integrity (SQLite + MySql)`) are gated by SKIPPING STEPS inside a job that always
# runs and always reports `success` — never by an `if:`-skipped job. On Gitea 1.25.4 an
# `if:`-skipped job reports commit-status state `skipped` (verified, PR #418), and how branch
# protection treats a `skipped` REQUIRED context is not something we rely on. Non-required jobs may
# skip freely (production already proves a `skipped` non-required context — e.g. `build` on every
# PR — does not block merge).
#
# Runs identically locally and in CI. Locally (no $GITHUB_OUTPUT) it prints the decision to stdout;
# e.g. GITHUB_EVENT_NAME=pull_request GITHUB_BASE_REF=main scripts/ci-detect-docs-only.sh
set -euo pipefail
out="${GITHUB_OUTPUT:-/dev/stdout}"
event="${GITHUB_EVENT_NAME:-}"
emit() {
echo "docs_only=$1" >> "$out"
echo "-> docs_only=$1"
}
# A release tag must NEVER be treated as docs-only, whatever it touches.
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
echo "tag build (${GITHUB_REF_NAME:-?}); never docs-only"
emit false
exit 0
fi
range=""
case "$event" in
pull_request)
base="${GITHUB_BASE_REF:-}"
if [ -z "$base" ]; then
echo "pull_request with no base ref; running full matrix (safe default)"
emit false
exit 0
fi
git fetch --no-tags --depth=200 origin "$base" || true
range="origin/${base}...HEAD"
;;
push)
# Only branch pushes reach here (tags handled above). We skip the heavy matrix on a docs-only
# push only when the range is UNAMBIGUOUS — i.e. HEAD is a merge commit, which is how every
# update to main lands (PR merge). Its first parent is the pre-merge branch tip, so
# HEAD^1...HEAD is exactly the merged delta. A non-merge (direct/multi-commit) push is rare and
# its true range is ambiguous here, so we fall back to running the full matrix.
git fetch --no-tags --depth=200 origin "${GITHUB_REF_NAME:-main}" || true
nfields="$(git rev-list --parents -n1 HEAD | wc -w | tr -d ' ')" # 1 (self) + parent count
if [ "${nfields:-0}" -ge 3 ]; then
range="HEAD^1...HEAD"
else
echo "non-merge push (parents=$(( nfields - 1 ))); running full matrix (safe default)"
emit false
exit 0
fi
;;
*)
echo "event '${event:-<none>}' is not pull_request/push; running full matrix (safe default)"
emit false
exit 0
;;
esac
# --no-renames is load-bearing: with rename detection ON (git's default) a code->docs rename
# (e.g. Foo.cs -> docs/Foo.md) shows ONLY the destination `docs/Foo.md`, hiding that a source file
# left the build -> misclassified as docs-only -> required tests skipped on a code change.
# --no-renames surfaces the deletion (`Foo.cs`, non-docs) so it correctly forces the full matrix,
# keeping the "any code path => run everything" invariant total.
changed="$(git diff --no-renames --name-only "$range" 2>/dev/null || true)"
echo "Range: $range"
echo "Changed files:"
printf '%s\n' "$changed"
if [ -z "$changed" ]; then
echo "empty/undeterminable diff; running full matrix (safe default)"
emit false
exit 0
fi
# docs_only unless SOME changed path is NOT docs. "docs" = under docs/ OR ends in .md (anywhere:
# README.md, CLAUDE.md, AGENTS.md, docs/handoffs/*.md, ...). Everything else — .cs, web/**,
# .gitea/**, Dockerfiles, scripts, csproj — is a code change and forces the full matrix.
#
# Capture the non-docs lines and test for emptiness rather than `grep -qv`: the combination of
# `-q` and `-v` early-exits inconsistently across grep implementations (BSD grep on macOS returned
# the wrong exit code here). `|| true` guards `set -e` when grep matches nothing (exit 1).
nondocs="$(printf '%s\n' "$changed" | grep -vE '(^docs/|\.md$)' || true)"
if [ -n "$nondocs" ]; then
echo "non-docs path(s) present in the diff -> NOT docs-only:"
printf '%s\n' "$nondocs" | sed 's/^/ /'
emit false
else
echo "every changed path is docs/ or *.md -> docs-only"
emit true
fi