The static gate passes (lint + Kyverno L0 + L1 render) but the trailing actions/upload-artifact@v4 step fails with "not supported on GHES" — Gitea Actions does not implement the artifact-v2 backend — and its always() failure sank the whole job. Remove the step; JUnit/coverage remain in the job workspace. Re-add via the @v3 action or Gitea's artifact backend later if retention is needed. 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) ─▶ 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.