The gate BLOCKED this branch, correctly, and the block was the interesting part.
Attaching the media library selects R-STATE-04 as a touched requirement ("bulk media storage has
a capacity strategy distinct from the replicated tier"), and R-STATE-04 had NO scaffolded test, so
the coverage REQUIRE stage failed both l0 and l1. There were two ways out. One was to keep
avoiding the token — the existing discipline in catalog/plex.yaml and catalog/qbittorrent.yaml,
which say in as many words that naming the requirement would block the merge. That made sense while
the tier was a stub. It does not make sense in the change that DELIVERS the tier: it would mean
shipping the requirement and claiming nothing. So: the check.
WHAT IT ASSERTS, and why each one is a real failure mode rather than a shape
The `bulk` StorageClass sat as `server: REPLACE-ME` for six roadmap phases, and the only thing
keeping a multi-terabyte library off the 3-replica `longhorn` class was four hand-written comments.
That is the class of failure being closed: a bulk volume on the replicated tier does not fail
loudly — Longhorn accepts the PVC and starts replicating 3x of data three mini-PCs cannot hold.
(1) DISTINCTNESS — no bulk PV/PVC on any class the repo declares with the Longhorn provisioner
(derived from platform/longhorn/, so adding a Longhorn class cannot create a blind spot),
and the class is explicitly EMPTY.
(2) CAPACITY STRATEGY as arithmetic — a bulk volume at least an order of magnitude above the
same service's replicated volume. The requirement is about two magnitudes of data; a "bulk"
volume sized like a config PVC means the tier was named, not applied. The 10x floor is
CALIBRATED against the real corpus, not guessed: the media library is ~460x its config
volume and the Nextcloud archive ~13x its 20Gi, so a higher floor would fire on a legitimate
volume and teach the next reader to weaken the check. My first draft used 100x and did
exactly that — the corpus caught it.
(3) PRE-BOUND FROM BOTH ENDS (claimRef + volumeName, agreeing). Guards the most deceptive
failure available in this tier: a dynamic bulk class hands out a FRESH EMPTY directory, the
pod mounts, the PVC Binds, everything is green, and the 8 TiB was never attached.
(4) Retain, always. (5) mountOptions carry `hard` and exactly one nfsvers=4.x, and never `soft`
— a soft mount hands a server hiccup to the app as a short read, which for a media library
is silent corruption; and v4 is what keeps the firewall surface at one port.
(6) ONE EXPORT, AT MOST ONE MOUNT PER WORKLOAD. This is the assertion I most wanted. Splitting
one export into two subPath mounts looks TIGHTER and breaks link() with EXDEV; Sonarr and
Radarr then degrade to copy-then-delete, rewriting every import over NFS and destroying the
seeding copy, surfacing weeks later as "imports are slow and my ratio is gone". No reviewer
sees that in a diff.
(7) READ-ONLY COUPLING — nfs.readOnly, the volumeMount flag and ReadOnlyMany must agree, so
dropping one still leaves the others; and every bulk mode is a `...Many`.
(8) The server is a BARE cluster-vars token, that knob has a base default, and EVERY overlay
supplies it — the seam that lets one authored definition serve two clusters that reach this
host by different addresses.
(9) LOAD-BEARING and NON-VACUOUS. Wired unconditionally into ci/l0.sh as Stage 3e for the same
reason as 3c/3d: Stage 3b scopes to touched requirements, and the path map keys NEITHER
apps/* NOR platform/longhorn/* to this requirement — so the change most likely to break
this could attribute nothing and be gated by nothing. An EMPTY bulk corpus FAILS rather
than passing trivially, and a floor on the assertion count keeps a pass from degenerating.
It reads the COMMITTED apps/ goldens rather than calling the renderer. Asking the renderer would
only prove it agrees with itself; the goldens are what Flux reconciles, and render==golden is
already its own gate stage.
PERTURBED, because a check that has never failed is not evidence. Each of these was applied to the
real tree and each fired with the intended message: bulk PVC moved onto `longhorn`; Retain -> Delete;
`hard` -> `soft`; the token replaced by the literal 192.168.1.9; sonarr's single mount split into
two subPath mounts (assertion 6); and the prod overlay's knob deleted.
Also hardens render.py: `size` is structurally required of a bulk entry (a PV with no capacity is
rejected by the API server) but the JSON Schema only enforces `export`, so omitting it raised a bare
KeyError three frames down. It now raises a sentence naming the service, the volume and the remedy.
Found by the same perturbation pass.
Coverage: 48/51 -> 49/51 (96.1%), 2 gaps, 0 safety-critical.
BASE_REF=main make -C ci gate -> MERGE GATE: PASS.
Co-Authored-By: Claude Opus 5 <[email protected]>
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 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 — 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.
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 gateon 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 · 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)
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.