fix(ci): declare whole-repo scope instead of hand-writing a gate stage per invariant
R-TEST-03 runs the conformance subset a change TOUCHES. That has one blind spot, and it
is the worst-shaped kind: an invariant that quantifies over the whole tracked tree — or
over subtrees ci/touched-requirements.sh's coarse path map keys to NOTHING (apps/, infra/,
most of platform/) — cannot be selected BY the requirements a change touches, because the
change most likely to break it attributes no requirement at all. Requirement-scoped
selection then filters the check out of exactly that change.
The repo's answer had been a hand-written "UNCONDITIONAL" stage per such check in
ci/l0.sh: 3c flux-postbuild-substitution, 3d keycloak-client-secret-reconciler, 3e
bulk-tier-distinct-from-replicated. Three near-identical 12-line rationales, three
hardcoded check.sh paths, three copies of the same rc 0/3/* case block — and worst, a
mechanism that failed SILENTLY, because whoever added the NEXT whole-repo invariant had
to REMEMBER to write a stage, and if they forgot the check ran only when some unrelated
requirement happened to be touched.
A test now DECLARES the scope of its invariant and the runner enforces it:
metadata:
annotations:
conformance.home.k3s/scope: "whole-repo"
* conformance/lib/common.sh conf_dirs_for_scope(s) resolves the annotation. Carries the
general rationale, once, instead of per stage.
* conformance/run.sh --include-scope SCOPE unions declaring tests into select_dirs,
still bounded by --layer. New --print-selection resolves the
selection and exits, running nothing.
* lib/run_l1_offline.py --unwired-scope: a SKIP of a declaring test exits 3, not 0.
--summary-out: one step-summary line per executed check.
* ci/l0.sh Stage 3b gains --include-scope whole-repo and an rc-3 arm.
Stages 3c/3d/3e DELETED (-150 lines).
* ci/l1.sh, ci/conformance.sh pass --include-scope too, so the next whole-repo invariant
at ANY layer needs only the annotation.
rc 3 SEMANTICS PRESERVED EXACTLY. A whole-repo check reporting "nothing to audit" is
reporting that the tree it audits has VANISHED. The deleted stages called that "not a
pass" and set the gate's rc=3; the runner now returns 3 and ci/l0.sh maps it to rc=3.
A missing check file still degrades to not-green, never to silence.
THE REASONING WAS RELOCATED, NOT DELETED. Each stage's incident narrative moved into the
header of the test it describes, beside its scope annotation: the Cilium k8sServiceHost
empty-substitution CNI outage (2026-09-01), 12 unusable Keycloak clients behind a green
cluster (2026-09-06), and a multi-TB bulk volume landing on the 3-replica tier.
AUDITED ALL 13 EXISTING l0-static TESTS, individually. 10 declare whole-repo scope; 3 are
DELIBERATELY left requirement-scoped and say so in their own headers:
* placeholder-guard-coupling — inputs are bootstrap/ + clusters/, both already mapped to
a requirement it declares. Scoping cannot hide a breaking change.
* dev-nodes-provisioned-as-code — inputs are infra/ and nothing else.
* spec-immutability-enforced — inputs are ci/ and the read-only spec repo.
The latter two were nonetheless under-gated because the PATH MAP was incomplete, which is
the right place to fix a path-scoped invariant: infra/* -> R-ENV-01 added, and R-TEST-06
added to .gitea/*|ci/* (it is no longer an uncovered ID — spec-immutability-enforced
carries the label, so attributing it no longer false-blocks the coverage gate).
NEW conformance/tests/l0-static/whole-repo-invariants-wired (R-TEST-03) keeps the
declarative mechanism honest for ALL declaring tests at once, replacing the three tests'
individual "ci/l0.sh greps my own check.sh path" self-assertions. It proves the union
EMPIRICALLY: with a requirement filter matching no test the L0 selection is empty, and
with --include-scope it is every declaring test; a scope-declared synthetic SKIP exits 3
while an undeclared one exits 0; and a ci/*.sh that hardcodes a check.sh path must belong
to a test that also declares the scope, so the bespoke pattern cannot creep back.
TASK 2 — a changed test attributes the requirements it CERTIFIES.
ci/touched-requirements.sh gains detection rule 3: a change anywhere under
conformance/tests/<test>/ attributes that test's own conformance.home.k3s/R-* labels, read
from its chainsaw-test.yaml. The content scan could not close this — it reads only
added/removed lines, so it sees a change to the LABEL BLOCK but not to the test BODY,
which is exactly the case that matters. Labels are read from HEAD or, failing that, from
the BASE, so DELETING a test still attributes what it used to certify; otherwise removing
the only verifier of a requirement is the cheapest way to make the gate stop checking it.
Whole-line `#` comments are dropped first and *.md is untouched, honouring the existing
"reference, not declaration" rules.
Both changes are fail-closed: every one of them can only ADD tests or requirements to a
run, never remove one.
TESTS: new ci/tests/touched-requirements-test-attribution.test.sh (15 assertions) covers
rule 3 — body-only edit, nested-fixture walk-up, test deletion, header-comment R-IDs not
attributed, non-test paths unaffected, and both new path-map entries. The two existing
suites still pass (39 assertions). `make -C ci test` runs all three.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
78d76fa089
commit
5fa278f61c
29 files changed
+1083
-165
No files matched your search
+82
-10
@@ -10,6 +10,12 @@
|
||||
# ./run.sh --requirements R-NET-01,R-NET-06 # merge-gate subset (tagged)
|
||||
# ./run.sh --layer l2 # one layer (l0|l1|l2|l3|meta|all; l0/l1 need no cluster)
|
||||
# ./run.sh --requirements R-NET-01,R-NET-02 --layer l2 # intersect: tagged AND in layer
|
||||
# ./run.sh --layer l0 --requirements R-X --include-scope whole-repo
|
||||
# # UNION: the tagged subset PLUS every test that
|
||||
# # DECLARES `conformance.home.k3s/scope: whole-repo`.
|
||||
# # A whole-repo invariant must run on EVERY gate
|
||||
# # invocation — see lib/common.sh's scope block for
|
||||
# # why, and why a SKIP of one is exit 3, not green.
|
||||
# ./run.sh --coverage-only # regenerate coverage report only (no cluster)
|
||||
# ./run.sh --status # DERIVED STATUS report only (no cluster): per
|
||||
# # requirement, is it implemented, is it actually
|
||||
@@ -18,6 +24,11 @@
|
||||
# # (runs/ledger.jsonl) actually proves it — see
|
||||
# # lib/status.py. No ledger row => not-derivable.
|
||||
# ./run.sh --lint # structural-validate test defs (no cluster)
|
||||
# ./run.sh --print-selection # print the test dirs the CURRENT selection resolves
|
||||
# # to, one per line, and exit. Runs no check. This is
|
||||
# # how a test can PROVE the selection includes it
|
||||
# # (tests/l0-static/whole-repo-invariants-wired) without
|
||||
# # executing the suite and recursing into itself.
|
||||
# ./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
|
||||
# ./run.sh --requirements R-NET-01 # ALSO asserts those IDs are covered (merge gate)
|
||||
# ./run.sh --require-all # phase-exit: every requirement must be covered
|
||||
@@ -79,6 +90,7 @@ ORIG_ARGS=("$@")
|
||||
|
||||
LAYER="all"
|
||||
REQS=""
|
||||
INCLUDE_SCOPES="" # --include-scope: scopes unioned into the selection (see lib/common.sh)
|
||||
MODE="run" # run | coverage-only | status | lint
|
||||
STRICT_COVERAGE=0
|
||||
REQUIRE_ALL=0
|
||||
@@ -122,9 +134,11 @@ while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--layer) LAYER="$2"; shift 2 ;;
|
||||
--requirements) REQS="$(conf_normalize_reqs "$2")"; shift 2 ;;
|
||||
--include-scope) INCLUDE_SCOPES="${INCLUDE_SCOPES:+${INCLUDE_SCOPES},}$2"; shift 2 ;;
|
||||
--coverage-only) MODE="coverage-only"; shift ;;
|
||||
--status) MODE="status"; shift ;;
|
||||
--lint) MODE="lint"; shift ;;
|
||||
--print-selection) MODE="print-selection"; shift ;;
|
||||
--strict-coverage) STRICT_COVERAGE=1; shift ;;
|
||||
--require-all) REQUIRE_ALL=1; shift ;;
|
||||
--no-cluster) FORCE_NO_CLUSTER=1; shift ;;
|
||||
@@ -251,11 +265,38 @@ lint_tests() {
|
||||
}
|
||||
|
||||
# --- Select which test directories Chainsaw should run --------------------------------
|
||||
# Requirement-scoped selection, then the DECLARED-SCOPE union on top:
|
||||
#
|
||||
# (--requirements ∩ --layer) ∪ (--include-scope ∩ --layer)
|
||||
#
|
||||
# The left half is R-TEST-03's touched subset. The right half is the fix for its blind
|
||||
# spot: an invariant that quantifies over the whole tracked tree cannot be selected BY the
|
||||
# requirements a change touches, because the change most likely to break it touches none of
|
||||
# them. See lib/common.sh's scope block for the incidents. The union is fail-closed by
|
||||
# construction — it can only ADD tests to a run, never remove one.
|
||||
select_dirs() {
|
||||
# --requirements AND --layer together => intersection (tests carrying the tag
|
||||
# AND living in that layer subtree). Either alone keeps its prior meaning, so
|
||||
# this is backward compatible (LAYER defaults to "all"). Both helpers emit
|
||||
# sorted, de-duplicated dirs, so `comm -12` yields the intersection.
|
||||
{
|
||||
requirement_scoped_dirs
|
||||
# The scope union is still LAYER-bounded: `--layer l0` must not pull a whole-repo-scoped
|
||||
# L2 test into an offline run. With no --layer (all), nothing is bounded away.
|
||||
if [ -n "${INCLUDE_SCOPES}" ]; then
|
||||
if [ "${LAYER}" != all ]; then
|
||||
comm -12 \
|
||||
<(conf_dirs_for_scopes "${INCLUDE_SCOPES}") \
|
||||
<(conf_dirs_for_layer "${LAYER}")
|
||||
else
|
||||
conf_dirs_for_scopes "${INCLUDE_SCOPES}"
|
||||
fi
|
||||
fi
|
||||
} | sort -u
|
||||
}
|
||||
|
||||
# The pre-existing requirement/layer selection, unchanged:
|
||||
# --requirements AND --layer together => intersection (tests carrying the tag
|
||||
# AND living in that layer subtree). Either alone keeps its prior meaning, so
|
||||
# this is backward compatible (LAYER defaults to "all"). Both helpers emit
|
||||
# sorted, de-duplicated dirs, so `comm -12` yields the intersection.
|
||||
requirement_scoped_dirs() {
|
||||
if [ -n "${REQS}" ] && [ "${LAYER}" != all ]; then
|
||||
comm -12 \
|
||||
<(conf_dirs_for_requirements "${REQS}") \
|
||||
@@ -396,6 +437,12 @@ layers_are_offline_only() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Markdown one-liner per executed offline check, for the CI step summary. Written next to
|
||||
# the JUnit; ci/l0.sh + ci/l1.sh splice it into $GITHUB_STEP_SUMMARY so the gate output still
|
||||
# NAMES every check that ran (that is what the deleted per-check gate stages did, one hard-coded
|
||||
# `summary` line each — now generated for all of them, whole-repo-scoped ones marked as such).
|
||||
: "${OFFLINE_SUMMARY_OUT:=${ARTIFACTS}/offline-summary.md}"
|
||||
|
||||
run_offline_checks() {
|
||||
local -a dirs
|
||||
mapfile -t dirs < <(offline_selected_dirs)
|
||||
@@ -403,14 +450,30 @@ run_offline_checks() {
|
||||
log "No L0/L1 offline checks match the selection (layer=${LAYER} reqs='${REQS}')."
|
||||
return 0
|
||||
fi
|
||||
# Declared scopes are passed through so the runner knows WHICH cases must not be allowed to
|
||||
# report a SKIP as green (rc 3). See lib/common.sh's scope block and run_l1_offline.py.
|
||||
local -a scope_args=() s
|
||||
for s in ${INCLUDE_SCOPES//,/ }; do scope_args+=(--unwired-scope "$s"); done
|
||||
log "Running ${#dirs[@]} offline L0/L1 check(s) (no cluster; docs/14)."
|
||||
[ -n "${INCLUDE_SCOPES}" ] && log " unconditional (declared scope ${INCLUDE_SCOPES}): $(conf_dirs_for_scopes "${INCLUDE_SCOPES}" | xargs -n1 basename 2>/dev/null | tr '\n' ' ')"
|
||||
"${PYTHON}" "${CONF_ROOT}/lib/run_l1_offline.py" \
|
||||
--junit "${JUNIT_OUT}" --label-prefix "${CONF_LABEL_PREFIX}" "${dirs[@]}"
|
||||
--junit "${JUNIT_OUT}" --label-prefix "${CONF_LABEL_PREFIX}" \
|
||||
--scope-annotation "${CONF_SCOPE_ANNOTATION}" \
|
||||
--summary-out "${OFFLINE_SUMMARY_OUT}" \
|
||||
${scope_args[@]+"${scope_args[@]}"} "${dirs[@]}"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Main
|
||||
###############################################################################
|
||||
# --print-selection is deliberately handled FIRST: it resolves the selection and exits,
|
||||
# writing nothing and running no check. A test that asserts "the gate's selection cannot
|
||||
# filter me out" needs to ask that question without executing the suite it is part of.
|
||||
if [ "${MODE}" = "print-selection" ]; then
|
||||
select_dirs
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "Coverage report first (always)"
|
||||
gen_coverage
|
||||
|
||||
@@ -440,17 +503,26 @@ esac
|
||||
# L0 static + L1 rendering are cluster-independent (docs/14): run the selected offline
|
||||
# checks and treat their result as authoritative REGARDLESS of cluster availability. An
|
||||
# offline check failure is a real failure (nothing cluster-gated about it), so it always blocks.
|
||||
# rc 3 is the harness-wide SKIPPED/not-run code (ci/README.md): NOT a pass, NOT a fail. The
|
||||
# offline runner returns it when a test carrying a --unwired-scope scope SKIPPED — a whole-repo
|
||||
# invariant reporting "nothing to audit" means the subtree it audits is gone, which the gate
|
||||
# must see as UNWIRED. It is propagated verbatim so ci/l0.sh can set the gate's own rc=3,
|
||||
# exactly as the deleted per-check stages did.
|
||||
OFFLINE_RC=0
|
||||
run_offline_checks || OFFLINE_RC=$?
|
||||
if layers_are_offline_only; then
|
||||
# Offline-only run (the ci/l0.sh + ci/l1.sh merge-gate paths): the offline result IS the result.
|
||||
if [ "${OFFLINE_RC}" -eq 0 ]; then
|
||||
log "Done (offline ${LAYER}). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md"
|
||||
else
|
||||
log "Offline ${LAYER} checks FAILED (rc=${OFFLINE_RC}). JUnit: ${JUNIT_OUT}"
|
||||
fi
|
||||
case "${OFFLINE_RC}" in
|
||||
0) log "Done (offline ${LAYER}). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md" ;;
|
||||
3) log "Offline ${LAYER}: a scope-declared check SKIPPED — UNWIRED, not green (rc=3). JUnit: ${JUNIT_OUT}" ;;
|
||||
*) log "Offline ${LAYER} checks FAILED (rc=${OFFLINE_RC}). JUnit: ${JUNIT_OUT}" ;;
|
||||
esac
|
||||
exit "${OFFLINE_RC}"
|
||||
fi
|
||||
if [ "${OFFLINE_RC}" -eq 3 ]; then
|
||||
log "Offline L0/L1: a scope-declared check SKIPPED — UNWIRED, not green (rc=3)."
|
||||
exit 3
|
||||
fi
|
||||
if [ "${OFFLINE_RC}" -ne 0 ]; then
|
||||
log "Offline L0/L1 checks FAILED (rc=${OFFLINE_RC}) — blocking (not cluster-gated)."
|
||||
exit "${OFFLINE_RC}"
|
||||
|
||||
Reference in new issue
Block a user