Files
home-network-k3s-impl/.gitea/workflows/README.md
T
jakman85andClaude Opus 5 72a46ad82d
gate / gate (pull_request) Successful in 1m35s
gate / conformance (pull_request) Has been skipped
chore(log): the handoff log was serialising every parallel branch
DECISIONS-LOG.md was append-only in ONE file and every agent appended to its
tail, so any two concurrent branches collided on the same last hunk. Measured
against the live Gitea today: four of the five open PRs were mergeable=false,
and `git merge-tree` named DECISIONS-LOG.md as the ONLY conflicting path in
all four.

    #22 fix/crafty-oauth2-proxy-signin       CONFLICT: DECISIONS-LOG.md
    #20 feat/conformance-job-template        CONFLICT: DECISIONS-LOG.md
    #19 feat/conformance-trigger-kubeconfig  CONFLICT: DECISIONS-LOG.md
    #18 feat/conformance-runner-image        CONFLICT: DECISIONS-LOG.md
    #17 feat/ci-is-the-approver              clean — it wrote no handoff

Nothing was wrong with any of those changes. The log's storage format
serialised work that was otherwise parallel, and the cost grew with every
concurrent branch (N branches -> N-1 rebases, each invalidating the gate run
before it). The agents work in isolated worktrees precisely so they do not
block each other; this one file undid that.

So: stop sharing a file. Handoffs are now one-per-file under decisions/,
named YYYY-MM-DD-<slug>.md. Disjoint paths have nothing to merge. There is
deliberately no index file (a shared mutable file is the thing that just
failed) and no counter (two agents would race for the same number) — the
directory listing is the index.

DECISIONS-LOG.md is frozen as the pre-2026-09-29 archive, its SHA-256 pinned
in decisions/ARCHIVE.sha256, and a new gate stage rejects any change to it.
That is a guard and not a paragraph because the old habit is written into the
runbooks and into every agent that has read the log, and getting it wrong does
not fail: it produces a clean-looking commit that re-creates the conflict on
OTHER branches, later, misattributed.

  ci/decisions-log.sh        new stage: archive pinned; every entry named with
                             a real date, carrying date/branch/title frontmatter
                             that agrees with its filename, and all three of
                             CLAUDE.md §7's sections with a real body under each.
                             Exit 0 or 1 only — no skip path by construction, so
                             no fail-open surface on a guard that closes one.
  ci/gate.sh                 runs it; fail and (impossible) skip both BLOCK.
                             No REQUIRE_DECISIONS_LOG knob: a guard with an off
                             switch is not a guard.
  ci/tests/decisions-log.test.sh   22 assertions, incl. the one that matters —
                             an append to the archive is rejected, and the
                             message names the file to write instead.
  ci/tests/gate-decision.test.sh   +3 assertions for the new wiring, AND a
                             pre-existing bug: the stub list never included
                             gitea-settings, so run_stage saw exit 127 and
                             recorded a FAILED stage in every case. 15 of 47
                             assertions failed on main for that reason alone —
                             the suite was measuring its own missing stub.
                             Now 50/50.
  CLAUDE.md                  §7 rewritten; §2b widened to the new path.
  ci/Makefile, ci/README.md, .gitea/workflows/README.md — stage named where
                             the other stages are named.

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

130 lines
7.4 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
```
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`](../../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.