#!/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