From 5ba737bec7c4a79d4cbaf91a8bfbfb53a196e213 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 19:18:37 +0200 Subject: [PATCH 1/3] ci(416): skip heavy jobs on docs-only changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs-only changes (docs/** or *.md) ran the full docker-build matrix (~9 min). Each heavy job (test, migrations, functional-e2e, build) now runs scripts/ci-detect-docs-only.sh as its first post-checkout step and gates every real step on docs_only!='true'. The jobs still RUN and report success in seconds, so the two required contexts keep reporting — a docs-only PR stays mergeable (never an if:-skipped required job; Gitea 1.25.4 reports if-skip as 'skipped', verified with a throwaway probe PR). build skips its image steps on a docs-only push to main; tag builds force docs_only=false. Detection uses --no-renames so a code->docs rename can never be misclassified as docs-only. Refs #416 --- .gitea/workflows/docker-build.yml | 76 +++++++++++++++++++++- scripts/ci-detect-docs-only.sh | 101 ++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100755 scripts/ci-detect-docs-only.sh diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 1c308ac33..d6fb06356 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -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 }}" diff --git a/scripts/ci-detect-docs-only.sh b/scripts/ci-detect-docs-only.sh new file mode 100755 index 000000000..332492f21 --- /dev/null +++ b/scripts/ci-detect-docs-only.sh @@ -0,0 +1,101 @@ +#!/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:-}' is not pull_request/push; running full matrix (safe default)" + emit false + exit 0 + ;; +esac + +changed="$(git diff --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 -- 2.47.3 From 92bb63b6dabcbdd8e51db7d27f0d32884fa02646 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 19:18:37 +0200 Subject: [PATCH 2/3] docs(416): document docs-only CI skip ci-cd.md gains a 'Docs-only skip' section + triggers-table note; decisions.md records the decision. Cross-refs the separate PR-vs-main rerun redundancy (#420) and the within-run triple build (#398). Refs #416 --- docs/ci-cd.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++ docs/decisions.md | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8af1eea0b..711ded711 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -68,6 +68,9 @@ job — it's Komodo Global Auto Update off the `:prod` tag (see "Cutting a relea | push tag `v*` | ✅ | ✅ | `:prod` + `:` + `:` | | `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 `:` 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 diff --git a/docs/decisions.md b/docs/decisions.md index a5db51d23..105870b54 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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". -- 2.47.3 From f7b97adce8ca30019025f9a0001d29875776540f Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 19:44:25 +0200 Subject: [PATCH 3/3] ci(416): harden docs-only detection with --no-renames (review finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changed-set git diff had rename detection on by default, so a code->docs rename (Foo.cs -> docs/Foo.md) showed only the destination and was misclassified as docs-only, skipping required tests on a code change. --no-renames surfaces the source deletion -> full matrix. Empirically verified. This is the cold-review MEDIUM; it was applied in the working tree but never committed before the first push (index/worktree mismatch) — committing it now. Refs #416 --- scripts/ci-detect-docs-only.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/ci-detect-docs-only.sh b/scripts/ci-detect-docs-only.sh index 332492f21..2ca1072ea 100755 --- a/scripts/ci-detect-docs-only.sh +++ b/scripts/ci-detect-docs-only.sh @@ -72,7 +72,12 @@ case "$event" in ;; esac -changed="$(git diff --name-only "$range" 2>/dev/null || true)" +# --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" -- 2.47.3