From 0d86630271f4d795a3b93391e887d2086e3f9dde Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Sun, 16 Aug 2026 00:08:33 -0500 Subject: [PATCH] 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) --- .gitea/workflows/README.md | 116 ++++++++++++++++++- .gitea/workflows/merge-gate.yaml | 132 ++++++++++++++++++++++ .gitea/workflows/scripts/decide.sh | 81 +++++++++++++ .gitea/workflows/scripts/install-tools.sh | 53 +++++++++ ci/Makefile | 46 ++++++++ ci/README.md | 84 ++++++++++++++ ci/conformance.sh | 57 ++++++++++ ci/gate.sh | 95 ++++++++++++++++ ci/l0.sh | 68 +++++++++++ ci/l1.sh | 43 +++++++ ci/lib.sh | 84 ++++++++++++++ ci/lint.sh | 29 +++++ ci/touched-requirements.sh | 81 +++++++++++++ ci/versions.env | 22 ++++ ci/yamllint.yaml | 33 ++++++ 15 files changed, 1022 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/merge-gate.yaml create mode 100755 .gitea/workflows/scripts/decide.sh create mode 100755 .gitea/workflows/scripts/install-tools.sh create mode 100644 ci/Makefile create mode 100644 ci/README.md create mode 100755 ci/conformance.sh create mode 100755 ci/gate.sh create mode 100755 ci/l0.sh create mode 100755 ci/l1.sh create mode 100755 ci/lib.sh create mode 100755 ci/lint.sh create mode 100755 ci/touched-requirements.sh create mode 100644 ci/versions.env create mode 100644 ci/yamllint.yaml diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md index 290666e..64e49ee 100644 --- a/.gitea/workflows/README.md +++ b/.gitea/workflows/README.md @@ -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. diff --git a/.gitea/workflows/merge-gate.yaml b/.gitea/workflows/merge-gate.yaml new file mode 100644 index 0000000..42a8615 --- /dev/null +++ b/.gitea/workflows/merge-gate.yaml @@ -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 diff --git a/.gitea/workflows/scripts/decide.sh b/.gitea/workflows/scripts/decide.sh new file mode 100755 index 0000000..4e6751e --- /dev/null +++ b/.gitea/workflows/scripts/decide.sh @@ -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 diff --git a/.gitea/workflows/scripts/install-tools.sh b/.gitea/workflows/scripts/install-tools.sh new file mode 100755 index 0000000..8e3ddd3 --- /dev/null +++ b/.gitea/workflows/scripts/install-tools.sh @@ -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 +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" diff --git a/ci/Makefile b/ci/Makefile new file mode 100644 index 0000000..8d50e8e --- /dev/null +++ b/ci/Makefile @@ -0,0 +1,46 @@ +# ci/Makefile — portable, single-command local equivalent of the Gitea Actions +# merge gate (R-TEST-02: the gates are runnable TODAY, without Gitea). +# +# The Gitea Actions workflow (.gitea/workflows/merge-gate.yaml) calls these exact +# scripts, so `make -C ci gate` locally == the CI merge gate. Run from anywhere: +# make -C ci gate +# +# Every stage is a thin wrapper over a ci/*.sh script; see ci/README.md. +SHELL := /usr/bin/env bash +CI_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) + +.DEFAULT_GOAL := help + +.PHONY: help lint schema l0 l1 conformance gate all touched versions tools + +help: ## Show this help + @awk 'BEGIN{FS=":.*##"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST) + +lint: ## yaml/lint (no cluster; live now) + @"$(CI_DIR)lint.sh" + +schema l0: ## ServiceDefinition schema validation + Kyverno L0 policy gate (no cluster; live now) + @"$(CI_DIR)l0.sh" + +l1: ## L1 rendering checks (no cluster; live now) + @"$(CI_DIR)l1.sh" + +conformance: ## L2/L3 in-cluster Chainsaw Job (SKIPS w/o CLUSTER_AVAILABLE=true) + @"$(CI_DIR)conformance.sh" + +gate: ## Full merge gate: all stages + R-TEST-03 decision (the single command) + @"$(CI_DIR)gate.sh" + +all: gate ## Alias for `gate` + +touched: ## Print the touched-requirement subset for the current branch vs main + @"$(CI_DIR)touched-requirements.sh" + +versions: ## Print pinned tool versions (R-TEST-02) + @cat "$(CI_DIR)versions.env" + +tools: ## Report which required tools are installed locally + @source "$(CI_DIR)versions.env"; \ + for t in yamllint kyverno chainsaw kubectl yq git; do \ + if command -v $$t >/dev/null 2>&1; then printf " \033[32mok\033[0m %s\n" "$$t"; \ + else printf " \033[33mmiss\033[0m %s\n" "$$t"; fi; done diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..eac3253 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,84 @@ +# `ci/` — portable merge gate (runnable today, without Gitea) + +**Task T-0.2 · R-TEST-02 (reproducible, single-command) · Owner WS-GITOPS (CI).** + +The same merge gate the Gitea Actions workflow runs +([`.gitea/workflows/`](../.gitea/workflows/README.md)), as plain scripts + a Makefile so the gates are +runnable **now**, before any Gitea host or cluster exists. CI and a laptop call the *identical* +scripts, so `make -C ci gate` == the CI merge gate. + +## Quick start + +```bash +make -C ci gate # the single command: run every stage + the R-TEST-03 decision +make -C ci help # list targets +make -C ci tools # which pinned tools are installed locally +make -C ci touched # print the touched-requirement subset for this branch vs main +``` + +Individual stages: + +```bash +make -C ci lint # yaml/lint (no cluster; live now) +make -C ci l0 # ServiceDefinition schema + Kyverno (no cluster; live now) +make -C ci l1 # L1 rendering checks (no cluster; live now) +make -C ci conformance # L2/L3 in-cluster Chainsaw Job (SKIPS unless CLUSTER_AVAILABLE=true) +``` + +## Exit-code contract + +Every stage script uses: **`0` = pass · `1` = fail (blocks merge) · `3` = SKIPPED** (cluster-gated +with no cluster, or an unwired seam / missing tool). A `3` is **never** counted as green — that is how +the gate stays honest in Phase 0 (no fake green). + +## Reproducibility (R-TEST-02) + +- **Version-pinned:** all tool versions live in [`versions.env`](versions.env) — the single source of + truth shared by the local scripts and the CI installer. Bumping a version is a normal + feature-branch → main change. +- **Single command, environment-agnostic:** `make -C ci gate` on any machine with the pinned tools. +- **Deterministic:** no cluster state is assumed; L2/L3 SKIP unless a cluster is explicitly declared. + +## Files + +``` +ci/ + Makefile targets: lint · l0 · l1 · conformance · gate · touched · tools · versions + versions.env PINNED tool versions (R-TEST-02) — single source of truth + lib.sh shared helpers: logging, tool checks, cluster detection, artifact paths + yamllint.yaml lint config for the yaml/lint stage + lint.sh Stage: yaml/lint + l0.sh Stage: ServiceDefinition schema validation + Kyverno L0 policy gate + l1.sh Stage: L1 rendering checks (Chainsaw L1 via the conformance runner) + conformance.sh Stage: L2/L3 in-cluster Chainsaw Job (trigger + collect) + touched-requirements.sh compute the R-* subset a change touches (drives the R-TEST-03 filter) + gate.sh run all stages + the merge decision (the single command) + README.md this file +``` + +## Integration seams — entrypoints owned by OTHER agents + +This gate is an **orchestrator**. It does not implement schema validation, Kyverno policies, or the +conformance runner — those are other workstreams' deliverables. Each seam is **env-overridable** so +the owning agent / PM can reconcile the real entrypoint **without editing these scripts**. Defaults +below are this workstream's **assumptions** — flagged for the PM to verify at integration. + +| Seam | Owner / task | Env override | Assumed default | Assumed interface | +|---|---|---|---|---| +| ServiceDefinition schema validator | WS-GITOPS · **T-0.3** (`schemas/`) | `SCHEMA_VALIDATE_CMD` | `schemas/validate.sh` | `validate.sh catalog/*.yaml` → exit 0/1; `--version` supported | +| Kyverno L0 policies + tests | WS-TEST · **T-0.5** (`platform/kyverno/`) | `KYVERNO_POLICY_DIR` | `platform/kyverno` | contains `kyverno-test.yaml` files discoverable by `kyverno test ` | +| Conformance runner (Chainsaw) | WS-TEST · **T-0.4** (`conformance/`) | `CONFORMANCE_CMD` | `conformance/run.sh` | `run.sh --layer [--requirements R-A,R-B] [--trigger in-cluster-job] --junit --coverage ` | +| Catalog (ServiceDefinitions) | WS-APPS | `CATALOG_DIR` | `catalog` | `*.yaml` ServiceDefinition files | + +Artifact locations (git-ignored): JUnit → `results/junit/*.xml`; coverage → `reports/coverage/**`. +Override with `JUNIT_DIR` / `COVERAGE_DIR` if the runner writes elsewhere. + +**A seam that is missing or not executable reports SKIPPED (exit 3), not a false pass** — so during +parallel Phase 0 development the gate is green-honest, and each seam goes live automatically the moment +its owner delivers a runnable entrypoint. + +## Cluster gating + +`CLUSTER_AVAILABLE=true` (env locally, `vars.CLUSTER_AVAILABLE` in Gitea) makes the L2/L3 stage run and +be **required**; anything else makes it **SKIP** (Phase 0). `REQUIRE_CLUSTER_TESTS=true` (Phase 1+) +turns any unverified cluster-needing touched requirement into a hard block. diff --git a/ci/conformance.sh b/ci/conformance.sh new file mode 100755 index 0000000..4d0fa3b --- /dev/null +++ b/ci/conformance.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# ci/conformance.sh — Stage 5: L2 behavioral + L3 fault-injection. +# +# Per docs/14 + ADR-0011, L2/L3 run as an IN-CLUSTER Chainsaw Job on the dev/test +# cluster; CI only *triggers* the Job and *collects* its JUnit + coverage. CI holds +# no broad kubeconfig — the Job's own scoped ServiceAccount holds cluster access. +# +# PHASE 0: no cluster exists. This stage therefore SKIPS (exit 3) — clearly +# reported, never faked green (HARD RULE 4). It goes live in Phase 1+ once a +# cluster is declared via CLUSTER_AVAILABLE=true. +set -euo pipefail +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/lib.sh" + +log "Stage: L2/L3 conformance (in-cluster Chainsaw Job)" +mkartifacts + +if ! cluster_available; then + skip "no cluster configured (CLUSTER_AVAILABLE!=true) — L2/L3 NOT RUN. Phase 0: expected." + skip "these requirements remain UNVERIFIED until a dev/test cluster exists (Phase 1)." + summary "- conformance-l2-l3: **SKIPPED — cluster required** (Phase 0; not a pass)" + exit 3 +fi + +if ! entrypoint_ready "$CONFORMANCE_CMD"; then + die "CLUSTER_AVAILABLE=true but conformance runner '${CONFORMANCE_CMD}' is missing (T-0.4)" +fi + +# Touched-requirement subset (R-TEST-03) — the merge gate runs only the tagged +# subset the change touches, not the whole suite. +mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true) +req_args=() +if [ "${#reqs[@]}" -gt 0 ]; then + log "touched requirements: ${reqs[*]}" + req_args=(--requirements "$(IFS=,; echo "${reqs[*]}")") +else + log "no touched requirements detected — running full L2/L3 subset as a safety net" +fi + +# Contract we ASSUME of conformance/run.sh (flag for PM to reconcile): +# --layer l2,l3 behavioral + fault-injection layers +# --trigger in-cluster-job create/await the in-cluster Chainsaw Job (Job holds +# cluster creds via its scoped SA; CI uses only a +# narrow trigger credential, referenced by SECRET NAME +# in the workflow: CONFORMANCE_TRIGGER_KUBECONFIG) +# --requirements R-A,R-B touched subset (R-TEST-03) +# --junit --coverage collected artifacts feed the gate +"$CONFORMANCE_CMD" \ + --layer l2,l3 \ + --trigger in-cluster-job \ + "${req_args[@]}" \ + --junit "${JUNIT_DIR}/l2l3.junit.xml" \ + --coverage "${COVERAGE_DIR}" + +ok "L2/L3 conformance passed for touched subset" +summary "- conformance-l2-l3: **passed**" diff --git a/ci/gate.sh b/ci/gate.sh new file mode 100755 index 0000000..518379e --- /dev/null +++ b/ci/gate.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# ci/gate.sh — the merge-gate decision. Runs every stage in order and applies the +# R-TEST-03 rule: a merge is allowed only when the touched-requirement subset is +# GREEN. Honestly distinguishes three outcomes per stage: +# pass (0) green +# fail (1) BLOCKS the merge +# skip (3) not run (cluster-gated stage w/ no cluster, or unwired seam) — +# NOT counted as green (HARD RULE 4: don't fake green) +# +# Phase 0 policy (no cluster): L0/L1 stages (lint, schema, kyverno, L1 render) +# MUST be green. L2/L3 SKIP. If the touched subset needs L2/L3 and no cluster is +# available, the gate reports those requirements UNVERIFIED and — unless +# REQUIRE_CLUSTER_TESTS=true — does not hard-block Phase-0 bootstrap work, while +# making the gap loud. Set REQUIRE_CLUSTER_TESTS=true (Phase 1+) to make any +# skipped, cluster-needing touched requirement a hard block. +set -uo pipefail # NOTE: no -e; we want to run all stages and aggregate. +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/lib.sh" +set +e # lib.sh enables -e; the gate must run ALL stages and aggregate, not abort. + +: "${REQUIRE_CLUSTER_TESTS:=false}" + +declare -A OUTCOME +run_stage() { + local name="$1"; shift + log "==== stage: ${name} ====" + "$@" + local rc=$? + case "$rc" in + 0) OUTCOME[$name]="pass"; ok "stage ${name}: pass" ;; + 3) OUTCOME[$name]="skip"; skip "stage ${name}: SKIPPED (not run)" ;; + *) OUTCOME[$name]="fail"; err "stage ${name}: FAIL" ;; + esac +} + +summary "## Merge gate (R-TEST-03)" +summary "" + +run_stage lint "${CI_DIR}/lint.sh" +run_stage l0 "${CI_DIR}/l0.sh" +run_stage l1 "${CI_DIR}/l1.sh" +run_stage conformance "${CI_DIR}/conformance.sh" + +echo >&2 +log "==== gate decision ====" +BLOCK=0 + +# L0/L1 stages: any failure blocks. A SKIP here means a seam is unwired — in +# Phase 0 that is tolerated (placeholders), but surfaced. +for s in lint l0 l1; do + case "${OUTCOME[$s]}" in + fail) err "${s}: FAILED -> blocks merge"; BLOCK=1 ;; + skip) warn "${s}: skipped (unwired seam / missing tool) — not green" ;; + pass) ok "${s}: green" ;; + esac +done + +# L2/L3 conformance: cluster-gated. +case "${OUTCOME[conformance]}" in + fail) + err "conformance (L2/L3): FAILED -> blocks merge"; BLOCK=1 ;; + pass) + ok "conformance (L2/L3): green" ;; + skip) + if cluster_available; then + err "conformance: skipped despite CLUSTER_AVAILABLE=true -> blocks merge"; BLOCK=1 + else + mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true) + if [ "${#reqs[@]}" -gt 0 ]; then + warn "conformance L2/L3 UNVERIFIED for touched requirements: ${reqs[*]}" + warn " (no cluster — Phase 0). These are NOT certified green." + summary "- **UNVERIFIED (no cluster):** ${reqs[*]}" + if [ "$REQUIRE_CLUSTER_TESTS" = "true" ]; then + err "REQUIRE_CLUSTER_TESTS=true and cluster-needing requirements are unverified -> blocks merge" + BLOCK=1 + fi + else + skip "conformance L2/L3 skipped; no cluster-needing requirements touched" + fi + fi ;; +esac + +echo >&2 +if [ "$BLOCK" -eq 0 ]; then + ok "MERGE GATE: PASS (touched-requirement subset green for the layers that can run)" + summary "" + summary "**Result: PASS** — L0/L1 green. L2/L3 status per cluster availability above." + exit 0 +else + err "MERGE GATE: BLOCKED" + summary "" + summary "**Result: BLOCKED** — see failures above." + exit 1 +fi diff --git a/ci/l0.sh b/ci/l0.sh new file mode 100755 index 0000000..b6b4dec --- /dev/null +++ b/ci/l0.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# ci/l0.sh — Stage 2+3: ServiceDefinition schema validation + Kyverno L0 policy gate. +# Both run in CI WITHOUT a cluster (docs/14 §"Where each layer runs") — live now. +# +# Stage 2 schema : catalog/*.yaml validated against the ServiceDefinition +# schema (owner: T-0.3, schemas/). +# Stage 3 L0 : `kyverno test` over platform/kyverno/ — default-deny baseline +# present, no plaintext secrets, required labels/egress-class, +# ServiceDefinition schema conformance (owner: T-0.5, ADR-0011). +# +# Integration seams are env-overridable (see ci/lib.sh + README). When an owning +# subtree is still a placeholder, this reports SKIPPED (exit 3) for that sub-stage +# rather than a false pass — the gate then knows the seam is unwired, not green. +set -euo pipefail +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/lib.sh" + +rc=0 + +# ---- Stage 2: ServiceDefinition schema validation --------------------------- +log "Stage: ServiceDefinition schema validation (catalog/ against schemas/)" +if ! entrypoint_ready "$SCHEMA_VALIDATE_CMD"; then + skip "schema validator not present at '${SCHEMA_VALIDATE_CMD}' (T-0.3 / schemas/ not delivered yet) — UNWIRED, not green" + summary "- schema: **SKIPPED** (validator entrypoint \`${SCHEMA_VALIDATE_CMD}\` missing — T-0.3)" +else + if [ -d "$CATALOG_DIR" ] && compgen -G "${CATALOG_DIR}/*.y*ml" >/dev/null 2>&1; then + if "$SCHEMA_VALIDATE_CMD" "${CATALOG_DIR}"/*.y*ml; then + ok "schema validation passed" + summary "- schema: **passed**" + else + err "schema validation failed"; summary "- schema: **FAILED**"; rc=1 + fi + else + log "no ServiceDefinitions in ${CATALOG_DIR} yet — validating validator is callable only" + "$SCHEMA_VALIDATE_CMD" --version >/dev/null 2>&1 || true + ok "schema validator present; no catalog entries to validate" + summary "- schema: **passed** (no catalog entries yet)" + fi +fi + +# ---- Stage 3: Kyverno L0 policy gate ---------------------------------------- +log "Stage: Kyverno L0 policy gate (kyverno test ${KYVERNO_POLICY_DIR})" +# `kyverno test ` discovers kyverno-test.yaml files recursively. +if ! have kyverno; then + skip "kyverno CLI not installed (pin v${KYVERNO_VERSION}) — cannot run L0 gate" + summary "- kyverno-l0: **SKIPPED** (kyverno CLI not installed)" + # Missing tool is an environment gap, not a code pass -> exit 3 unless already failing. + [ "$rc" -eq 0 ] && rc=3 +elif [ ! -d "$KYVERNO_POLICY_DIR" ] || ! compgen -G "${KYVERNO_POLICY_DIR}/**/kyverno-test.yaml" >/dev/null 2>&1 \ + && ! find "$KYVERNO_POLICY_DIR" -name 'kyverno-test.yaml' 2>/dev/null | grep -q .; then + skip "no kyverno-test.yaml under '${KYVERNO_POLICY_DIR}' (T-0.5 / platform/kyverno not delivered yet) — UNWIRED, not green" + summary "- kyverno-l0: **SKIPPED** (no policy tests yet — T-0.5)" +else + if kyverno test "$KYVERNO_POLICY_DIR"; then + ok "Kyverno L0 policy tests passed" + summary "- kyverno-l0: **passed**" + else + err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1 + fi +fi + +case "$rc" in + 0) ok "L0 stage complete" ;; + 3) skip "L0 stage incomplete (unwired seam / missing tool) — not a pass" ;; + *) err "L0 stage failed" ;; +esac +exit "$rc" diff --git a/ci/l1.sh b/ci/l1.sh new file mode 100755 index 0000000..8e72095 --- /dev/null +++ b/ci/l1.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# ci/l1.sh — Stage 4: L1 rendering checks. Runs in CI WITHOUT a cluster +# (docs/14: "L1 rendering assertions run in CI without a cluster"). +# +# L1 asserts ServiceDefinition -> rendered resources match golden expectations +# (e.g. `egress: vpn` renders the kill-switched egress path). The actual render +# + assert is Chainsaw's L1 layer, owned by the conformance harness (T-0.4). +# CI just invokes the runner filtered to the L1 layer and the touched subset. +set -euo pipefail +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/lib.sh" + +log "Stage: L1 rendering checks (no cluster)" +mkartifacts + +if ! entrypoint_ready "$CONFORMANCE_CMD"; then + skip "conformance runner not present at '${CONFORMANCE_CMD}' (T-0.4 / conformance/ not delivered yet) — UNWIRED, not green" + summary "- l1-render: **SKIPPED** (conformance runner missing — T-0.4)" + exit 3 +fi + +# Touched-requirement subset (R-TEST-03). Empty subset => run all L1 as a sanity net. +mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true) +req_args=() +if [ "${#reqs[@]}" -gt 0 ]; then + log "touched requirements: ${reqs[*]}" + req_args=(--requirements "$(IFS=,; echo "${reqs[*]}")") +fi + +# Contract we ASSUME of conformance/run.sh (flag for PM to reconcile): +# --layer l1 select the L1 rendering layer (no cluster) +# --requirements R-A,R-B filter to the touched subset +# --junit write JUnit XML +# --coverage write the requirement-coverage report +"$CONFORMANCE_CMD" \ + --layer l1 \ + "${req_args[@]}" \ + --junit "${JUNIT_DIR}/l1.junit.xml" \ + --coverage "${COVERAGE_DIR}" + +ok "L1 rendering checks passed" +summary "- l1-render: **passed**" diff --git a/ci/lib.sh b/ci/lib.sh new file mode 100755 index 0000000..5ebd1e5 --- /dev/null +++ b/ci/lib.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# ci/lib.sh — shared helpers for the merge-gate scripts. +# Sourced by every ci/*.sh; never executed directly. +# +# Conventions: +# - All scripts: `set -euo pipefail`. +# - Log to stderr; machine-readable output (e.g. requirement lists) to stdout. +# - Exit codes: 0 = pass, 1 = fail (gate blocks), 3 = SKIPPED/not-run +# (cluster-gated stage with no cluster — NOT a pass, NOT a fail). +set -euo pipefail + +# --- Paths ------------------------------------------------------------------- +# CI_DIR = this ci/ directory; REPO_ROOT = its parent (impl repo root). +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/versions.env" + +# --- Integration points (other agents' entrypoints) -------------------------- +# These are the seams this gate depends on. Each has an env override so the PM / +# owning agent can reconcile the real entrypoint at integration WITHOUT editing +# these scripts. Defaults are this workstream's *assumptions* — see README. +: "${CATALOG_DIR:=${REPO_ROOT}/catalog}" # ServiceDefinition sources +: "${SCHEMAS_DIR:=${REPO_ROOT}/schemas}" # owned by T-0.3 (schema + renderer contract) +: "${KYVERNO_POLICY_DIR:=${REPO_ROOT}/platform/kyverno}" # owned by T-0.5 (L0 Kyverno policies + tests) +: "${CONFORMANCE_DIR:=${REPO_ROOT}/conformance}" # owned by T-0.4 (Chainsaw harness + coverage) +# Commands (overridable). Defaults documented in ci/README.md. +: "${SCHEMA_VALIDATE_CMD:=${SCHEMAS_DIR}/validate.sh}" # ASSUMED: validates catalog/*.yaml against the ServiceDefinition schema +: "${CONFORMANCE_CMD:=${CONFORMANCE_DIR}/run.sh}" # ASSUMED: single-command conformance runner (R-TEST-02) + +# --- Artifact locations (git-ignored: see .gitignore) ------------------------ +: "${RESULTS_DIR:=${REPO_ROOT}/results}" # JUnit XML lands here +: "${REPORTS_DIR:=${REPO_ROOT}/reports}" # requirement-coverage report lands here +: "${JUNIT_DIR:=${RESULTS_DIR}/junit}" +: "${COVERAGE_DIR:=${REPORTS_DIR}/coverage}" + +# --- Logging ----------------------------------------------------------------- +_is_tty() { [ -t 2 ]; } +if _is_tty; then C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' +else C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""; C_DIM=""; C_RST=""; fi + +log() { printf '%s[gate]%s %s\n' "$C_BLU" "$C_RST" "$*" >&2; } +ok() { printf '%s[ ok ]%s %s\n' "$C_GRN" "$C_RST" "$*" >&2; } +warn() { printf '%s[warn]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } +err() { printf '%s[fail]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; } +skip() { printf '%s[skip]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } +die() { err "$*"; exit 1; } + +# Emit a GitHub/Gitea-Actions step-summary line if running in Actions. +summary() { [ -n "${GITHUB_STEP_SUMMARY:-}" ] && printf '%s\n' "$*" >> "$GITHUB_STEP_SUMMARY" || true; } + +# --- Tooling ----------------------------------------------------------------- +have() { command -v "$1" >/dev/null 2>&1; } + +# require_tool — fail with an actionable message if missing. +require_tool() { + have "$1" && return 0 + die "required tool '$1' not found. $2" +} + +# entrypoint_ready — returns 0 only if it exists AND is runnable. +# Used to distinguish 'other agent hasn't delivered yet / non-executable stub' +# (-> SKIP) from a wired entrypoint that genuinely fails (-> FAIL). A file that +# exists but isn't executable counts as NOT ready (unwired), not a failure. +entrypoint_ready() { + local ep="$1" + have "$ep" && return 0 # a bare command on PATH + [ -f "$ep" ] && [ -x "$ep" ] && return 0 # an executable file + return 1 +} + +# --- Cluster detection (drives SKIP vs run for L2/L3) ------------------------- +# A cluster is 'available' only if explicitly declared. We NEVER auto-assume a +# cluster in Phase 0 — silence means SKIP, not pass (HARD RULE 4). +# CLUSTER_AVAILABLE=true -> L2/L3 run and are REQUIRED green +# CLUSTER_AVAILABLE=false/unset -> L2/L3 SKIP (reported, never faked green) +cluster_available() { + case "${CLUSTER_AVAILABLE:-false}" in + true|1|yes) return 0 ;; + *) return 1 ;; + esac +} + +mkartifacts() { mkdir -p "$JUNIT_DIR" "$COVERAGE_DIR"; } diff --git a/ci/lint.sh b/ci/lint.sh new file mode 100755 index 0000000..be703aa --- /dev/null +++ b/ci/lint.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# ci/lint.sh — Stage 1: yaml/lint. Runs in CI without a cluster (live now). +# Lints all YAML in the repo against ci/yamllint.yaml. +set -euo pipefail +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/lib.sh" + +log "Stage: yaml/lint" +require_tool yamllint "Install: pip install 'yamllint==${YAMLLINT_VERSION}'" + +# Lint tracked + untracked (not yet committed) YAML; skip .git and artifact dirs. +cd "$REPO_ROOT" +mapfile -t files < <( + { git ls-files '*.yaml' '*.yml' 2>/dev/null + git ls-files --others --exclude-standard '*.yaml' '*.yml' 2>/dev/null + } | sort -u +) +# Fallback for a non-git checkout. +if [ "${#files[@]}" -eq 0 ]; then + mapfile -t files < <(find . -path ./.git -prune -o \( -name '*.yaml' -o -name '*.yml' \) -print) +fi +if [ "${#files[@]}" -eq 0 ]; then + ok "no YAML files to lint" + exit 0 +fi + +yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${files[@]}" +ok "yaml/lint passed (${#files[@]} files)" diff --git a/ci/touched-requirements.sh b/ci/touched-requirements.sh new file mode 100755 index 0000000..14320a3 --- /dev/null +++ b/ci/touched-requirements.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# ci/touched-requirements.sh — compute the requirement subset a change "touches". +# +# R-TEST-03 gates a merge on the conformance subset tagged with the requirement +# IDs the change touches. This script derives that subset from the diff so the +# conformance runner can be filtered to it (conformance.sh --requirements ...). +# +# Output (stdout): newline-separated, sorted, unique R-* IDs. Nothing else goes +# to stdout, so callers can `mapfile`/`$(...)` it directly. +# +# Detection = union of two signals: +# 1. R-* tokens literally present in the diff of changed files (tests, policies, +# and ServiceDefinitions carry their R-IDs per docs/14 + ADR-0011). +# 2. A coarse path->requirement map for changes that don't self-declare IDs. +# +# ASSUMPTION (flag for PM): the authoritative change->requirement mapping is +# really WS-TEST's coverage tooling (tests tag their own R-IDs). This script is a +# CI-side approximation good enough to *filter* the subset; at integration it +# should defer to / cross-check the coverage report emitted by conformance/. +set -euo pipefail +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${CI_DIR}/lib.sh" + +# Base ref to diff against. In Gitea/GitHub Actions PR runs this is the target +# branch (main). Locally, default to main; override with BASE_REF. +: "${BASE_REF:=}" +resolve_base() { + if [ -n "$BASE_REF" ]; then echo "$BASE_REF"; return; fi + if [ -n "${GITHUB_BASE_REF:-}" ]; then echo "origin/${GITHUB_BASE_REF}"; return; fi + if git -C "$REPO_ROOT" rev-parse --verify -q origin/main >/dev/null; then echo "origin/main"; return; fi + if git -C "$REPO_ROOT" rev-parse --verify -q main >/dev/null; then echo "main"; return; fi + echo "HEAD~1" # last resort +} + +changed_files() { + local base; base="$(resolve_base)" + # Three-dot: changes on this branch since it diverged from base. + if git -C "$REPO_ROOT" diff --name-only "${base}...HEAD" 2>/dev/null; then return; fi + git -C "$REPO_ROOT" diff --name-only "${base}" 2>/dev/null || true +} + +# Coarse path -> requirement map. Deliberately conservative; extend as subtrees +# fill in. Left side is a path prefix (relative to repo root). +path_requirements() { + case "$1" in + catalog/*) echo "R-GITOPS-02" ;; + schemas/*) echo "R-GITOPS-02" ;; + platform/kyverno/*) echo "R-TEST-01 R-NET-01 R-SEC-01" ;; + platform/cilium/*) echo "R-NET-01 R-NET-02 R-NET-05" ;; + platform/longhorn/*) echo "R-STATE-01 R-STATE-02" ;; + platform/cert-manager/*) echo "R-TLS-01 R-TLS-02" ;; + platform/externaldns/*) echo "R-DNS-02" ;; + platform/velero/*) echo "R-STATE-03" ;; + platform/monitoring/*) echo "R-OBS-01 R-OBS-02" ;; + platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;; + conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;; + clusters/*) echo "R-GITOPS-01 R-ENV-02" ;; + .gitea/*|ci/*) echo "R-GITOPS-01 R-TEST-02 R-TEST-03" ;; + *) : ;; + esac +} + +main() { + local files reqs="" + files="$(changed_files)" + [ -n "$files" ] || { log "no changed files vs base; empty touched-requirement set"; exit 0; } + + while IFS= read -r f; do + [ -n "$f" ] || continue + reqs+=" $(path_requirements "$f")" + # Scan file contents for self-declared R-IDs (only if it still exists). + if [ -f "${REPO_ROOT}/${f}" ]; then + reqs+=" $(grep -hoE 'R-[A-Z]+-[0-9]+' "${REPO_ROOT}/${f}" 2>/dev/null | tr '\n' ' ' || true)" + fi + done <<< "$files" + + # Normalize: split, unique, sort. Emit to stdout. + printf '%s\n' $reqs | sed '/^$/d' | sort -u +} +main "$@" diff --git a/ci/versions.env b/ci/versions.env new file mode 100644 index 0000000..c80a073 --- /dev/null +++ b/ci/versions.env @@ -0,0 +1,22 @@ +# ci/versions.env — pinned tool versions (R-TEST-02: reproducible, version-pinned) +# +# Single source of truth for every tool the gate invokes. The Gitea Actions +# workflow and the local scripts both source this file so CI and a laptop run +# the exact same versions. Bump deliberately; a version change is a code change +# that must go through the normal feature-branch -> main gate. +# +# Owner: WS-GITOSS (CI). The Kyverno/Chainsaw versions MUST stay in lockstep with +# whatever platform/kyverno and conformance/ pin — see integration notes in README. + +# L0 policy gate (Kyverno CLI) + ServiceDefinition validation (kyverno-json) +KYVERNO_VERSION="1.13.4" +KYVERNO_JSON_VERSION="0.0.5" + +# L1/L2/L3 declarative runner (Chainsaw) — invoked via conformance/ entrypoint, +# pinned here so a local run matches CI even before the in-cluster Job exists. +CHAINSAW_VERSION="0.2.12" + +# Lint / manifest tooling +YAMLLINT_VERSION="1.35.1" +KUBECTL_VERSION="1.31.4" +YQ_VERSION="4.44.6" diff --git a/ci/yamllint.yaml b/ci/yamllint.yaml new file mode 100644 index 0000000..5b9d58f --- /dev/null +++ b/ci/yamllint.yaml @@ -0,0 +1,33 @@ +# ci/yamllint.yaml — lint config for the yaml/lint gate stage. +# Kept under ci/ (not repo root) because WS-GITOPS(CI) owns only .gitea/ and ci/. +# Pass with: yamllint -c ci/yamllint.yaml +extends: default + +yaml-files: + - "*.yaml" + - "*.yml" + +rules: + # Kubernetes / Kustomize / Helm manifests routinely exceed 80 cols; allow room + # but keep a sane ceiling so lines stay reviewable. + line-length: + max: 160 + level: warning + # '---' at the top of multi-doc manifests is idiomatic; don't force it, don't ban it. + document-start: disable + # Helm/Kustomize templates and CRDs use 'on:', 'yes'/'no' keys legitimately. + truthy: + allowed-values: ["true", "false"] + check-keys: false + # Gitea/GitHub Actions expressions `${{ ... }}` and inline flow maps + # (`with: { fetch-depth: 0 }`) legitimately carry a space inside braces. + braces: + max-spaces-inside: 1 + min-spaces-inside: 0 + comments: + min-spaces-from-content: 1 + # Long inline comments in manifests are common. + comments-indentation: disable + indentation: + spaces: 2 + indent-sequences: consistent