Files
home-network-k3s-impl/.gitea/workflows/README.md
T
jakman85andClaude Opus 5 e72050c690
gate / gate (push) Successful in 1m37s
gate / gate (pull_request) Successful in 1m36s
gate / conformance (push) Has been skipped
gate / conformance (pull_request) Has been skipped
docs: correct CI/platform documentation that was stale, wrong, or load-bearing
The 2026-09-29 session found the merge gate did not exist -- no branch protection, no
runner -- while the tree asserted both. This corrects the documents that carried, and in
several cases CAUSED, that belief.

CLAUDE.md gains section 2b: never infer platform state from this repository. It carries
the specific API and kubectl checks for protection, runners, CI status, workloads and
Flux revisions, and the rule that a green tick is not proof anything ran (a skipped Gitea
job reports success). This is the rule whose absence produced every other finding.

.gitea/workflows/README.md and merge-gate.yaml both said "there is no remote git host,
this file is dormant". Gitea had been live for weeks. Both were written as plans and never
revisited once the plan completed. The Transition section is now marked COMPLETE and says
what is genuinely still outstanding (CLUSTER_AVAILABLE unset, so L2/L3 never runs in CI).

DECISIONS-LOG 2026-08-31 is annotated IN PLACE. Anyone reading chronologically previously
hit "required status check; runner incluster-dind online" with nothing marking it false
until a month later. The correction now sits on the bullet itself.

ci/README.md, clusters/README.md and both conformance READMEs carry a status banner: they
speak as though no cluster exists, which makes a live L2/L3 coverage gap read as an
expected phase. That framing is why the gap went unexamined.

apps/wave-1/gitea-actions-runner-secret.sops.yaml: fixed a wrong path, replaced advice to
`sops -d` into a terminal (which CLAUDE.md forbids) with the rotation script, and recorded
that a fresh token alone does not re-register a runner holding a stale /data/.runner.

apps/wave-0/plex-claim-secret.sops.yaml: the header claimed the file ships the placeholder.
It has not since the claim -- it holds the spent token, which is inert and deliberately
left in place.

catalog/plex.yaml and the plex secret both asserted a "~4-minute TTL" as fact. It was
uncited, upstream documents no expiry, and it was the basis for a since-disproven claim
that the pipeline was too slow to fit inside it. Measured: ~2m45s.

Both .sops.yaml files were verified to still decrypt after the comment edits
(`sops -d > /dev/null`), and render==golden confirms the catalog comment does not
propagate into the rendered manifest.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 02:14:26 -05:00

7.3 KiB

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

NOTE (2026-09-29): this section is written in the past's voice — "Phase 0", "no cluster exists". A cluster has existed for weeks and Flux has been reconciling against it. The skip logic below is still correct and still honest, but read it as describing a live coverage gap (L2/L3 is not wired into CI) rather than an expected phase. Its being phrased as a phase is part of why the gap went unexamined.

Originally: The gate is deliberately built so unavailable checks SKIP loudly, they do not pass:

  • The conformance job is if: ${{ vars.CLUSTER_AVAILABLE == 'true' }} — with no cluster it is skipped by Gitea (shown as skipped, not green). The merge-gate decision 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 as l1-junit-coverage / conformance-junit-coverage).
  • Requirement-coverage report → reports/coverage/** — maps every R-* 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 into main.
  • push → main only. The per-branch prefix list was removed: act_runner runs at capacity 1, so a branch push fires a redundant run that serialises ahead of the PR run for the same tree. No work on main — and since 2026-09-29 that is enforced, not conventional: main is a protected branch and a direct push is rejected by the pre-receive hook (verified, not assumed).

Transition: COMPLETE (2026-09-29)

This section used to describe a migration as pending. It has happened. Gitea is live at gitea.home.keithjackson.dev, the runner incluster-dind is registered and online, and gate.yml runs on every PR.

What took a month to notice: the migration was recorded as done on 2026-08-31 without anyone checking the platform. In reality main had no branch protection and zero runners were registered until 2026-09-29. Every "the gate enforces this" statement in between rested on an assertion nobody verified. PR #1 — the first pull request this repository ever had — sat pending for 30.6 minutes because nothing existed to run it.

So: do not read this file, or the DECISIONS-LOG, as evidence about the live platform. Query the API. infra/gitea/ holds the scripts and the measured state.

Still genuinely outstanding: vars.CLUSTER_AVAILABLE and secrets.CONFORMANCE_TRIGGER_KUBECONFIG are unset, so L2/L3 conformance does not run in CI. That is a real coverage gap, not a phase.

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.