ci(416): skip heavy jobs on docs-only changes without bricking the merge gate #422
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
Executable
+106
@@ -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
|
||||
Reference in New Issue
Block a user