Files
home-network-k3s-impl/ci
jakman85andClaude Opus 5 87c3d83dff fix(conformance): placeholder-guard-coupling actually RUNS — it was skipping forever
`conformance/tests/l0-static/placeholder-guard-coupling` (R-ENV-02, R-STATE-03) exited 3
SKIPPED on every gate run since it landed, because its check keyed its whole existence on
`bootstrap/standup.sh`. Measured, not assumed: that file has NEVER been on `main` — there is
no deletion commit for it anywhere; it landed only on unmerged prod-standup branches
(`work/prod-standup-campaign`, `prod/*`, `feat/prod-hardening`). It was planned and never
merged, not removed. SKIPPED is not green, and a permanently-skipped incident-derived test is
the quiet version of the failure it was written to prevent.

The skip was also hiding a RED. Run against the real `standup.sh` from
`work/prod-standup-campaign`, the old check FAILED 2 of 34 assertions — false positives from
treating the empty string as placeholder-shaped for knobs cluster-vars authors empty on
purpose (`BACKUP_CREDENTIALS_SUFFIX`, `VELERO_BACKUP_PREFIX`, both `""` on dev and both
documented there as load-bearing). It would have false-blocked the merge landing its own input.

NOT deleted — the hazard is not gone. Split into two halves, one of which needs no guard:

  HALF A (always runs): no environment overlay ships a placeholder-shaped EFFECTIVE
  cluster-vars value; an empty effective value is STATED by the overlay rather than inherited
  in silence; a LOUD base sentinel is explicitly overridden by every overlay. This is the
  2026-09-05 outcome asserted at the source instead of at the guard — if prod's
  S3_BACKUP_ENDPOINT is never a placeholder, velero cannot be pointed at a hostname that does
  not exist. Input surface is `clusters/`, all of it on `main`.

  HALF B (self-activating): the real predicate extracted and executed against those same
  values, the consumer trace, and the mid-string anchoring property.

The check NEVER exits 3 again, and the guard is DISCOVERED by content rather than hardcoded:
any tracked `*.sh` defining `<name>_is_placeholder()` is found, so a guard landing at
`bootstrap/standup.sh` — or moved into a sourceable library, the remedy the check already
recommends — activates HALF B with no edit to the test. Two files defining it is a FAIL. When
no guard exists the gap is printed loudly every run instead of swallowed as a skip.

LOUD vs EMPTY is the correction that makes it honest in both modes: a loud placeholder is
always a defect in an overlay, while `""` is held to the explicitness rule instead — and still
counts as a placeholder for a knob a guard covers, because a `""` case-arm is the guard
author's declaration that empty is not a usable target.

Proof, all reverted: 84 assertions and exit 0 on `main`; prod S3_BACKUP_ENDPOINT set to the
literal `https://TBD-R2-ACCOUNT-ID.r2.cloudflarestorage.com` FAILS (A1); deleting dev's
explicit VELERO_BACKUP_PREFIX op so it inherits `""` FAILS (A2); a LOUD base BULK_NFS_SERVER
with prod's override deleted FAILS 2/86 (A1+A3); and with the real `standup.sh` present,
reverting `s3_is_placeholder` to the `TBD-*` PREFIX glob FAILS 12/110 (B3 anchoring). With the
pristine `standup.sh` the check PASSES at 110 assertions where the old one failed 2 of 34.

L0 layer: 14 checks, 14 PASS, 0 SKIP, 0 FAIL (was 13 PASS / 1 SKIP). Coverage unchanged —
R-ENV-02 and R-STATE-03 both still list this test, 49/51 with the same 2 gaps.

Also:
  * `ci/touched-requirements.sh` — `bootstrap/*` -> R-STATE-03 R-GITOPS-01 is KEPT, its stale
    comment corrected. It claimed standup.sh "carries the placeholder guards", describing a
    file that is not here; R-STATE-03 there is ANTICIPATORY. Deleting the entry would leave the
    change that lands a placeholder guard selected by nothing.
  * `ci/tests/touched-requirements.test.sh` — new path-map section (12 assertions, was 8)
    locking `bootstrap/*` and `clusters/*`, so removing either entry is a test failure rather
    than a silent loss of selection. All three ci/tests pass (12 + 31 + 15).
  * `docs/BULK-NFS-STORAGE.md` §7 — its "nothing in the gate currently refuses a
    placeholder-shaped overlay value" is no longer true; corrected.
  * Scope re-audited: still deliberately requirement-scoped, not whole-repo. Every path that
    can break it attributes a requirement it declares.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-13 00:10:37 -05:00
..

ci/ — portable merge gate (runnable today, without Gitea)

Task T-0.2 · R-TEST-02 (reproducible, single-command) · Owner WS-GITOPS (CI).

The same merge gate the Gitea Actions workflow runs (.gitea/workflows/), as plain scripts + a Makefile so the gates are runnable now, before any Gitea host or cluster exists. CI and a laptop call the identical scripts, so make -C ci gate == the CI merge gate.

Quick start

make -C ci gate        # the single command: run every stage + the R-TEST-03 decision
make -C ci help        # list targets
make -C ci tools       # which pinned tools are installed locally
make -C ci touched     # print the touched-requirement subset for this branch vs main

Individual stages:

make -C ci lint        # yaml/lint                          (no cluster; live now)
make -C ci l0          # schema + Kyverno + L0 static conformance (no cluster; live now)
make -C ci l1          # L1 rendering checks                 (no cluster; live now)
make -C ci prom-rules  # promtool rule-unit tests on alerts  (no cluster; live now)
make -C ci conformance # L2/L3 in-cluster Chainsaw Job       (SKIPS unless CLUSTER_AVAILABLE=true)
make -C ci test        # unit tests for these scripts themselves (ci/tests/*.test.sh)

The l0 stage has three sub-stages: ServiceDefinition schema validation, the Kyverno policy gate (kyverno test), and conformance/run.sh --layer l0 --include-scope whole-repo — the whole-repo L0 invariants a per-resource policy cannot express (secret hygiene across every tracked file, least-privilege RBAC, spec-immutability enforcement). See conformance/tests/README.md.

--include-scope whole-repo: why the L0 sub-stage is not purely requirement-scoped

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 the coarse path→requirement 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.

This used to be handled by a hand-written "UNCONDITIONAL" stage per such check (3c subst-safety, 3d keycloak-client-secrets, 3e bulk-tier): three near-identical rationales, three hardcoded check.sh paths, three copies of the same rc 0/3/* case block — and, worse, a mechanism that failed silently, because whoever added the next whole-repo invariant had to remember to write a stage. Those stages are gone. A test now declares conformance.home.k3s/scope: "whole-repo" and the runner unions every declaring test into the run. Adding the annotation is the only step.

rc 3 is preserved exactly as those stages had it: a SKIP of a scope-declared check means the tree it audits has vanished, so the runner exits 3, ci/l0.sh sets the gate's own rc=3, and the gate reports SKIPPED — never a pass. The mechanism is itself proven, end-to-end and falsifiably, by conformance/tests/l0-static/whole-repo-invariants-wired; conformance/lib/common.sh carries the full rationale and the incidents behind it.

Exit-code contract

Every stage script uses: 0 = pass · 1 = fail (blocks merge) · 3 = SKIPPED (cluster-gated with no cluster, or an unwired seam / missing tool). A 3 is never counted as green — that is how the gate stays honest in Phase 0 (no fake green).

The touched-requirement subset can also be undeterminable (exit 3)

touched-requirements.sh is the input to the R-TEST-03 decision. It resolves a diff base in this order, verifying each candidate exists before using it:

BASE_REF (explicit)  >  GITHUB_BASE_REF (Actions PR runs)  >  origin/main  >  main

If no candidate resolves, if there is no merge base, if the diff command fails, or if HEAD is not ahead of the base (you are sitting on main, or the branch is already merged), it prints a loud banner, writes nothing to stdout, and exits 3 — plus a machine-readable reason code (on-base / no-base / diff-failed) to $TOUCHED_REASON_FILE when that variable names a path.

Why this is not cosmetic. Every one of those conditions used to degrade silently to an empty touched set and exit 0. An empty set means conformance/run.sh is invoked with no --requirements, which means its coverage --require gate never runs — so on 2026-08-31 the local gate printed MERGE GATE: PASS having checked nothing, while CI (correct base, 17 touched requirements) BLOCKED on real coverage debt. There is no silent narrowing left: the HEAD~1 last resort and the two-dot diff fallback are both gone. Locked down by ci/tests/touched-requirements-base.test.sh.

gate.sh turns exit 3 into an UNDECIDED merge decision, never a pass:

reason headline exit
on-base MERGE GATE: NOT APPLICABLE — no branch under gate 0 (a push-to-main CI run legitimately lands here; it just must not read as "my branch is green")
no-base, diff-failed MERGE GATE: BLOCKED 1 (the environment cannot support a merge decision; refusing to decide must not look like deciding yes)

A stage that needs the subset (l0.sh, l1.sh) still runs its FULL layer as a superset sanity net in that case — nothing goes untested — and says plainly that the scoped check did not happen.

Reproducibility (R-TEST-02)

  • Version-pinned: all tool versions live in versions.env — the single source of truth shared by the local scripts and the CI installer. Bumping a version is a normal feature-branch → main change.
  • Single command, environment-agnostic: make -C ci gate on any machine with the pinned tools.
  • Deterministic: no cluster state is assumed; L2/L3 SKIP unless a cluster is explicitly declared.

Files

ci/
  Makefile                 targets: lint · l0 · l1 · prom-rules · conformance · gate · touched · test · tools · versions
  versions.env             PINNED tool versions (R-TEST-02) — single source of truth
  lib.sh                   shared helpers: logging, tool checks, cluster detection, artifact paths
  yamllint.yaml            lint config for the yaml/lint stage
  lint.sh                  Stage: yaml/lint
  l0.sh                    Stage: schema validation + Kyverno L0 gate + L0 static conformance
  l1.sh                    Stage: L1 rendering checks (Chainsaw L1 via the conformance runner)
  prom-rules.sh            Stage: `promtool test rules` over platform/monitoring's PrometheusRules
                           (harness: platform/monitoring/tests/). Catches the two alerting bugs no
                           other stage can see — permanently-firing and structurally INERT rules.
  conformance.sh           Stage: L2/L3 in-cluster Chainsaw Job (trigger + collect)
  touched-requirements.sh  compute the R-* subset a change touches (drives the R-TEST-03 filter);
                           exits 3 LOUDLY rather than guessing when the diff base is unreliable
  gate.sh                  run all stages + the merge decision (the single command)
  tests/*.test.sh          unit tests for these scripts (`make -C ci test`), run against
                           throwaway git repos via the REPO_ROOT override — no network, no cluster
  README.md                this file

Integration seams — entrypoints owned by OTHER agents

This gate is an orchestrator. It does not implement schema validation, Kyverno policies, or the conformance runner — those are other workstreams' deliverables. Each seam is env-overridable so the owning agent / PM can reconcile the real entrypoint without editing these scripts. Defaults below are this workstream's assumptions — flagged for the PM to verify at integration.

Seam Owner / task Env override Assumed default Assumed interface
ServiceDefinition schema validator WS-GITOPS · T-0.3 (schemas/) SCHEMA_VALIDATE_CMD schemas/validate.sh validate.sh catalog/*.yaml → exit 0/1; --version supported
Kyverno L0 policies + tests WS-TEST · T-0.5 (platform/kyverno/) KYVERNO_POLICY_DIR platform/kyverno contains kyverno-test.yaml files discoverable by kyverno test <dir>
Conformance runner (Chainsaw) WS-TEST · T-0.4 (conformance/) CONFORMANCE_CMD conformance/run.sh run.sh --layer <l0|l1|l2,l3> [--requirements R-A,R-B]; artifacts land in conformance/_artifacts/
Catalog (ServiceDefinitions) WS-APPS CATALOG_DIR catalog *.yaml ServiceDefinition files

Artifact locations (git-ignored): JUnit → results/junit/*.xml; coverage → reports/coverage/**. Override with JUNIT_DIR / COVERAGE_DIR if the runner writes elsewhere.

A seam that is missing or not executable reports SKIPPED (exit 3), not a false pass — so during parallel Phase 0 development the gate is green-honest, and each seam goes live automatically the moment its owner delivers a runnable entrypoint.

Cluster gating

CLUSTER_AVAILABLE=true (env locally, vars.CLUSTER_AVAILABLE in Gitea) makes the L2/L3 stage run and be required; anything else makes it SKIP (Phase 0). REQUIRE_CLUSTER_TESTS=true (Phase 1+) turns any unverified cluster-needing touched requirement into a hard block.