T-0.2 CI merge-gate pipeline + portable local runners (R-TEST-02/03, R-GITOPS-01)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-16 00:08:33 -05:00
1 parent de56c55dcc
commit 0d86630271
15 files changed
+1022 -2

No files matched your search

+114 -2
View File
@@ -1,3 +1,115 @@
# .gitea/workflows
# CI merge gate — `.gitea/workflows/`
_Placeholder — populated in Phase 0. See ../home-network-k3s (read-only spec)._
**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) ─▶ 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 (Phase 0)
**No cluster exists in Phase 0.** 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` → `feature/**`, `fix/**`, `docs/**`, `chore/**`: fast feedback on the branch. **No work on `main`.**
## Transition: generic-CI-now → Gitea-Actions-after-Gitea-is-up
Per [`CONTRIBUTING.md`](../../../home-network-k3s/CONTRIBUTING.md) and
[`docs/05` §"local bootstrap → self-hosted Gitea"](../../../home-network-k3s/docs/05-gitops-and-delivery.md):
1. **Now (interim):** there is **no remote git host** on this network. This workflow file is dormant —
but the **identical gate runs locally today** via `make -C ci gate` (R-TEST-02). Development is
local feature-branch → `main`, the gate enforced by convention + the local runner.
2. **Gitea comes online early** (one of the first services onboarded) at `gitea.home.keithjackson.dev`.
3. **CI moves to Gitea Actions:** this workflow activates unchanged. The only operational steps are
enabling a Gitea Actions runner and setting `vars.CLUSTER_AVAILABLE=true` +
`secrets.CONFORMANCE_TRIGGER_KUBECONFIG` once a dev/test cluster exists. PRs then become the
**platform-enforced** merge gate — the same no-work-in-main rule, now enforced by machine.
## 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.
+132
View File
@@ -0,0 +1,132 @@
# .gitea/workflows/merge-gate.yaml
# CI merge gate (T-0.2, R-GITOPS-01, R-TEST-02/03) for the home k3s + Longhorn
# platform. Enforces docs/14 (Testing & Conformance) gating rules as Gitea Actions.
#
# Pipeline (docs/14 "Where each layer runs"):
# lint -> schema -> kyverno-L0 -> L1-render [no cluster; live now]
# |
# v
# conformance-L2-L3 (in-cluster Chainsaw Job)
# | [cluster-gated; SKIPS in Phase 0]
# v
# merge-gate decision [R-TEST-03: touched subset green]
#
# Transition (CONTRIBUTING.md / docs/05 §"local bootstrap -> self-hosted Gitea"):
# Today there is no Gitea host, so this file is dormant. The SAME gate is runnable
# now via `make -C ci gate` (R-TEST-02). Once Gitea is self-hosted and Gitea
# Actions is enabled, this workflow becomes the ENFORCED PR merge gate into main —
# no code change required, only turning on the runner and setting CLUSTER_AVAILABLE.
#
# CI holds NO broad kubeconfig. L2/L3 run as an in-cluster Chainsaw Job whose own
# scoped ServiceAccount holds cluster access (docs/14, R-SEC-02); CI uses only a
# narrow trigger credential referenced BY SECRET NAME below.
---
name: merge-gate
on:
pull_request:
branches: [main] # PRs are the enforced merge gate into main (CONTRIBUTING.md)
push:
branches:
- 'feature/**' # feature-branch -> main workflow (no work on main)
- 'fix/**'
- 'docs/**'
- 'chore/**'
concurrency:
group: merge-gate-${{ github.ref }}
cancel-in-progress: true
# Repo/org variables & secrets (set in Gitea, referenced by NAME only — no plaintext):
# vars.CLUSTER_AVAILABLE 'true' once a dev/test cluster exists (Phase 1+); else L2/L3 SKIP
# secrets.CONFORMANCE_TRIGGER_KUBECONFIG narrow creds to CREATE/AWAIT the in-cluster Chainsaw Job only
env:
CLUSTER_AVAILABLE: ${{ vars.CLUSTER_AVAILABLE }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history so touched-requirement diff works
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh lint
- name: yaml/lint
run: make -C ci lint
l0:
runs-on: ubuntu-latest
needs: [lint]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh l0
- name: Schema validation + Kyverno L0
run: make -C ci l0
l1:
runs-on: ubuntu-latest
needs: [l0]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh l1
- name: L1 rendering checks
run: make -C ci l1
- name: Upload L1 artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: l1-junit-coverage
path: |
results/junit/*.xml
reports/coverage/**
if-no-files-found: ignore
# L2/L3 behavioral + fault-injection. Runs ONLY when a cluster is declared.
# In Phase 0 (CLUSTER_AVAILABLE unset) this job is SKIPPED by Gitea — shown as
# skipped, never as passed. It never fakes green (HARD RULE 4).
conformance:
runs-on: ubuntu-latest
needs: [l1]
if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh conformance
- name: Trigger in-cluster Chainsaw Job (L2/L3) and collect artifacts
env:
# Narrow trigger credential; the Job's scoped SA holds real cluster access.
CONFORMANCE_TRIGGER_KUBECONFIG: ${{ secrets.CONFORMANCE_TRIGGER_KUBECONFIG }}
run: make -C ci conformance
- name: Upload L2/L3 artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: conformance-junit-coverage
path: |
results/junit/*.xml
reports/coverage/**
if-no-files-found: ignore
# Final decision. Applies R-TEST-03 honestly over the upstream job results:
# any L0/L1 failure -> BLOCK
# conformance failed -> BLOCK
# conformance skipped + cluster available -> BLOCK (should have run)
# conformance skipped + no cluster -> report UNVERIFIED (Phase 0), do not fake green
merge-gate:
runs-on: ubuntu-latest
needs: [lint, l0, l1, conformance]
if: always()
steps:
- uses: actions/checkout@v4
- name: Decide
env:
R_LINT: ${{ needs.lint.result }}
R_L0: ${{ needs.l0.result }}
R_L1: ${{ needs.l1.result }}
R_CONF: ${{ needs.conformance.result }}
run: ./.gitea/workflows/scripts/decide.sh
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# .gitea/workflows/scripts/decide.sh — merge-gate decision job for Gitea Actions.
# Reads upstream job results (Gitea/GitHub Actions values: success | failure |
# skipped | cancelled) from env and applies the R-TEST-03 rule honestly.
#
# R_LINT R_L0 R_L1 L0/L1 layers (run in CI, no cluster). Any failure -> BLOCK.
# R_CONF L2/L3 conformance job:
# success -> green
# failure/cancelled -> BLOCK
# skipped + CLUSTER_AVAILABLE=true -> BLOCK (should have run)
# skipped + no cluster -> UNVERIFIED (Phase 0), do NOT fake green
#
# Set REQUIRE_CLUSTER_TESTS=true (Phase 1+) to turn any unverified cluster-needing
# touched requirement into a hard block.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${HERE}/../../.." && pwd)"
: "${R_LINT:=}"; : "${R_L0:=}"; : "${R_L1:=}"; : "${R_CONF:=}"
: "${CLUSTER_AVAILABLE:=false}"
: "${REQUIRE_CLUSTER_TESTS:=false}"
sm() { [ -n "${GITHUB_STEP_SUMMARY:-}" ] && printf '%s\n' "$*" >> "$GITHUB_STEP_SUMMARY" || true; }
say() { printf '%s\n' "$*" >&2; }
cluster_on() { case "$CLUSTER_AVAILABLE" in true|1|yes) return 0;; *) return 1;; esac; }
block=0
sm "## Merge gate decision (R-TEST-03)"
sm ""
sm "| Stage | Result |"
sm "|---|---|"
for pair in "lint:$R_LINT" "l0:$R_L0" "l1:$R_L1"; do
name="${pair%%:*}"; res="${pair#*:}"
case "$res" in
success) say "[ ok ] ${name}: green"; sm "| ${name} | pass |" ;;
skipped|"") say "[warn] ${name}: skipped/unwired (Phase 0 placeholder) — not green"; sm "| ${name} | skipped |" ;;
*) say "[FAIL] ${name}: ${res} -> BLOCK"; sm "| ${name} | **FAIL** |"; block=1 ;;
esac
done
# Touched-requirement subset for reporting UNVERIFIED items.
mapfile -t reqs < <("${REPO_ROOT}/ci/touched-requirements.sh" 2>/dev/null || true)
case "$R_CONF" in
success)
say "[ ok ] conformance (L2/L3): green"; sm "| conformance (L2/L3) | pass |" ;;
failure|cancelled)
say "[FAIL] conformance (L2/L3): ${R_CONF} -> BLOCK"; sm "| conformance (L2/L3) | **FAIL** |"; block=1 ;;
skipped|"")
if cluster_on; then
say "[FAIL] conformance skipped but CLUSTER_AVAILABLE=true -> BLOCK"
sm "| conformance (L2/L3) | **FAIL (skipped w/ cluster)** |"; block=1
else
sm "| conformance (L2/L3) | skipped — cluster required (Phase 0) |"
if [ "${#reqs[@]}" -gt 0 ]; then
say "[warn] L2/L3 UNVERIFIED (no cluster) for touched requirements: ${reqs[*]}"
sm ""
sm "> **UNVERIFIED (no cluster, Phase 0):** ${reqs[*]}"
if [ "$REQUIRE_CLUSTER_TESTS" = "true" ]; then
say "[FAIL] REQUIRE_CLUSTER_TESTS=true and requirements unverified -> BLOCK"; block=1
fi
else
say "[skip] L2/L3 skipped; no cluster-needing requirements touched"
fi
fi ;;
*)
say "[FAIL] conformance: unexpected result '${R_CONF}' -> BLOCK"; block=1 ;;
esac
sm ""
if [ "$block" -eq 0 ]; then
say "MERGE GATE: PASS"
sm "**Result: PASS** — touched-requirement subset green for the layers that can run this phase."
exit 0
else
say "MERGE GATE: BLOCKED"
sm "**Result: BLOCKED** — see failures above."
exit 1
fi
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# .gitea/workflows/scripts/install-tools.sh — install the PINNED tools a given
# gate stage needs on a Gitea Actions runner. Versions come from ci/versions.env
# (single source of truth, R-TEST-02). Usage: install-tools.sh <lint|l0|l1|conformance>
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${HERE}/../../.." && pwd)"
# shellcheck disable=SC1091
source "${REPO_ROOT}/ci/versions.env"
stage="${1:-all}"
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"; case "$arch" in x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac
bindir="${HOME}/.local/bin"; mkdir -p "$bindir"
echo "${bindir}" >> "${GITHUB_PATH:-/dev/null}" 2>/dev/null || true
export PATH="${bindir}:${PATH}"
log() { printf '[install] %s\n' "$*" >&2; }
install_yamllint() { have yamllint || { log "yamllint ${YAMLLINT_VERSION}"; pip install --quiet "yamllint==${YAMLLINT_VERSION}"; }; }
install_kyverno() {
have kyverno && return 0
log "kyverno CLI ${KYVERNO_VERSION}"
local url="https://github.com/kyverno/kyverno/releases/download/v${KYVERNO_VERSION}/kyverno-cli_v${KYVERNO_VERSION}_${os}_${arch}.tar.gz"
curl -fsSL "$url" | tar -xz -C "$bindir" kyverno
}
install_chainsaw() {
have chainsaw && return 0
log "chainsaw ${CHAINSAW_VERSION}"
local url="https://github.com/kyverno/chainsaw/releases/download/v${CHAINSAW_VERSION}/chainsaw_${os}_${arch}.tar.gz"
curl -fsSL "$url" | tar -xz -C "$bindir" chainsaw
}
install_kubectl() {
have kubectl && return 0
log "kubectl ${KUBECTL_VERSION}"
curl -fsSL -o "${bindir}/kubectl" "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/${os}/${arch}/kubectl"
chmod +x "${bindir}/kubectl"
}
have() { command -v "$1" >/dev/null 2>&1; }
case "$stage" in
lint) install_yamllint ;;
l0) install_yamllint; install_kyverno ;;
l1) install_chainsaw ;; # L1 render via conformance runner (Chainsaw)
conformance) install_chainsaw; install_kubectl ;; # trigger the in-cluster Job
all) install_yamllint; install_kyverno; install_chainsaw; install_kubectl ;;
*) echo "unknown stage: $stage" >&2; exit 2 ;;
esac
log "stage '${stage}' tools ready"