HAND-OFF 2 (L2/L3 conformance wiring) — DEFERRED with a concrete plan, per the hand-off's escape hatch (too risky to wire live cluster access from CI autonomously; do NOT hand CI a broad kubeconfig). The intended seam (in-cluster Chainsaw Job under its own scoped SA; CI holds only a narrow trigger credential) is mostly scaffolded but not safe to finish autonomously: - ci/conformance.sh still runs run.sh locally rather than triggering the in-cluster Job. - conformance/incluster/chainsaw-job.yaml has no repo-clone init-container and the stock chainsaw image lacks bash/python3/kubectl/git that run.sh needs. - No trigger SA/RBAC/credential exists, and the core blocker is a privilege-escalation design question: any `create jobs` credential in the conformance namespace can create a Job running as the privileged conformance-runner SA. This needs an admission constraint (recommended: narrow trigger SA + a Kyverno policy pinning the admissible Job shape), or a GitOps-native zero-credential trigger — an operator decision. - conformance/incluster/ isn't wired into Flux; Gitea vars/secrets aren't provisioned. docs/plans/l2-l3-conformance-ci-wiring.md captures current state, the security analysis, the functional gaps, a file-by-file step sequence, validation/rollback, and the open decisions. gate.yml's conformance placeholder now points to it (still if:false, honest). Co-Authored-By: Claude Opus 4.8 <[email protected]>
CI merge gate — .gitea/workflows/
Task T-0.2 · Requirements R-GITOPS-01, R-TEST-02, R-TEST-03 · Owner WS-GITOPS (CI).
This is the merge gate for the home k3s + Longhorn platform. It enforces the gating rules in the
spec (docs/14-testing-and-conformance.md,
ADR-0010,
ADR-0011) as a
Gitea Actions pipeline: no change lands on main unless the conformance subset for the requirements
it touches is green (R-TEST-03).
Done is a green, reproducible, requirement-tagged test — never "merged," never a demo. This gate is the machine that enforces it.
The pipeline
lint ─▶ l0 (schema + Kyverno) ─▶ render-golden ─▶ l1 (rendering) ─▶ conformance (L2/L3) ─▶ merge-gate decision
└──────────────── run in CI, NO cluster ────────────────┘ └── in-cluster Chainsaw Job ──┘
live now cluster-gated (SKIPS in Phase 0)
| Stage | Job | Layer | Verifies | Cluster? |
|---|---|---|---|---|
| yaml/lint | lint |
— | YAML well-formed (ci/yamllint.yaml) |
no — live now |
| schema | l0 |
L0 | ServiceDefinitions valid against schemas/ |
no — live now |
| Kyverno L0 | l0 |
L0 | kyverno test platform/kyverno/: default-deny baseline present, no plaintext secrets, required labels/egress-class, schema conformance |
no — live now |
| L1 rendering | l1 |
L1 | ServiceDefinition → rendered resources match golden expectations (Chainsaw L1 via conformance/) |
no — live now |
| conformance | conformance |
L2+L3 | Real runtime behavior + fault-injection, as an in-cluster Chainsaw Job CI triggers and awaits | yes — cluster-gated |
| decision | merge-gate |
— | Applies R-TEST-03 over the above; blocks merge on any failure | no |
Each stage is a thin wrapper over a ci/*.sh script, so CI and a laptop run the identical gate
(R-TEST-02). See ../../ci/README.md.
Why L2/L3 is an in-cluster Job (not run by CI directly)
Per docs/14 §"Where each layer runs"
and ADR-0011: CI holds no broad kubeconfig. L2/L3 run as an in-cluster Chainsaw Job whose own
scoped test ServiceAccount holds cluster access (R-SEC-02). CI only triggers the Job and
collects its artifacts, using a narrow trigger credential referenced by secret name only
(secrets.CONFORMANCE_TRIGGER_KUBECONFIG) — never a plaintext secret, never a cluster-admin kubeconfig.
Honest SKIP — never fake green (Phase 0)
No cluster exists in Phase 0. The gate is deliberately built so unavailable checks SKIP loudly, they do not pass:
- The
conformancejob isif: ${{ vars.CLUSTER_AVAILABLE == 'true' }}— with no cluster it is skipped by Gitea (shown as skipped, not green). Themerge-gatedecision then reports any touched requirement that needs L2/L3 as UNVERIFIED, not satisfied. - Stages whose owning subtree is still a placeholder (
schemas/,platform/kyverno/,conformance/) report SKIPPED — unwired, distinct from a pass, until those seams are delivered.
The decision matrix (scripts/decide.sh):
| Situation | Outcome |
|---|---|
| Any of lint/l0/l1 fails | BLOCK |
| conformance fails/cancelled | BLOCK |
conformance skipped while CLUSTER_AVAILABLE=true |
BLOCK (it should have run) |
| conformance skipped, no cluster, touched reqs need L2/L3 | UNVERIFIED — reported, not green; blocks only if REQUIRE_CLUSTER_TESTS=true |
| lint/l0/l1 green, no cluster-needing reqs touched | PASS |
REQUIRE_CLUSTER_TESTS is false in Phase 0 (bootstrap: seams and cluster don't exist yet) and flips
to true from Phase 1 once a dev/test cluster is declared — from then on, any touched requirement that
can't be verified is a hard block.
Artifacts (R-TEST-04 traceability)
Every layer emits machine-readable output collected by the merge-gate:
- JUnit XML →
results/junit/*.xml(uploaded asl1-junit-coverage/conformance-junit-coverage). - Requirement-coverage report →
reports/coverage/**— maps everyR-*to its verifying test(s); uncovered requirements are visible gaps. Produced by the conformance runner (conformance/, WS-TEST).
Both dirs are git-ignored (see .gitignore).
Triggers & branch model (CONTRIBUTING.md)
pull_request→main: PRs are the enforced merge gate intomain.push→feature/**,fix/**,docs/**,chore/**: fast feedback on the branch. No work onmain.
Transition: generic-CI-now → Gitea-Actions-after-Gitea-is-up
Per CONTRIBUTING.md and
docs/05 §"local bootstrap → self-hosted Gitea":
- Now (interim): there is no remote git host on this network. This workflow file is dormant —
but the identical gate runs locally today via
make -C ci gate(R-TEST-02). Development is local feature-branch →main, the gate enforced by convention + the local runner. - Gitea comes online early (one of the first services onboarded) at
gitea.home.keithjackson.dev. - CI moves to Gitea Actions: this workflow activates unchanged. The only operational steps are
enabling a Gitea Actions runner and setting
vars.CLUSTER_AVAILABLE=true+secrets.CONFORMANCE_TRIGGER_KUBECONFIGonce a dev/test cluster exists. PRs then become the platform-enforced merge gate — the same no-work-in-main rule, now enforced by machine.
Configuration (set in Gitea; referenced by name only)
| Kind | Name | Purpose |
|---|---|---|
| variable | CLUSTER_AVAILABLE |
'true' once a dev/test cluster exists → L2/L3 run and are required; else they SKIP |
| variable | REQUIRE_CLUSTER_TESTS |
'true' (Phase 1+) makes unverified cluster-needing touched reqs a hard block |
| secret | CONFORMANCE_TRIGGER_KUBECONFIG |
narrow creds to create/await the in-cluster Chainsaw Job (not cluster-admin) |
Files
.gitea/workflows/
merge-gate.yaml the pipeline (lint → l0 → l1 → conformance → decision)
scripts/
install-tools.sh installs the pinned tools per stage (from ci/versions.env)
decide.sh the R-TEST-03 merge decision over job results
README.md this file
The stage logic itself lives in ../../ci/ so it is reusable locally and in CI.