Files
home-network-k3s-impl/.gitea/workflows
jakman85andClaude Opus 5 50a48e6f07
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
fix(ci): the settings token could NEVER have been stored -- Gitea reserves the GITEA_ prefix
The drift detector merged in #11 was inert and would have stayed inert silently.

WHAT HAPPENED. infra/gitea/set-ci-token.sh minted a token and PUT it to the repo Actions
secret GITEA_SETTINGS_TOKEN. Gitea RESERVES the `GITEA_` prefix for secret names -- exactly
as GitHub reserves `GITHUB_` -- and rejects that PUT with:

    HTTP 400  {"message":"invalid secret name"}

The script sent the PUT to /dev/null and printed "==> stored." unconditionally, and
`curl -sS` exits 0 on an HTTP error, so it reported success for a store that never happened.
Measured afterwards: the token existed in the user's token list, and the repo's secret list
was EMPTY. ci/gitea-settings.sh would have skipped on every CI run forever, and with
REQUIRE_GITEA_SETTINGS=true it would instead have BLOCKED EVERY PR -- including the one
fixing it.

Three changes:
  * the secret is SETTINGS_API_TOKEN; ci/gitea-settings.sh reads it, keeping
    GITEA_SETTINGS_TOKEN as a local-run fallback;
  * set-ci-token.sh checks the PUT's HTTP status AND reads the secret back, refusing to claim
    success otherwise. That missing verification is the actual defect -- the wrong name was
    just what it hid;
  * gate.yml maps the secret and ENABLES REQUIRE_GITEA_SETTINGS.

WHY ENABLING IT HERE IS SAFE: this PR's own CI run is the proof. If the stage were still
skipping, REQUIRE turns that into a failure and this PR goes RED rather than merging. Green
means the token resolved and live Gitea matched infra/gitea/expected-branch-protection.json.

Verified locally before pushing:
  REQUIRE=true, token present -> MERGE GATE: PASS
  REQUIRE=true, no token      -> gitea-settings SKIPPED -> BLOCKED (not a silent skip)

The secret is already stored and read back as present, so CI has what it needs.

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

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.