Files
ersatztv/scripts/ci-detect-docs-only.sh
T
timothy 74005cc952
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(416): detect against FETCH_HEAD with a two-dot diff (shallow-checkout safe)
The test/migrations jobs check out fetch-depth:1. A shallow clone has NO
origin/<base> tracking ref and no merge-base, so the three-dot
origin/main...HEAD errored -> empty diff -> docs_only=false -> EVERY docs-only PR
silently ran the full matrix (safe but the skip never fired). git fetch always
writes FETCH_HEAD, which resolves in a shallow clone; diff against it with a
two-dot tree diff (no merge-base). Confirmed in a real shallow file:// clone:
origin/main did NOT resolve and three-dot errored, while FETCH_HEAD two-dot
correctly returned the docs file. api-docs/format were unaffected only because
they use fetch-depth:0.

Refs #416
2026-07-17 21:33:53 +02:00

126 lines
5.9 KiB
Bash
Executable File

#!/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
# Resolve a concrete BASE COMMIT to diff HEAD against. SHALLOW-CHECKOUT SAFE (ersatztv#416
# follow-up): the `test`/`migrations` jobs check out `fetch-depth: 1`, and a shallow clone has NO
# `origin/<base>` remote-tracking ref and no merge-base — so the old `origin/<base>...HEAD`
# (three-dot) errored, the diff came back empty, and EVERY docs-only PR silently ran the full
# matrix (safe but useless). `git fetch` ALWAYS writes FETCH_HEAD (the fetched base tip), which
# resolves even in a shallow clone; we diff against it with a TWO-dot tree diff below (no merge-base
# required). Verified in a real shallow `file://` clone. (`api-docs`/`format` only worked because
# they use `fetch-depth: 0`.)
base_rev=""
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
if ! git fetch --no-tags --depth=200 origin "$base" 2>/dev/null; then
echo "fetch of base '$base' failed; running full matrix (safe default)"
emit false
exit 0
fi
base_rev="$(git rev-parse --verify -q FETCH_HEAD || true)"
;;
push)
# Only branch pushes reach here (tags handled above). Skip only when the range is UNAMBIGUOUS —
# HEAD is a merge commit (how every update to main lands): its first parent is the pre-merge
# tip, so HEAD^1 is the base. Fetch depth 2 so the first-parent tree exists in the shallow
# checkout; if it still can't be resolved/diffed we fall through to the full matrix. A non-merge
# (direct/multi-commit) push is ambiguous here -> full matrix.
git fetch --no-tags --depth=2 origin "${GITHUB_REF_NAME:-main}" 2>/dev/null || true
nfields="$(git rev-list --parents -n1 HEAD 2>/dev/null | wc -w | tr -d ' ')" # 1 (self) + parents
if [ "${nfields:-0}" -ge 3 ]; then
base_rev="$(git rev-parse --verify -q 'HEAD^1' || true)"
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
if [ -z "$base_rev" ]; then
echo "could not resolve a base revision (shallow/offline); running full matrix (safe default)"
emit false
exit 0
fi
# TWO-dot tree diff `git diff <base> HEAD` (NOT three-dot) so no merge-base is needed — that is what
# makes this work in a shallow checkout. `--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 "$base_rev" HEAD 2>/dev/null || true)"
echo "Base: $base_rev (event=$event)"
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