#!/usr/bin/env bash # ci/tests/gate-decision.test.sh — the merge gate's OWN decision table. # # WHY THIS SUITE EXISTS (2026-09-13). ci/gate.sh used to set BLOCK=1 for a FAILED stage but # only `warn` for a SKIPPED one, so it printed the words "— not green" and then concluded # `MERGE GATE: PASS` and exited 0. Observed live twice: `prom-rules` skipped for a missing # promtool, and `l0` skipped for a missing pinned kyverno CLI — both read by automation as # merge-approved. That is fail-open in the one component whose entire job is to fail closed, # and nothing tested it: every other ci/tests suite covers the INPUT (touched-requirements), # none covered the DECISION. # # WHAT IS UNDER TEST: the real ci/gate.sh and ci/lib.sh, copied into a throwaway CI_DIR beside # STUB stage scripts whose exit codes this suite dictates (STAGE_RC_) and a stub # touched-requirements.sh whose rc/reason it dictates (TR_RC / TR_REASON / TR_OUT). Stubbing the # stages is the point: this asserts how the gate REASONS about pass/fail/skip, independently of # what any stage happens to do on this machine — so it runs anywhere, with no tools, no cluster, # no network, in milliseconds. # # THE INVARIANT: `MERGE GATE: PASS` + exit 0 means every stage that was supposed to run RAN and # was GREEN. A stage that could not run is never a pass, and the exit code says so too. # # Run: ci/tests/gate-decision.test.sh (or `make -C ci test`) set -uo pipefail REAL_CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PASS=0 FAIL=0 ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); } bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); } TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT CI="${TMP}/ci"; mkdir -p "$CI" REPO="${TMP}/repo"; mkdir -p "$REPO" cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$CI/" # One stub per stage. It exits with STAGE_RC_, default 0. # NOTE: this list must name EVERY stage gate.sh runs. It did not: `gitea-settings` was added to # gate.sh and never added here, so its script was absent from the stub CI dir, `run_stage` got # exit 127 from the missing file, and the gate recorded a FAILED stage in every single case — # 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a # PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the # decision logic. `decisions-log` is added here in the same change that adds it to gate.sh. for stage in spec-immutability decisions-log lint l0 render-golden l1 gitea-settings conformance; do cat > "${CI}/${stage}.sh" <<'STUB' #!/usr/bin/env bash n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}" exit "${!v:-0}" STUB chmod +x "${CI}/${stage}.sh" done # Stub decision input. TR_RC (default 0), TR_REASON (written to $TOUCHED_REASON_FILE like the # real script does), TR_OUT (the touched requirement list on stdout). cat > "${CI}/touched-requirements.sh" <<'STUB' #!/usr/bin/env bash if [ -n "${TOUCHED_REASON_FILE:-}" ]; then printf '%s\n' "${TR_REASON:-}" > "$TOUCHED_REASON_FILE"; fi [ -n "${TR_OUT:-}" ] && printf '%s\n' "$TR_OUT" exit "${TR_RC:-0}" STUB chmod +x "${CI}/touched-requirements.sh" # run_gate ... — run the gate with a clean, explicit environment. Sets OUT and RC. # GATE_STRICT_SKIPS is pinned false unless a case says otherwise, so the suite never depends on # whether the machine running it happens to look like CI. OUT="" RC=0 run_gate() { local summary_file="${TMP}/summary.md"; : > "$summary_file" OUT="$(env -i PATH="$PATH" HOME="$HOME" \ REPO_ROOT="$REPO" GITHUB_STEP_SUMMARY="$summary_file" \ GATE_STRICT_SKIPS=false TR_OUT="R-TEST-03" \ "$@" bash "${CI}/gate.sh" 2>&1)" RC=$? SUMMARY="$(cat "$summary_file")" } # assert_verdict