Files
jakman85andClaude Opus 5.5 9537ca2ddb feat(ci): run the merge gate inside the CI-built toolbox image, pinned by digest
The gate job now runs in gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501e...
(the image images.yaml publishes from infra/devcontainer/Containerfile) instead of
catthehacker/ubuntu:act-22.04 plus a per-run download of yamllint, check-jsonschema, kyverno,
promtool and chainsaw. Local (dev.sh) and CI now gate on the identical toolchain.

- actions/checkout (Node) replaced by a plain-git step with fetch-depth-0 semantics; the
  toolbox has no Node. The job token is not persisted into .git/config.
- The install step is replaced by a check that every ci/versions.env pin equals the image's
  /etc/dev-toolchain-versions, so the pins stay enforced.
- Renovate follows the digest through a `# renovate-digest:` marker; github-actions manager
  disabled for own images.
- install-tools.sh kept (merge-gate.yaml blueprint + alert-rules-unit-tested check use it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:12:03 -06:00

11 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

 decisions-log ─▶ 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)

How images are built

This repo's three images — toolbox (infra/devcontainer/), agent-runner (infra/agent/, built FROM the toolbox) and conformance-runner (conformance/incluster/) — are built, verified and published by images.yaml, never from a workstation (operator rule, 2026-10-29). The logic is in scripts/images.sh.

The builder is rootless BuildKit (platform/buildkit/), reached over mTLS with a pinned buildx — no job here touches a Docker daemon, privileged or otherwise (operator decision on PR #81). The client certificate reaches jobs as the Actions secrets BUILDKIT_CA, BUILDKIT_CLIENT_CERT, BUILDKIT_CLIENT_KEY.

Verification is a build target. Each Containerfile ends in a verify stage that runs every in-image assertion at build time as the non-root uid with RUN --network=none (tool presence and pinned versions, PM_SELFTEST=1 pm-dispatch, the pm-worker probe, the conformance-clone refusals, no writable path outside the workspace). The pipeline builds <image>-verify before it builds, let alone pushes, the image. Build args come from each image's own build-image.sh --print-build-args, which is also the local podman path — so the recipe and the proof exist once.

Trigger What happens
pull request touching an image's inputs build + verify only. Nothing is pushed and no registry credential is referenced
push to main touching an image's inputs build + verify + publish to gitea.home.keithjackson.dev/home-network/<image> as sha-<commit> (immutable) and main (moving); the registry-returned digest is in the job summary
workflow_dispatch the same as a push when run on main (all images by default); verify-only elsewhere

Only images whose inputs changed are built. The publish job never writes to git: Renovate follows each image's main tag through a # renovate-digest: marker next to every digest reference (platform/pm-agent/, infra/agent/versions.env, conformance/versions.env) and opens the bump PR, which goes through the gate like anything else. A PR opened with the Actions token would not trigger the required gate, which is why it is Renovate's job and not this one's.

Credential: the repository secret REGISTRY_PUSH_TOKEN (scope write:package, owned by an account with package write on the org). The automatic Actions token cannot push in Gitea 1.24.3 — the reasoning, with source citations, is in the header of images.yaml. Retention: infra/gitea/registry-retention.sh, an operator action, keeps every digest git pins plus the newest tags.

Cost: the job shares the single act_runner slot with the gate, but the build itself runs in the buildkitd pod, not in DinD, so the runner's disk and memory are untouched. Until platform/buildkit is deployed and its client certificate copied (infra/gitea/buildkit-client-secret.sh), the job fails loudly at its builder preflight.

Files

.gitea/workflows/
  merge-gate.yaml            the pipeline (lint → l0 → l1 → conformance → decision)
  images.yaml                build/verify/publish this repo's container images (see above)
  scripts/
    install-tools.sh         installs the pinned tools per stage (from ci/versions.env) — used by the
                             dormant merge-gate.yaml blueprint only; gate.yml runs in the toolbox image
    decide.sh                the R-TEST-03 merge decision over job results
    images.sh                the image pipeline's driver (what to build, base, push, digest)
  README.md                  this file

The stage logic itself lives in ../../ci/ so it is reusable locally and in CI.