#!/usr/bin/env bash # ci/touched-requirements.sh — compute the requirement subset a change "touches". # # R-TEST-03 gates a merge on the conformance subset tagged with the requirement # IDs the change touches. This script derives that subset from the diff so the # conformance runner can be filtered to it (conformance.sh --requirements ...). # # Output (stdout): newline-separated, sorted, unique R-* IDs. Nothing else goes # to stdout, so callers can `mapfile`/`$(...)` it directly. # # SOURCE OF TRUTH (reconciled, PM 2026-08-16): the conformance requirement TAGS are # authoritative. Each Chainsaw test declares the requirements it verifies via # `conformance.home.k3s/` labels; the coverage report (conformance/_artifacts/ # coverage/coverage.json) is generated from those tags. This script's job is only to # select which tagged subset a change TOUCHES, then hand those IDs to # `conformance.sh --requirements ...` which runs exactly the tests carrying those tags. # # Detection = union of: # 1. R-* tokens on the ADDED/REMOVED LINES of the diff, in non-Markdown files — # including the authoritative `conformance.home.k3s/R-*` tags on changed tests, and # the R-IDs that policies/ServiceDefinitions self-declare (docs/14 + ADR-0011). # Untouched lines and prose are NOT scanned; see changed_diff() for why. # 2. A coarse path->requirement fallback for changes that don't self-declare IDs. # The coarse map (2) NEVER overrides an explicit tag; it only widens coverage for # untagged changes so nothing silently escapes the gate. set -euo pipefail CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "${CI_DIR}/lib.sh" # --------------------------------------------------------------------------------- # BASE-REF RESOLUTION — and why it now REFUSES rather than guesses. # # BUG THIS FIXES (local/CI divergence, 2026-08-31): every failure to establish a # reliable diff base used to degrade SILENTLY to an EMPTY touched set, which main() # reported as "no changed files vs base" and exit 0. An empty set means the coverage # `--require` gate is never applied, so the merge gate printed "MERGE GATE: PASS" # having checked NOTHING — while CI, with a correct base, computed 17 touched # requirements and blocked. A false green is the worst outcome this project can # produce, so every one of those degradations is now a LOUD exit 3: # # * HEAD~1 last resort REMOVED. It silently narrowed the diff to the last commit # (and on a one-commit repo failed into an empty set). # * two-dot fallback REMOVED. It masked a failing three-dot diff as "no changes". # * unresolvable base exit 3 (was: HEAD~1 or empty). # * diff command fails exit 3 (was: empty set). # * HEAD not ahead of exit 3 (was: exit 0 with an empty set — this is the case # the base that produced the false green: running the gate while # sitting on `main`, or on a branch already merged). # # Exit 3 is the ci/README.md "SKIPPED / not-run" code: NOT a pass, NOT a fail. Callers # (ci/gate.sh) must surface it as an UNDECIDED merge decision. An honest, noisy skip # beats a quiet false pass. # # Order of preference: BASE_REF (explicit) > GITHUB_BASE_REF (Actions PR runs, target # branch) > origin/main > main. Each candidate is VERIFIED to resolve to a commit # before it is used, so a shallow checkout that lacks origin/main is reported, not # papered over. : "${BASE_REF:=}" BASE="" # resolved base ref BASE_SOURCE="" # where it came from (for the log line) BASE_PROBLEM="" # why resolution failed, when it did rev() { git -C "$REPO_ROOT" rev-parse --verify -q "$1^{commit}" 2>/dev/null; } # Loud, structured refusal. Prints to stderr only; stdout stays empty so a caller that # captures stdout gets NO requirement list rather than a silently-empty one. # # $1 is a machine-readable REASON CODE, also written to $TOUCHED_REASON_FILE when that # env var names a path. ci/gate.sh uses it to tell the two cases apart: # on-base HEAD is the base branch (or already merged) — nothing to gate. Legitimate # (a push-to-main CI run lands here), so the gate reports NOT APPLICABLE # rather than PASS, and does not fail the job. # no-base no reliable base ref exists in this checkout — the ENVIRONMENT is broken, # diff-failed or git could not produce the diff. A merge decision is impossible, so the # gate BLOCKS rather than guessing. undecidable() { local code="$1"; shift if [ -n "${TOUCHED_REASON_FILE:-}" ]; then printf '%s\n' "$code" > "$TOUCHED_REASON_FILE"; fi err "TOUCHED-REQUIREMENT SET NOT DETERMINABLE (${code})" err " reason: $*" err " consequence: the R-TEST-03 coverage gate CANNOT be applied to this checkout." err " This is exit 3 (SKIPPED / not-run), never a pass — see ci/README.md." err " Fix one of:" err " * check out the feature branch you mean to gate (not the base branch), or" err " * git fetch origin main (so origin/main exists and is current), or" err " * BASE_REF= make -C ci gate to name the base explicitly." exit 3 } resolve_base() { local cand if [ -n "$BASE_REF" ]; then if rev "$BASE_REF" >/dev/null; then BASE="$BASE_REF"; BASE_SOURCE="BASE_REF"; return 0; fi BASE_PROBLEM="BASE_REF='${BASE_REF}' does not resolve to a commit in ${REPO_ROOT}" return 1 fi if [ -n "${GITHUB_BASE_REF:-}" ]; then for cand in "origin/${GITHUB_BASE_REF}" "${GITHUB_BASE_REF}"; do if rev "$cand" >/dev/null; then BASE="$cand"; BASE_SOURCE="GITHUB_BASE_REF"; return 0; fi done BASE_PROBLEM="GITHUB_BASE_REF='${GITHUB_BASE_REF}' is set, but neither 'origin/${GITHUB_BASE_REF}' nor '${GITHUB_BASE_REF}' exists here (shallow checkout? the workflow needs fetch-depth: 0)" return 1 fi for cand in origin/main main; do if rev "$cand" >/dev/null; then BASE="$cand"; BASE_SOURCE="default"; return 0; fi done BASE_PROBLEM="no base ref resolves (tried origin/main, main) in ${REPO_ROOT}" return 1 } # Both diff helpers assume BASE is already resolved and validated by main(). They return # git's exit status so the CALLER can distinguish "no changes" from "the diff failed" — # conflating those two is exactly what produced the false green. changed_files() { # Three-dot: changes on this branch since it diverged from base. git -C "$REPO_ROOT" diff --name-only "${BASE}...HEAD" } # The ADDED/REMOVED lines of the change, for the R-ID content scan — excluding # documentation. # # THREE scoping rules, all learned from the gate blocking merges it should not have: # # 1. Scan the DIFF, not whole file contents. Grepping whole files meant a change # inherited every requirement those files had EVER mentioned, on lines the author # never touched. Since some of those are legitimately uncovered, `--require-all` # then failed and BLOCKED the merge over unrelated prose. # # 2. Skip `*.md`. A requirement ID in prose is a REFERENCE, not a declaration — # explaining a requirement is not the same as changing behaviour against it. # Without this, the decision log cannot record which requirements a change # concerned without thereby claiming to have touched them, and the CI-fix commit # that explains a mis-attribution re-triggers the very mis-attribution it fixes. # Nothing escapes: authoritative declarations (`conformance.home.k3s/R-*` tags, # Kyverno policies, ServiceDefinitions) all live in YAML, and the doc subtrees # that matter (conformance/, schemas/, catalog/, clusters/, ci/) are covered by # the path->requirement fallback regardless of file type. # # 3. Skip COMMENT-ONLY lines in non-Markdown files — rule 2's logic, applied to the # prose that lives INSIDE code files. A requirement ID cited in a YAML/shell `#` # comment is documentation, not a declaration, exactly like an ID in a `*.md` file. # Rule 2 drops whole Markdown files, but a `.gitea/workflows/*.yml` or `ci/*.sh` # HEADER that explains a requirement would otherwise re-attribute it. Concretely: # gate.yml's header cites R-TEST-06 (enforced by the spec-immutability STAGE, not by # any Chainsaw test, so a known-uncovered ID); before this rule, editing that comment # re-attributed R-TEST-06 and the coverage `--require` check false-blocked the merge — # the same self-referential trap rule 2 fixed for `*.md`. Applied in main()'s content # scan (see there): after the diff marker is stripped, a line whose first non-blank # character is `#` is dropped BEFORE the R-ID grep. Deliberately conservative — only # WHOLE-LINE comments are dropped; an R-ID on a real code/spec line is still detected # (authoritative declarations — test tags, policies, ServiceDefinitions — are never a # bare comment), so this only narrows PROSE, never a declaration. # # Removed lines count: deleting a test's requirement tag, or a policy's self-declared # ID, touches that requirement just as much as adding one. # `:(exclude)*.md` drops Markdown from the content scan (rule 2 above). # `:(exclude)ci/tests/` drops this dir's OWN unit-test fixtures: those files embed example # R-IDs as DATA (asserting the scan's behaviour), never as declarations — the same # "reference, not declaration" logic as rule 2. Their gate coverage is unaffected: any # ci/ change (ci/tests/ included) still attributes R-GITOPS-01/R-TEST-02/R-TEST-03 via the # path->requirement map below. NOTE: this excludes ci/tests/ only — conformance/tests/ # carry AUTHORITATIVE `conformance.home.k3s/R-*` labels and are deliberately still scanned. # Three-dot to match changed_files(): changes on this branch since it diverged from base. changed_diff() { local -a excl=(':(exclude)*.md' ':(exclude)ci/tests/') git -C "$REPO_ROOT" diff -U0 "${BASE}...HEAD" -- . "${excl[@]}" } # Coarse path -> requirement map. Deliberately conservative; extend as subtrees # fill in. Left side is a path prefix (relative to repo root). path_requirements() { case "$1" in catalog/*) echo "R-GITOPS-02" ;; schemas/*) echo "R-GITOPS-02" ;; platform/kyverno/*) echo "R-TEST-01 R-NET-01 R-SEC-01" ;; platform/cilium/*) echo "R-NET-01 R-NET-02 R-NET-05" ;; platform/longhorn/*) echo "R-STATE-01 R-STATE-02" ;; platform/cert-manager/*) echo "R-TLS-01 R-TLS-02" ;; platform/externaldns/*) echo "R-DNS-02" ;; platform/velero/*) echo "R-STATE-03" ;; platform/monitoring/*) echo "R-OBS-01 R-OBS-02" ;; # platform/cnpg was UNMAPPED until 2026-09-06. It is the PostgreSQL DR path for keycloak-db / # nextcloud-db / semaphore-db (barmanObjectStore WAL archiving + ScheduledBackup), so a change # there is an R-IDP-05 / R-STATE-03 change; it now also carries the platform-health alert rules # for that path (R-OBS-04). Without this line an edit under platform/cnpg/ contributed ZERO # requirements unless it happened to retype an R-ID on a non-comment line, and escaped the gate. platform/cnpg/*) echo "R-IDP-05 R-STATE-03 R-OBS-04" ;; # vpn-egress was UNMAPPED until 2026-09-06. platform/vpn-egress/egress-steering.yaml IS the # R-NET-04 datapath (the rule that selects between the two egress paths) and the R-NET-05/06/07 # steering policy — it even self-declares those IDs in its `homelab.keithjackson.dev/requirements` # annotation. But the content scan only reads ADDED/REMOVED lines, so any edit that does not # happen to retype an R-ID (a selector change, an excludedCIDRs change, a comment) contributed # ZERO requirements and escaped the gate entirely. Mapped explicitly; the annotation still wins # when a diff does carry the tags. platform/vpn-egress/*) echo "R-NET-04 R-NET-05 R-NET-06 R-NET-07" ;; # Keycloak is the whole SSO surface: the realm import (declarative identity config), # the SOPS client secrets, and the reconciler that heals them. Added 2026-09-06 after # the client-secret incident, in which editing platform/keycloak/ attributed NO # requirement, so the L2 keycloak tests were filtered out of the merge gate's touched # subset — the change that could break every OIDC login was gated by nothing. # R-IDP-05 is in scope because platform/keycloak/ also carries the CNPG DB Cluster and # its off-cluster backup credential, not only the realm. platform/keycloak/*) echo "R-IDP-01 R-IDP-02 R-IDP-03 R-IDP-04 R-IDP-05 R-IDP-06" ;; platform/oauth2-proxy/*) echo "R-IDP-02 R-IDP-06" ;; platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;; conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;; clusters/*) echo "R-GITOPS-01 R-ENV-02" ;; # bootstrap/ was UNMAPPED until 2026-09-06, and the omission was load-bearing: # bootstrap/standup.sh carries the placeholder guards that decide whether `velero` may be # enabled against a real backup endpoint. A change touching standup.sh ALONGSIDE any # already-mapped path scoped the L0 run to that other path's requirements and silently # excluded conformance/tests/l0-static/placeholder-guard-coupling — the check that exists # precisely to catch a guard/value mismatch. The 2026-09-05 guard bug (a prefix glob that # stopped matching when the value it guards changed, in a different file) is exactly the # class that omission would hide. bootstrap/*) echo "R-STATE-03 R-GITOPS-01" ;; .gitea/*|ci/*) echo "R-GITOPS-01 R-TEST-02 R-TEST-03" ;; *) : ;; esac } main() { local files reqs="" rc=0 base_sha head_sha local_main merge_base resolve_base || undecidable no-base "$BASE_PROBLEM" base_sha="$(rev "$BASE")" || undecidable no-base "resolved base '${BASE}' vanished" head_sha="$(rev HEAD)" || undecidable no-base "HEAD does not resolve to a commit (empty repo?)" log "diff base: ${BASE} (${BASE_SOURCE}) @ ${base_sha:0:12} ... HEAD @ ${head_sha:0:12}" # Advisory: a never-fetched origin/main silently WIDENS the set (over-broad, never a # false green) — surfaced so an unexpectedly large touched set has an explanation. if [ "$BASE" = "origin/main" ] && local_main="$(rev main)" && [ -n "$local_main" ] \ && [ "$local_main" != "$base_sha" ]; then warn "local 'main' (${local_main:0:12}) != origin/main (${base_sha:0:12}) — if origin/main" warn " was not fetched recently the touched set may be stale: git fetch origin main" fi merge_base="$(git -C "$REPO_ROOT" merge-base "$BASE" HEAD 2>/dev/null)" \ || undecidable no-base "no merge base between '${BASE}' and HEAD (unrelated histories, or a shallow clone)" if [ "$merge_base" = "$head_sha" ]; then undecidable on-base "HEAD is not ahead of '${BASE}' (merge-base == HEAD): this checkout contains no branch commits to gate. The empty touched set would be empty BY DEFAULT, not by verification." fi files="$(changed_files)" || rc=$? [ "$rc" -eq 0 ] || undecidable diff-failed "git diff --name-only ${BASE}...HEAD failed (rc=${rc})" if [ -z "$files" ]; then warn "HEAD is ahead of ${BASE} but the three-dot diff is empty (self-cancelling commits?)" warn " -> genuinely empty touched-requirement set; base WAS established." exit 0 fi while IFS= read -r f; do [ -n "$f" ] || continue reqs+=" $(path_requirements "$f")" done <<< "$files" # Self-declared R-IDs on the ADDED/REMOVED lines of the change (see changed_diff). # `^[+-]` keeps hunk headers (`@@`) and the `+++`/`---` file headers out of scope. # `sed 's/^[+-]//'` then strips the single-char diff marker (safe: -U0 emits one), so # what remains is the SOURCE line, and `grep -vE '^[[:space:]]*#'` drops whole-line # YAML/shell comments (rule 3 in changed_diff's header) BEFORE the R-ID grep — a # requirement cited only in a `#` comment is prose, not a declaration. local diff_txt diff_txt="$(changed_diff)" || rc=$? [ "$rc" -eq 0 ] || undecidable diff-failed "git diff -U0 ${BASE}...HEAD failed (rc=${rc})" reqs+=" $(printf '%s\n' "$diff_txt" \ | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | sed -E 's/^[+-]//' \ | grep -vE '^[[:space:]]*#' \ | grep -hoE 'R-[A-Z]+-[0-9]+' \ | tr '\n' ' ' || true)" # Normalize: split, unique, sort. Emit to stdout. printf '%s\n' $reqs | sed '/^$/d' | sort -u } main "$@"