#!/usr/bin/env bash # ci/lib.sh — shared helpers for the merge-gate scripts. # Sourced by every ci/*.sh; never executed directly. # # Conventions: # - All scripts: `set -euo pipefail`. # - Log to stderr; machine-readable output (e.g. requirement lists) to stdout. # - Exit codes: 0 = pass, 1 = fail (gate blocks), 3 = SKIPPED/not-run # (cluster-gated stage with no cluster — NOT a pass, NOT a fail). set -euo pipefail # --- Paths ------------------------------------------------------------------- # CI_DIR = this ci/ directory (always the real script location). REPO_ROOT = its # parent (impl repo root) by default, but OVERRIDABLE via the environment so the # scripts can be pointed at a throwaway git repo under test (see ci/tests/) without # copying them out of the tree. CI and normal runs leave it unset -> unchanged. CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" : "${REPO_ROOT:=$(cd "${CI_DIR}/.." && pwd)}" # shellcheck disable=SC1091 source "${CI_DIR}/versions.env" # --- Integration points (other agents' entrypoints) -------------------------- # These are the seams this gate depends on. Each has an env override so the PM / # owning agent can reconcile the real entrypoint at integration WITHOUT editing # these scripts. Defaults are this workstream's *assumptions* — see README. : "${CATALOG_DIR:=${REPO_ROOT}/catalog}" # ServiceDefinition sources : "${SCHEMAS_DIR:=${REPO_ROOT}/schemas}" # owned by T-0.3 (schema + renderer contract) : "${KYVERNO_POLICY_DIR:=${REPO_ROOT}/platform/kyverno}" # owned by T-0.5 (L0 Kyverno policies + tests) : "${CONFORMANCE_DIR:=${REPO_ROOT}/conformance}" # owned by T-0.4 (Chainsaw harness + coverage) # Commands (overridable). Defaults documented in ci/README.md. : "${SCHEMA_VALIDATE_CMD:=${SCHEMAS_DIR}/validate.sh}" # ASSUMED: validates catalog/*.yaml against the ServiceDefinition schema : "${CONFORMANCE_CMD:=${CONFORMANCE_DIR}/run.sh}" # ASSUMED: single-command conformance runner (R-TEST-02) # --- Artifact locations (git-ignored: see .gitignore) ------------------------ : "${RESULTS_DIR:=${REPO_ROOT}/results}" # JUnit XML lands here : "${REPORTS_DIR:=${REPO_ROOT}/reports}" # requirement-coverage report lands here : "${JUNIT_DIR:=${RESULTS_DIR}/junit}" : "${COVERAGE_DIR:=${REPORTS_DIR}/coverage}" # --- Logging ----------------------------------------------------------------- _is_tty() { [ -t 2 ]; } if _is_tty; then C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' else C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""; C_DIM=""; C_RST=""; fi log() { printf '%s[gate]%s %s\n' "$C_BLU" "$C_RST" "$*" >&2; } ok() { printf '%s[ ok ]%s %s\n' "$C_GRN" "$C_RST" "$*" >&2; } warn() { printf '%s[warn]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } err() { printf '%s[fail]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; } skip() { printf '%s[skip]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } die() { err "$*"; exit 1; } # Emit a GitHub/Gitea-Actions step-summary line if running in Actions. summary() { [ -n "${GITHUB_STEP_SUMMARY:-}" ] && printf '%s\n' "$*" >> "$GITHUB_STEP_SUMMARY" || true; } # --- Tooling ----------------------------------------------------------------- have() { command -v "$1" >/dev/null 2>&1; } # require_tool — fail with an actionable message if missing. require_tool() { have "$1" && return 0 die "required tool '$1' not found. $2" } # entrypoint_ready — returns 0 only if it exists AND is runnable. # Used to distinguish 'other agent hasn't delivered yet / non-executable stub' # (-> SKIP) from a wired entrypoint that genuinely fails (-> FAIL). A file that # exists but isn't executable counts as NOT ready (unwired), not a failure. entrypoint_ready() { local ep="$1" have "$ep" && return 0 # a bare command on PATH [ -f "$ep" ] && [ -x "$ep" ] && return 0 # an executable file return 1 } # truthy — the ONE place the repo's "is this switch on" spelling lives, so # CLUSTER_AVAILABLE / REQUIRE_CLUSTER_TESTS / REQUIRE_SPEC_VERIFICATION / GATE_STRICT_SKIPS # all agree. Anything not listed is OFF, and that direction matters: a misspelled value # (CLUSTER_AVAILABLE=ture, REQUIRE_SPEC_VERIFICATION=yeah) leaves the guard OFF rather than # silently declaring a capability "available", so a typo can only make the gate stricter about # what it claims to have verified, never looser. truthy() { case "${1:-}" in true|TRUE|True|1|yes|YES|y|on|ON) return 0 ;; *) return 1 ;; esac } # --- Cluster detection (drives SKIP vs run for L2/L3) ------------------------- # A cluster is 'available' only if explicitly DECLARED. We NEVER auto-assume a # cluster in Phase 0 — silence means SKIP, not pass (HARD RULE 4). # CLUSTER_AVAILABLE=true -> L2/L3 run and are REQUIRED green # CLUSTER_AVAILABLE=false/unset -> L2/L3 SKIP (reported, never faked green) # That DECLARATION is what makes an L2/L3 skip different IN KIND from every other skip — # see "WHICH SKIPS BLOCK, AND WHY" in ci/gate.sh. cluster_available() { truthy "${CLUSTER_AVAILABLE:-false}"; } # --- Skip strictness: is this a CI runner, or a workstation? ------------------- # A SKIPPED stage is never green anywhere (HARD RULE 4), but its SEVERITY differs: # # CI the pinned toolchain is installed by .gitea/workflows/scripts/install-tools.sh # before the gate runs, and every seam is on `main`. A stage that skips here is a # DEFECT (a tool dropped from the installer, a seam that went unwired) -> BLOCK. # local a missing pinned tool is a real, ordinary local condition -> the gate reports # UNDETERMINED (exit 3): not green, not a pass, but distinguishable from a red. # # HOW STRICTNESS IS DECIDED — DECLARED first, detected only as a fallback: # # 1. GATE_STRICT_SKIPS=true|false is the AUTHORITY. .gitea/workflows/gate.yml sets `true` on # the gate step, so on the one CI runner that matters strictness is a DECLARATION IN THE # WORKFLOW, not an inference: the environment that KNOWS it is CI says so, and no # env-var sniffing can get it wrong. # 2. `auto` (the default — anywhere the caller said nothing) falls back to GITHUB_ACTIONS / # CI, which are set by the runner ITSELF (Gitea's act_runner, as well as GitHub). We do # NOT sniff hostnames, paths or tool availability: a heuristic that guesses "this looks # like CI" is exactly the unreliable inference this gate must not contain. # # WHY A WRONG ANSWER CANNOT FAIL OPEN: both branches are non-zero and NEITHER is a pass. A CI # run misread as local yields UNDETERMINED (exit 3) instead of BLOCKED (exit 1) — that still # fails the job unless its caller explicitly tolerates 3, which gate.yml does only knowingly # and only for the one declared-environmental skip (see its comment). A workstation misread as # CI yields BLOCKED instead of UNDETERMINED — harsher, never looser. Set GATE_STRICT_SKIPS=true # locally to rehearse CI strictness. : "${GATE_STRICT_SKIPS:=auto}" strict_skips() { case "$GATE_STRICT_SKIPS" in auto) truthy "${GITHUB_ACTIONS:-}" || truthy "${CI:-}" ;; *) truthy "$GATE_STRICT_SKIPS" ;; esac } mkartifacts() { mkdir -p "$JUNIT_DIR" "$COVERAGE_DIR"; } # --- Offline (L0/L1) per-check step summary ----------------------------------- # conformance/lib/run_l1_offline.py writes one Markdown line per check it executed to # conformance/_artifacts/offline-summary.md. Splice those into $GITHUB_STEP_SUMMARY so the # gate's output still NAMES every check that ran and its verdict. # # WHY THIS EXISTS: ci/l0.sh used to carry a hand-written stage per whole-repo check, each with # its own `summary "- subst-safety: **passed**"` line. Those stages are gone (the tests declare # `conformance.home.k3s/scope: whole-repo` instead and the runner unions them in), so the # naming is generated for EVERY offline check rather than for the three that had a stage. : "${OFFLINE_SUMMARY_FILE:=${CONFORMANCE_DIR}/_artifacts/offline-summary.md}" emit_offline_summary() { local f="${1:-$OFFLINE_SUMMARY_FILE}" line [ -f "$f" ] || return 0 while IFS= read -r line; do [ -n "$line" ] && summary " $line" done < "$f" return 0 } # --- Touched-requirement subset (R-TEST-03 input) ---------------------------- # Sets: TOUCHED_RC (0 ok / 3 not determinable), TOUCHED_REQS[] and REQ_ARGS[] # (ready to splice into a conformance/run.sh invocation). # # ci/touched-requirements.sh exits 3 — LOUDLY — when it cannot establish a reliable # diff base, instead of the old silent degradation to an empty set. An empty set # means run.sh gets no `--requirements`, which means its coverage `--require` gate # never runs; treating "could not determine" as "nothing touched" is precisely the # false green this helper exists to prevent. A stage still runs its FULL layer in # that case (a superset, so nothing goes untested), but says plainly that the # SCOPED check did not happen; ci/gate.sh turns that into an UNDECIDED decision. load_touched_requirements() { local out rc=0 out="$("${CI_DIR}/touched-requirements.sh")" || rc=$? TOUCHED_RC=$rc TOUCHED_REQS=() # `if`, not `[ ... ] && ...`: under `set -e` a false AND-list is the caller's last # command status and would abort the stage script on an EMPTY touched set. if [ -n "$out" ]; then mapfile -t TOUCHED_REQS <<< "$out"; fi REQ_ARGS=() if [ "$rc" -eq 3 ]; then warn "touched-requirement subset UNDETERMINED (reason logged above)." warn " Running the FULL layer as a superset sanity net; the SCOPED R-TEST-03" warn " coverage check is NOT performed here — ci/gate.sh reports that as UNDECIDED." elif [ "$rc" -ne 0 ]; then # Any OTHER non-zero exit means the subset is UNUSABLE, not empty (today: a # path->requirement map that cannot classify a changed path, once that map fails closed). # The FULL layer still runs here as a superset — nothing goes untested — but this stage must # not claim the SCOPED check happened, and ci/gate.sh turns the same exit into a BLOCKED # merge decision. Wording carried over from the unlanded fix/touched-requirements-fail-closed # (47068ee), whose gate-side half this branch lands. warn "touched-requirements.sh exited ${rc} (subset UNUSABLE — see its output above)." warn " Running the FULL layer as a superset; the SCOPED R-TEST-03 check did NOT happen." warn " ci/gate.sh BLOCKS the merge on this: fix the path->requirement map in" warn " ci/touched-requirements.sh so every changed path is classified." elif [ "${#TOUCHED_REQS[@]}" -gt 0 ]; then log "touched requirements: ${TOUCHED_REQS[*]}" REQ_ARGS=(--requirements "$(IFS=,; echo "${TOUCHED_REQS[*]}")") else log "empty touched-requirement set (base WAS established) — running the full layer." fi return 0 }