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]>
130 lines
7.3 KiB
Markdown
130 lines
7.3 KiB
Markdown
# 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`](../../../home-network-k3s/docs/14-testing-and-conformance.md),
|
|
[ADR-0010](../../../home-network-k3s/decisions/adr-0010-conformance-testing.md),
|
|
[ADR-0011](../../../home-network-k3s/decisions/adr-0011-test-tooling-kyverno-chainsaw.md)) 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`](../../ci/README.md).
|
|
|
|
### Why L2/L3 is an in-cluster Job (not run by CI directly)
|
|
|
|
Per [`docs/14` §"Where each layer runs"](../../../home-network-k3s/docs/14-testing-and-conformance.md)
|
|
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`](../../.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/`](../../ci/) so it is reusable locally and in CI.
|