diff --git a/.gitea/workflows/gate.yml b/.gitea/workflows/gate.yml index b9f05b6..d4354ae 100644 --- a/.gitea/workflows/gate.yml +++ b/.gitea/workflows/gate.yml @@ -1,19 +1,27 @@ # .gitea/workflows/gate.yml — the ENFORCED PR merge gate (Phase 6, WS-GITOSS). # # This is the LIVE gate wired to the in-cluster Gitea Actions runner (ns gitea-actions, -# Docker executor + DinD). It runs the STATIC layers of the conformance gate — the ones -# docs/14 says run "without a cluster": spec-immutability (best-effort), lint, Kyverno L0, -# the render==golden reproducibility check, and L1 offline render checks. It is the single -# required status check on `main` -# (see branch protection). `make -C ci lint l0 render-golden l1` here is byte-identical to a laptop run -# (R-TEST-02) — the runner just installs the pinned tools from ci/versions.env first. +# Docker executor + DinD). It invokes the AGGREGATE merge gate — `make -C ci gate` — so CI +# runs BYTE-FOR-BYTE the same command a human runs in the devcontainer (R-TEST-02): the +# runner just installs the pinned tools from ci/versions.env first. `make -C ci gate` +# (ci/gate.sh) runs every stage in order — spec-immutability, lint, Kyverno L0, +# render==golden, L1 offline render — and applies the R-TEST-03 decision, printing +# "MERGE GATE: PASS" / "MERGE GATE: BLOCKED". It is the single required status check on +# `main` (see branch protection). A non-zero exit fails the job, so a BLOCKED gate blocks +# the merge. # -# The L2/L3 CONFORMANCE layer (behavioural + fault-injection, needs a live cluster) is -# NOT wired yet — it is a deliberate design decision (a CI toolchain image + a scoped -# in-cluster kubeconfig/RBAC for the runner). It is present below as an `if: false` -# placeholder so the shape is visible and the wiring is a one-line flip once decided. -# The full pipeline blueprint (spec-checkout, per-layer jobs, decision job) is retained -# dormant in merge-gate.yaml. +# The cluster-gated L2/L3 conformance stage SKIPS honestly here (exit 3, surfaced not faked +# — HARD RULE 4) because this runner declares no cluster: `make -C ci gate` does not set +# CLUSTER_AVAILABLE, so gate.sh reports the touched requirements UNVERIFIED without blocking +# Phase-0/6 static work. L2/L3 run as an IN-CLUSTER Chainsaw Job (its own scoped SA; CI holds +# no broad kubeconfig) driven by the separate `conformance` job below. +# +# NOTE (R-TEST-06 / gate-semantics): spec-immutability is ALSO surfaced as its own explicit +# early step (below) so its result is a distinct, tolerant status in the Gitea UI; gate.sh +# re-runs it as its first stage, which is the authoritative one. `make -C ci gate` here is +# the byte-identical laptop gate; the extra pre-step is a read-only git-diff check and is +# purely additive. The full pipeline blueprint (spec-checkout, per-layer jobs, decision job) +# is retained dormant in merge-gate.yaml. --- name: gate @@ -47,19 +55,27 @@ jobs: ./.gitea/workflows/scripts/install-tools.sh l0 ./.gitea/workflows/scripts/install-tools.sh l1 - # R-TEST-06: the spec is read-only to agents. The spec repo is NOT checked out into - # CI here (it is a separate repo; SPEC_REPO_SLUG is not wired into this runner), so - # the guard honestly SKIPS (exit 3) rather than faking green. A real drift/edit of a - # co-located spec would exit 1 and fail. Tolerated: exit 3 only. - # Call the script DIRECTLY, not via `make`: GNU make masks a recipe's exit code as - # its own 2 on failure, so `make spec-immutability || [ $? -eq 3 ]` would see 2 and - # fail. The script itself exits 3 on an honest skip (spec repo not checked out here), - # which we tolerate; a real drift/edit exits 1 and fails the gate. + # R-TEST-06: the spec is read-only to agents. An EXPLICIT early surfacing of the + # spec-immutability check, so its result is a distinct, tolerant status line in the + # Gitea UI before the (longer) aggregate gate log. The spec repo is NOT checked out + # into CI here (separate repo; SPEC_REPO_SLUG not wired into this runner), so the guard + # honestly SKIPS (exit 3) rather than faking green; a real drift/edit of a co-located + # spec exits 1 and fails. Tolerated: exit 3 only. Call the script DIRECTLY, not via + # `make spec-immutability`: GNU make masks a recipe's exit code as its own 2, so + # `make spec-immutability || [ $? -eq 3 ]` would see 2 and fail. The AUTHORITATIVE + # spec-immutability run is the FIRST stage of `make -C ci gate` below (gate.sh calls + # the script directly, so it sees exit 3 → SKIP, not a mask); this pre-step is a cheap, + # read-only git-diff duplicate that only adds early UI signal. - name: Spec immutability (R-TEST-06, skips if spec repo absent) run: ./ci/spec-immutability.sh || [ "$?" -eq 3 ] - - name: Static gate — lint + Kyverno L0 + render==golden + L1 render - run: make -C ci lint l0 render-golden l1 + # Aggregate merge gate: byte-for-byte the command a human runs in the devcontainer + # (`make -C ci gate` -> ci/gate.sh). Runs spec-immutability, lint, Kyverno L0, + # render==golden and L1 render, then the R-TEST-03 decision. Exits non-zero on a + # BLOCKED gate, which fails this job. The cluster-gated L2/L3 stage SKIPS here (no + # CLUSTER_AVAILABLE) — surfaced as UNVERIFIED, never faked green (see header). + - name: Merge gate — make -C ci gate + run: make -C ci gate # NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4 # ("not supported on GHES" — Gitea does not implement the artifact-v2 backend), which diff --git a/ci/Makefile b/ci/Makefile index 986af72..632926f 100644 --- a/ci/Makefile +++ b/ci/Makefile @@ -1,8 +1,9 @@ # 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: +# The LIVE Gitea Actions workflow (.gitea/workflows/gate.yml) invokes `make -C ci gate` +# directly, so `make -C ci gate` locally IS the CI merge gate — byte-for-byte. Run from +# anywhere: # make -C ci gate # # Every stage is a thin wrapper over a ci/*.sh script; see ci/README.md. @@ -11,7 +12,7 @@ CI_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) .DEFAULT_GOAL := help -.PHONY: help spec-immutability lint schema l0 render-golden l1 conformance gate all touched versions tools +.PHONY: help spec-immutability lint schema l0 render-golden l1 conformance gate all touched versions tools test help: ## Show this help @awk 'BEGIN{FS=":.*##"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST) @@ -42,6 +43,9 @@ all: gate ## Alias for `gate` touched: ## Print the touched-requirement subset for the current branch vs main @"$(CI_DIR)touched-requirements.sh" +test: ## Run ci/ script unit tests (no cluster; e.g. touched-requirements content scan) + @for t in "$(CI_DIR)"tests/*.test.sh; do echo "== $$t"; bash "$$t"; done + versions: ## Print pinned tool versions (R-TEST-02) @cat "$(CI_DIR)versions.env" diff --git a/ci/lib.sh b/ci/lib.sh index 5ebd1e5..0e6cbc2 100755 --- a/ci/lib.sh +++ b/ci/lib.sh @@ -10,9 +10,12 @@ set -euo pipefail # --- Paths ------------------------------------------------------------------- -# CI_DIR = this ci/ directory; REPO_ROOT = its parent (impl repo root). +# CI_DIR = this ci/ directory (always the real script location). REPO_ROOT = its +# parent (impl repo root) by default, but OVERRIDABLE via the environment so the +# scripts can be pointed at a throwaway git repo under test (see ci/tests/) without +# copying them out of the tree. CI and normal runs leave it unset -> unchanged. CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)" +: "${REPO_ROOT:=$(cd "${CI_DIR}/.." && pwd)}" # shellcheck disable=SC1091 source "${CI_DIR}/versions.env" diff --git a/ci/tests/touched-requirements.test.sh b/ci/tests/touched-requirements.test.sh new file mode 100755 index 0000000..36812ea --- /dev/null +++ b/ci/tests/touched-requirements.test.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# ci/tests/touched-requirements.test.sh — regression tests for the content scan in +# ci/touched-requirements.sh, focused on rule 3: R-IDs cited only in a whole-line +# YAML/shell `#` comment are PROSE (a reference), not a declaration, and must NOT be +# attributed — while R-IDs on real code/spec lines still ARE. +# +# The tests point the real script at a THROWAWAY git repo via the REPO_ROOT override +# (ci/lib.sh honours a pre-set REPO_ROOT); the script itself is run from its true +# location so it still sources the real lib.sh. No network, no cluster. +# +# Run: ci/tests/touched-requirements.test.sh (or `make -C ci test`) +set -euo pipefail + +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${CI_DIR}/touched-requirements.sh" + +PASS=0 FAIL=0 +ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); } +bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); } + +# assert_absent +assert_absent() { + if grep -qxF "$1" <<<"$2"; then bad "$3 (found unexpected '$1' in: $(tr '\n' ' ' <<<"$2"))"; + else ok "$3"; fi +} +# assert_present +assert_present() { + if grep -qxF "$1" <<<"$2"; then ok "$3"; + else bad "$3 (missing '$1' from: $(tr '\n' ' ' <<<"$2"))"; fi +} + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +git -C "$TMP" init -q -b main +git -C "$TMP" config user.email t@t.t +git -C "$TMP" config user.name t +git -C "$TMP" config commit.gpgsign false # throwaway repo: never sign (ignore host gpg config) +mkdir -p "$TMP/notes" # a path OUTSIDE path_requirements() so we isolate the content scan +echo "baseline" > "$TMP/notes/base.txt" +git -C "$TMP" add -A && git -C "$TMP" commit -qm baseline + +git -C "$TMP" checkout -q -b feature + +# A YAML file: an R-ID in a whole-line comment (must be IGNORED) and an R-ID on a real +# value line (must be DETECTED). Also an indented comment, to prove leading space is fine. +cat > "$TMP/notes/example.yaml" <<'YAML' +# This header explains R-DOC-01 and must NOT be attributed. +kind: Thing +metadata: + name: implements-R-REAL-01 +spec: + note: value # trailing inline comment mentioning R-INLINE-01 (conservatively still seen) + # deeply indented comment about R-DOC-02 — also NOT attributed +YAML + +# A shell file: a `#` comment R-ID (IGNORED) plus a real command line R-ID (DETECTED). +cat > "$TMP/notes/example.sh" <<'SH' +#!/usr/bin/env bash +# doc: this script relates to R-SHDOC-01 (comment only — ignored) +echo "runs check for R-SHREAL-01" +SH + +git -C "$TMP" add -A && git -C "$TMP" commit -qm feature + +OUT="$(REPO_ROOT="$TMP" BASE_REF=main "$SCRIPT" 2>/dev/null)" +printf 'touched set:\n%s\n---\n' "$OUT" + +# Comment-only citations are dropped (rule 3): +assert_absent "R-DOC-01" "$OUT" "whole-line YAML comment R-ID is NOT attributed" +assert_absent "R-DOC-02" "$OUT" "indented YAML comment R-ID is NOT attributed" +assert_absent "R-SHDOC-01" "$OUT" "shell '#' comment R-ID is NOT attributed" +# Real code/spec lines still count: +assert_present "R-REAL-01" "$OUT" "R-ID on a real YAML value line IS attributed" +assert_present "R-SHREAL-01" "$OUT" "R-ID on a real shell command line IS attributed" +# Conservative: a trailing inline comment on a REAL line is still seen (only whole-line +# comments are dropped) — documents the intentional boundary of the fix. +assert_present "R-INLINE-01" "$OUT" "inline comment on a real line is (conservatively) still seen" + +# Removed comment lines are also excluded, while removed real lines still count. The file +# must EXIST at the base, so we diff the removal against the commit that added it. +git -C "$TMP" checkout -q -b feature2 main +cat > "$TMP/notes/todelete.yaml" <<'YAML' +# will delete a comment citing R-DELDOC-01 and a real line citing R-DELREAL-01 +kind: Thing +name: implements-R-DELREAL-01 +YAML +git -C "$TMP" add -A && git -C "$TMP" commit -qm add +ADD_SHA="$(git -C "$TMP" rev-parse HEAD)" +git -C "$TMP" rm -q "$TMP/notes/todelete.yaml" && git -C "$TMP" commit -qm remove +OUT2="$(REPO_ROOT="$TMP" BASE_REF="$ADD_SHA" "$SCRIPT" 2>/dev/null)" +printf 'touched set (removal):\n%s\n---\n' "$OUT2" +assert_absent "R-DELDOC-01" "$OUT2" "REMOVED whole-line comment R-ID is NOT attributed" +assert_present "R-DELREAL-01" "$OUT2" "REMOVED real-line R-ID IS attributed" + +echo +if [ "$FAIL" -eq 0 ]; then echo "touched-requirements tests: PASS ($PASS assertions)"; exit 0; +else echo "touched-requirements tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi diff --git a/ci/touched-requirements.sh b/ci/touched-requirements.sh index adc4034..a18268a 100755 --- a/ci/touched-requirements.sh +++ b/ci/touched-requirements.sh @@ -49,7 +49,7 @@ changed_files() { # The ADDED/REMOVED lines of the change, for the R-ID content scan — excluding # documentation. # -# TWO scoping rules, both learned from the gate blocking merges it should not have: +# THREE scoping rules, all learned from the gate blocking merges it should not have: # # 1. Scan the DIFF, not whole file contents. Grepping whole files meant a change # inherited every requirement those files had EVER mentioned, on lines the author @@ -66,14 +66,36 @@ changed_files() { # that matter (conformance/, schemas/, catalog/, clusters/, ci/) are covered by # the path->requirement fallback regardless of file type. # +# 3. Skip COMMENT-ONLY lines in non-Markdown files — rule 2's logic, applied to the +# prose that lives INSIDE code files. A requirement ID cited in a YAML/shell `#` +# comment is documentation, not a declaration, exactly like an ID in a `*.md` file. +# Rule 2 drops whole Markdown files, but a `.gitea/workflows/*.yml` or `ci/*.sh` +# HEADER that explains a requirement would otherwise re-attribute it. Concretely: +# gate.yml's header cites R-TEST-06 (enforced by the spec-immutability STAGE, not by +# any Chainsaw test, so a known-uncovered ID); before this rule, editing that comment +# re-attributed R-TEST-06 and the coverage `--require` check false-blocked the merge — +# the same self-referential trap rule 2 fixed for `*.md`. Applied in main()'s content +# scan (see there): after the diff marker is stripped, a line whose first non-blank +# character is `#` is dropped BEFORE the R-ID grep. Deliberately conservative — only +# WHOLE-LINE comments are dropped; an R-ID on a real code/spec line is still detected +# (authoritative declarations — test tags, policies, ServiceDefinitions — are never a +# bare comment), so this only narrows PROSE, never a declaration. +# # Removed lines count: deleting a test's requirement tag, or a policy's self-declared # ID, touches that requirement just as much as adding one. -# `:(exclude)*.md` drops Markdown from the content scan (rule 2 above). Three-dot to -# match changed_files(): changes on this branch since it diverged from base. +# `:(exclude)*.md` drops Markdown from the content scan (rule 2 above). +# `:(exclude)ci/tests/` drops this dir's OWN unit-test fixtures: those files embed example +# R-IDs as DATA (asserting the scan's behaviour), never as declarations — the same +# "reference, not declaration" logic as rule 2. Their gate coverage is unaffected: any +# ci/ change (ci/tests/ included) still attributes R-GITOPS-01/R-TEST-02/R-TEST-03 via the +# path->requirement map below. NOTE: this excludes ci/tests/ only — conformance/tests/ +# carry AUTHORITATIVE `conformance.home.k3s/R-*` labels and are deliberately still scanned. +# Three-dot to match changed_files(): changes on this branch since it diverged from base. changed_diff() { local base; base="$(resolve_base)" - if git -C "$REPO_ROOT" diff -U0 "${base}...HEAD" -- . ':(exclude)*.md' 2>/dev/null; then return; fi - git -C "$REPO_ROOT" diff -U0 "${base}" -- . ':(exclude)*.md' 2>/dev/null || true + local -a excl=(':(exclude)*.md' ':(exclude)ci/tests/') + if git -C "$REPO_ROOT" diff -U0 "${base}...HEAD" -- . "${excl[@]}" 2>/dev/null; then return; fi + git -C "$REPO_ROOT" diff -U0 "${base}" -- . "${excl[@]}" 2>/dev/null || true } # Coarse path -> requirement map. Deliberately conservative; extend as subtrees @@ -109,9 +131,15 @@ main() { # Self-declared R-IDs on the ADDED/REMOVED lines of the change (see changed_diff). # `^[+-]` keeps hunk headers (`@@`) and the `+++`/`---` file headers out of scope. + # `sed 's/^[+-]//'` then strips the single-char diff marker (safe: -U0 emits one), so + # what remains is the SOURCE line, and `grep -vE '^[[:space:]]*#'` drops whole-line + # YAML/shell comments (rule 3 in changed_diff's header) BEFORE the R-ID grep — a + # requirement cited only in a `#` comment is prose, not a declaration. reqs+=" $(changed_diff \ | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ + | sed -E 's/^[+-]//' \ + | grep -vE '^[[:space:]]*#' \ | grep -hoE 'R-[A-Z]+-[0-9]+' \ | tr '\n' ' ' || true)"