From 35f8b70d95c7b0f406eacacd59c929c6e63fb60e Mon Sep 17 00:00:00 2001 From: "spyroot@gmail.com" Date: Sun, 26 Jul 2026 11:21:43 +0400 Subject: [PATCH 1/2] Add focused private CI validation --- .gitignore | 9 +-- .gitlab-ci.yml | 53 ++++++++++++-- STANDARDS.md | 20 ++++++ gates/manifest.yaml | 6 +- inventory/ci/go-no-go.yaml | 107 +++++++++++++++++++++++++++++ schemas/gates.schema.json | 8 +++ scripts/check.sh | 95 ++++++++++++++++++------- scripts/gates/run.sh | 74 ++++++++++++++++++-- tests/gates/test_gate_meta.py | 40 +++++++++++ tests/gates/test_gate_scripts.py | 57 ++++++++++++++- tests/gates/test_meta_detection.py | 14 ++++ tools/gate_meta.py | 7 +- 12 files changed, 447 insertions(+), 43 deletions(-) create mode 100644 STANDARDS.md create mode 100644 inventory/ci/go-no-go.yaml diff --git a/.gitignore b/.gitignore index 0dbae7f9..33891610 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,12 @@ NV72_OTEL_BRIEF.md .internal/ tools/sync_claude_agents.py -# Internal-only documentation (team/dev notes; external docs live in docs/) +# Private inventory is ignored except for the public go/no-go contract. docs/internal/ -inventory/ +inventory/* +!inventory/ci/ +inventory/ci/* +!inventory/ci/go-no-go.yaml # macOS / stray test output .DS_Store @@ -213,5 +216,3 @@ tests/popen_test.py # De-duplicated corpus: the redundant orig/ backup tree is never committed tests/supermicro_gb300_corpus/json_responses/orig/ bios_firmware/ - - diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d9aee851..30aa5585 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,16 +1,26 @@ # GitLab pipeline for the homelab platform. Every job runs on the self-managed `homelab-k8s` runner # (Kubernetes executor: one disposable pod per job — the execution authority). Gates are driven from -# gates/manifest.yaml via scripts/gates/run.sh . The meta-gate (tools/gate_meta.py) enforces +# gates/manifest.yaml through scripts/check.sh. The meta-gate (tools/gate_meta.py) enforces # that the required jobs exist, none uses allow_failure, every job carries the homelab-k8s tag, and no # live-apply job is reachable from a merge-request pipeline. # -# Four pipeline shapes: +# Six pipeline shapes: +# self-managed API/web -> focused-gate only (diagnostic evidence only) # merge-request -> gate-merge (static + unit + render; NO cluster mutation, NO production creds) # default-branch -> gate-merge + gate-integration # scheduled -> gate-integration (cluster smoke) # manual protected deploy -> deploy-apply (protected branch only, manual, serialized) # protected default branch -> publish-github (GATED outbound mirror: agent files/names/secrets # must pass the boundary gates before anything reaches public GitHub) +workflow: + rules: + - if: >- + $CI_SERVER_HOST != "gitlab.com" && + ($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api") + variables: + FOCUSED_GATE: unit.all + - when: always + stages: [validate, integration, deploy, publish] # The shared toolbox image is the ONLY CI image. It supplies the runtime (conda, @@ -34,24 +44,49 @@ variables: # pre-installed toolchain in the image. CONDA_ENV: redfish_ctl +.focused-dispatch: &focused-dispatch + if: >- + $CI_SERVER_HOST != "gitlab.com" && + ($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api") && + $FOCUSED_GATE != null && $FOCUSED_GATE != "" + +focused-gate: + stage: validate + tags: [homelab-k8s] + rules: + - *focused-dispatch + - when: never + script: + # A focused result proves only this gate at this commit. It is not merge or release evidence. + - ./scripts/check.sh --profile merge --gate "${FOCUSED_GATE:-unit.all}" + artifacts: + when: always + paths: + - reports/gate-report.sanitized.json + - reports/gates/ + gate-merge: stage: validate tags: [homelab-k8s] rules: + - <<: *focused-dispatch + when: never - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' script: - ./scripts/check.sh --list - - ./scripts/gates/run.sh merge + - ./scripts/check.sh --profile merge gate-integration: stage: integration tags: [homelab-k8s] rules: + - <<: *focused-dispatch + when: never - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_PIPELINE_SOURCE == "schedule"' script: - - ./scripts/gates/run.sh integration + - ./scripts/check.sh --profile integration # Live cluster checks run HERE, as a pipeline job on the in-cluster runner — # never from a workstation. The runner pod already carries a ServiceAccount, so @@ -65,6 +100,8 @@ k8s-live-check: stage: integration tags: [homelab-k8s] rules: + - <<: *focused-dispatch + when: never - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_PIPELINE_SOURCE == "schedule"' before_script: [] # no project env needed; this probes the cluster only @@ -87,12 +124,14 @@ deploy-apply: name: production resource_group: homelab-apply rules: + - <<: *focused-dispatch + when: never - if: '$CI_COMMIT_REF_PROTECTED == "true"' when: manual variables: MUTATION_LOCK: "$CI_JOB_ID" script: - - ./scripts/gates/run.sh deploy + - ./scripts/check.sh --profile deploy # Gated outbound mirror: internal GitLab is the source of truth; this is the ONLY path to public GitHub. # Runs the boundary gates (agent files/names/secrets) FIRST, then pushes on pass. Protected default @@ -101,13 +140,15 @@ publish-github: stage: publish tags: [homelab-k8s] rules: + - <<: *focused-dispatch + when: never - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_REF_PROTECTED == "true"' before_script: [] # boundary gates need no project environment script: # Run the PROFILE, not a hand-listed set of scripts. A hand-listed set silently stops covering # the boundary the moment a gate is added to the registry — which is exactly how the exact-commit, # agent-artifact and large-artifact checks came to be missing here. - - ./scripts/gates/run.sh repository-export + - ./scripts/check.sh --profile repository-export # The shared contract's export chain also requires a secret scan and sanitized evidence. Both are # registered under `merge` because they run there too, and the schema allows a gate exactly one # profile, so they are named explicitly rather than duplicated under a second id. diff --git a/STANDARDS.md b/STANDARDS.md new file mode 100644 index 00000000..78974f58 --- /dev/null +++ b/STANDARDS.md @@ -0,0 +1,20 @@ +# Shared Engineering Standards + +`redfish_ctl` consumes the shared engineering standards through +`standards-binding.yaml`. That binding defines the authority, exact version +lock, and required contract set; shared contract text is not copied into this +repository. + +Before acting, read in this order: + +1. `standards-binding.yaml`. +2. The shared `README.md` and `manifest.yaml` resolved from the binding's + `spec.source.localPath`. +3. Every required contract named by that manifest and the project binding. + +Follow the precedence defined by the pinned shared contracts. Project rules may +add stricter constraints but may not weaken the pinned standards. + +If the binding, pinned revision, or a required contract cannot be read, stop and +report a precise `BLOCKER:`. Do not infer a replacement from another project or +an old handoff. diff --git a/gates/manifest.yaml b/gates/manifest.yaml index 9fbc3e98..d26304cd 100644 --- a/gates/manifest.yaml +++ b/gates/manifest.yaml @@ -1,6 +1,6 @@ # Gate registry — the ONE canonical source of every mandatory gate (validated by schemas/gates.schema.json). # Each gate: id, profile (when it runs), command (executable), required, mutates (does it change a target). -# Enforced by tools/gate_meta.py (the meta-gate) + tests/gates/. Run a profile with scripts/gates/run.sh. +# Enforced by tools/gate_meta.py (the meta-gate) + tests/gates/. Run gates through scripts/check.sh. # # Profiles: merge (MR/pre-merge, no cluster mutation) | integration (needs cluster, no BMC mutation) # | deploy (live apply, protected pipeline only) @@ -15,6 +15,10 @@ required_jobs: - gate-merge - gate-integration +# Diagnostic jobs must exist and remain safe, but are not merge/release evidence. +diagnostic_jobs: + - focused-gate + mandatory_ids: - meta.gate-registry - meta.ci-runner-tags diff --git a/inventory/ci/go-no-go.yaml b/inventory/ci/go-no-go.yaml new file mode 100644 index 00000000..bd5add40 --- /dev/null +++ b/inventory/ci/go-no-go.yaml @@ -0,0 +1,107 @@ +apiVersion: standards.spyroot.dev/v1alpha1 +kind: GoNoGoProfile +metadata: + name: redfish_ctl + +spec: + targetBranch: main + + standards: + bindingFile: standards-binding.yaml + requireExactRevision: true + + environment: + manager: conda + file: environment.yml + nameFromFile: true + explicitName: "" + createOrUpdateAllowed: false + + checks: + required: + - id: unit.all + kind: command + command: + - ./scripts/check.sh + - --profile + - merge + - --gate + - unit.all + timeoutSeconds: 1800 + offline: true + warningsAllowed: 0 + skippedRequiredTestsAllowed: 0 + + - id: repo.format + kind: command + command: + - ./scripts/check.sh + - --profile + - merge + - --gate + - repo.format + timeoutSeconds: 600 + offline: true + findingsAllowed: 0 + + - id: meta.gate-registry + kind: command + command: + - ./scripts/check.sh + - --profile + - merge + - --gate + - meta.gate-registry + timeoutSeconds: 120 + offline: true + + optional: [] + + ci: + requiredForMerge: true + exactCommitRequired: true + requiredContext: gate-merge + requireStatusReadback: true + smokeRequiredFor: + - publish + - mirror + - promotion + - deploy + - apply + + safety: + defaultOffline: true + forbiddenWithoutCurrentApproval: + - live-hardware + - live-cluster + - external-api + - real-credentials + - package-install + - environment-update + - deployment + - publication + - promotion + - destructive-operation + + architecture: + rules: [] + + ownership: + authorityFiles: [] + treatAuthorityAsSecretClass: true + + documentation: + requiredFiles: + - README.md + - STANDARDS.md + requireSafetyWarningsForProtectedActions: true + requireSetupAndExpectedOutputForNewUserFlows: true + + secrets: + requirePublicBoundaryScan: true + forbiddenTrackedGlobs: + - "*.env" + - "*.pem" + - "*.key" + - "*kubeconfig*" + - ".internal/**" diff --git a/schemas/gates.schema.json b/schemas/gates.schema.json index 8ac7db13..5f5db387 100644 --- a/schemas/gates.schema.json +++ b/schemas/gates.schema.json @@ -29,6 +29,14 @@ "minLength": 1 } }, + "diagnostic_jobs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, "mandatory_ids": { "type": "array", "minItems": 1, diff --git a/scripts/check.sh b/scripts/check.sh index ef61f787..c4bcf1c6 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # check.sh — the single entry point for the gate registry (gates/manifest.yaml). # -# check.sh --list enumerate every registered gate (id, profile, mutates) -# check.sh --profile run all mandatory gates of a profile +# check.sh --list enumerate every registered gate +# check.sh --profile run all gates in a profile +# check.sh --profile --gate run one gate in that profile # (merge|integration|deploy|repository-export) # # EXECUTION AUTHORITY = Kubernetes. Outside a cluster pod, check.sh REFUSES to run tests locally and @@ -47,25 +48,73 @@ print(f"\n{len(reg['gates'])} gates; mandatory: {len(reg['mandatory_ids'])}; run PY } -case "${1:-}" in - --list) - _list - ;; - --profile) - profile="${2:?usage: check.sh --profile }" - if _in_cluster; then - exec ./scripts/gates/run.sh "$profile" - fi - ref="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" - echo "check.sh: REFUSING to run gates locally — Kubernetes is the execution authority." >&2 - echo " Dispatch in-cluster instead: push $ref and let the GitLab pipeline run it on the" >&2 - echo " homelab-k8s runner. There is no workstation dispatch path." >&2 - echo " (or run inside a homelab-k8s runner/Job — a pod is detected from the kubelet's own" >&2 - echo " evidence, not from an environment variable, so exporting one cannot bypass this)" >&2 - exit 3 - ;; - *) - echo "usage: check.sh {--list | --profile }" >&2 +usage() { + cat <<'USAGE' +usage: check.sh --list + check.sh --profile [--gate ] + +A focused --gate result proves only that gate at the exact commit. It is not +merge or release evidence; use the full required pipeline for those decisions. +USAGE +} + +list=false +profile="" +gate="" +while [ "$#" -gt 0 ]; do + case "$1" in + --list) + list=true + shift + ;; + --profile) + profile="${2:?check.sh: --profile requires a value}" + shift 2 + ;; + --gate) + gate="${2:?check.sh: --gate requires a value}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "check.sh: unexpected argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if $list; then + if [ -n "$profile" ] || [ -n "$gate" ]; then + echo "check.sh: --list cannot be combined with --profile or --gate" >&2 exit 2 - ;; -esac + fi + _list + exit 0 +fi + +if [ -z "$profile" ]; then + echo "check.sh: --profile is required when running gates" >&2 + usage >&2 + exit 2 +fi + +runner_args=(--profile "$profile") +if [ -n "$gate" ]; then + runner_args+=(--gate "$gate") +fi + +if _in_cluster; then + exec ./scripts/gates/run.sh "${runner_args[@]}" +fi + +ref="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" +echo "check.sh: REFUSING to run gates locally — Kubernetes is the execution authority." >&2 +echo " Dispatch in-cluster instead: push $ref and let the GitLab pipeline run it on the" >&2 +echo " homelab-k8s runner. There is no workstation dispatch path." >&2 +echo " (or run inside a homelab-k8s runner/Job — a pod is detected from the kubelet's own" >&2 +echo " evidence, not from an environment variable, so exporting one cannot bypass this)" >&2 +exit 3 diff --git a/scripts/gates/run.sh b/scripts/gates/run.sh index 8c169c9c..7e7e6f9a 100755 --- a/scripts/gates/run.sh +++ b/scripts/gates/run.sh @@ -1,11 +1,55 @@ #!/usr/bin/env bash -# Run every gate of a PROFILE from the gate registry (gates/manifest.yaml), in registry order; stop on -# the first failure. An unknown profile is an error, never a silent pass. -# run.sh merge | integration | deploy +# Internal backend for scripts/check.sh. Run a profile, or one gate in a profile, +# from gates/manifest.yaml. An unknown profile, unknown gate, or profile/gate +# mismatch is an error, never a silent pass. +# run.sh merge +# run.sh --profile merge [--gate unit.all] set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/../.." -profile="${1:?usage: run.sh }" -exec python3 - "$profile" <<'PY' + +usage() { + cat <<'USAGE' +usage: run.sh + run.sh --profile [--gate ] +USAGE +} + +profile="" +gate="" +if [ "$#" -gt 0 ] && [[ "$1" != -* ]]; then + profile="$1" + shift +fi + +while [ "$#" -gt 0 ]; do + case "$1" in + --profile) + profile="${2:?run.sh: --profile requires a value}" + shift 2 + ;; + --gate) + gate="${2:?run.sh: --gate requires a value}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "run.sh: unexpected argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ -z "$profile" ]; then + echo "run.sh: a profile is required" >&2 + usage >&2 + exit 2 +fi + +exec python3 - "$profile" "$gate" <<'PY' import pathlib import subprocess import sys @@ -13,6 +57,7 @@ import sys import yaml profile = sys.argv[1] +selected_gate = sys.argv[2] or None registry = yaml.safe_load(pathlib.Path("gates/manifest.yaml").read_text(encoding="utf-8")) known = sorted({g.get("profile") for g in registry["gates"] if g.get("profile")}) if profile not in known: @@ -22,10 +67,27 @@ gates = [g for g in registry["gates"] if g.get("profile") == profile] if not gates: print(f"run.sh: profile '{profile}' has no registered gates", file=sys.stderr) sys.exit(1) +if selected_gate: + matches = [gate for gate in registry["gates"] if gate.get("id") == selected_gate] + if not matches: + print(f"run.sh: unknown gate '{selected_gate}'", file=sys.stderr) + sys.exit(1) + selected = matches[0] + if selected.get("profile") != profile: + print( + f"run.sh: gate '{selected_gate}' belongs to profile " + f"'{selected.get('profile')}', not '{profile}'", + file=sys.stderr, + ) + sys.exit(1) + gates = [selected] for gate in gates: print(f"=== gate {gate['id']} ({gate['command']}) ===") if subprocess.run([gate["command"]]).returncode != 0: print(f"GATE FAILED: {gate['id']}", file=sys.stderr) sys.exit(1) -print(f"run.sh: all {profile} gates passed") +if selected_gate: + print(f"run.sh: gate {selected_gate} passed") +else: + print(f"run.sh: all {profile} gates passed") PY diff --git a/tests/gates/test_gate_meta.py b/tests/gates/test_gate_meta.py index ffd2f2d8..91926c6f 100644 --- a/tests/gates/test_gate_meta.py +++ b/tests/gates/test_gate_meta.py @@ -2,6 +2,8 @@ import json from pathlib import Path +import yaml + from tools import gate_meta REPO_ROOT = Path(__file__).resolve().parents[2] @@ -66,3 +68,41 @@ def test_every_gate_declares_profile_and_mutates(): for gate in registry["gates"]: assert gate.get("profile") in allowed, gate assert isinstance(gate.get("mutates"), bool), f"{gate.get('id')} lacks a bool 'mutates'" + + +def test_focused_gitlab_job_matches_the_builder_consumer_contract(): + """Internal API/web dispatch runs one registry gate through the guarded wrapper.""" + ci = yaml.safe_load((REPO_ROOT / ".gitlab-ci.yml").read_text(encoding="utf-8")) + registry = gate_meta._load_registry() + assert "focused-gate" in registry["diagnostic_jobs"] + assert "focused-gate" not in registry["required_jobs"] + + workflow_rule = ci["workflow"]["rules"][0] + assert workflow_rule["variables"]["FOCUSED_GATE"] == "unit.all" + assert '!= "gitlab.com"' in workflow_rule["if"] + assert "web" in workflow_rule["if"] + assert "api" in workflow_rule["if"] + + focused = ci["focused-gate"] + assert focused["script"] == [ + './scripts/check.sh --profile merge --gate "${FOCUSED_GATE:-unit.all}"' + ] + + focused_rules = repr(focused["rules"]) + assert '!= "gitlab.com"' in focused_rules + assert "web" in focused_rules + assert "api" in focused_rules + assert "FOCUSED_GATE" in focused_rules + + for job_name in ( + "gate-merge", + "gate-integration", + "k8s-live-check", + "deploy-apply", + "publish-github", + ): + rules = ci[job_name]["rules"] + assert rules[0].get("when") == "never", job_name + condition = rules[0].get("if", "") + assert '!= "gitlab.com"' in condition, job_name + assert "FOCUSED_GATE" in condition, job_name diff --git a/tests/gates/test_gate_scripts.py b/tests/gates/test_gate_scripts.py index 055d0c89..d9a73068 100644 --- a/tests/gates/test_gate_scripts.py +++ b/tests/gates/test_gate_scripts.py @@ -5,7 +5,6 @@ load the registry at all, passes every existing check. These tests close that gap by executing the scripts and asserting they fail when they must. -Author Mus spyroot@gmail.com """ import os import shutil @@ -77,6 +76,42 @@ def test_run_sh_loads_the_registry_and_rejects_an_unknown_profile() -> None: assert "FileNotFoundError" not in combined, combined +def test_run_sh_rejects_an_unknown_focused_gate() -> None: + """A focused request cannot turn an unknown gate ID into a green no-op.""" + proc = subprocess.run( + [ + str(REPO_ROOT / "scripts" / "gates" / "run.sh"), + "--profile", + "merge", + "--gate", + "no-such-gate", + ], + capture_output=True, + text=True, + ) + combined = proc.stdout + proc.stderr + assert proc.returncode != 0, combined + assert "unknown gate 'no-such-gate'" in combined, combined + + +def test_run_sh_rejects_a_gate_from_another_profile() -> None: + """Focused validation must not run a gate outside the requested profile.""" + proc = subprocess.run( + [ + str(REPO_ROOT / "scripts" / "gates" / "run.sh"), + "--profile", + "merge", + "--gate", + "integration.namespace", + ], + capture_output=True, + text=True, + ) + combined = proc.stdout + proc.stderr + assert proc.returncode != 0, combined + assert "belongs to profile 'integration', not 'merge'" in combined, combined + + def test_evidence_sanitized_fails_on_a_planted_secret(tmp_path) -> None: """evidence.sanitized exits non-zero when the evidence dir contains secret-shaped content. @@ -176,6 +211,26 @@ def test_check_sh_refuses_when_only_the_service_host_variable_is_set() -> None: assert "unknown profile" not in combined, f"the guard let the runner start: {combined}" +def test_check_sh_parses_a_focused_gate_but_refuses_local_execution() -> None: + """The focused syntax is accepted without weakening the Kubernetes guard.""" + env = { + key: value + for key, value in os.environ.items() + if key not in {"KUBERNETES_SERVICE_HOST", "KUBERNETES_SERVICE_PORT"} + } + proc = subprocess.run( + [str(CHECK_SH), "--profile", "merge", "--gate", "unit.all"], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + ) + combined = proc.stdout + proc.stderr + assert proc.returncode == 3, combined + assert "REFUSING" in combined, combined + assert "unexpected argument" not in combined, combined + + @pytest.mark.skipif(not _in_a_pod(), reason="in-cluster acceptance: only meaningful inside a pod") def test_check_sh_still_runs_in_cluster() -> None: """check.sh accepts a real pod, so the hardened guard cannot break the in-cluster CI job. diff --git a/tests/gates/test_meta_detection.py b/tests/gates/test_meta_detection.py index 948e67b7..28193d37 100644 --- a/tests/gates/test_meta_detection.py +++ b/tests/gates/test_meta_detection.py @@ -197,3 +197,17 @@ def test_detects_missing_required_job(tmp_path, monkeypatch): monkeypatch.setattr(gate_meta, "REPO_ROOT", tmp_path) failures, _ = gate_meta._check_gitlab(_valid_registry()) assert any("required GitLab job missing" in f for f in failures) + + +def test_detects_missing_diagnostic_job(tmp_path, monkeypatch): + """A declared diagnostic job must exist without becoming merge evidence.""" + _write_gitlab(tmp_path, """ + gate-merge: + tags: [homelab-k8s] + script: [true] + """) + monkeypatch.setattr(gate_meta, "REPO_ROOT", tmp_path) + registry = _valid_registry() + registry["diagnostic_jobs"] = ["focused-gate"] + failures, _ = gate_meta._check_gitlab(registry) + assert any("diagnostic GitLab job missing" in f for f in failures) diff --git a/tools/gate_meta.py b/tools/gate_meta.py index 8774d0ec..fb51456b 100644 --- a/tools/gate_meta.py +++ b/tools/gate_meta.py @@ -2,8 +2,8 @@ Reads ``gates/manifest.yaml`` (the single registry of every mandatory gate) and fails the build when the pipeline could silently skip, misroute, or mis-classify a gate. -It is itself registered as the ``repo.meta`` gate and is run in CI via -``tests/test_gate_meta.py``. Checks (a check whose inputs do not exist yet — no +It is registered as the ``meta.gate-registry`` gate and is covered by +``tests/gates/test_gate_meta.py``. Checks (a check whose inputs do not exist yet — no ``.gitlab-ci.yml``, no ``modules/`` — is reported as skipped, not failed): 1. every required gate's command file exists @@ -199,6 +199,9 @@ def _check_gitlab(registry: dict) -> tuple[list[str], bool]: for required in required_jobs: if required not in real_jobs: failures.append(f"required GitLab job missing: {required}") + for diagnostic in registry.get("diagnostic_jobs") or []: + if diagnostic not in real_jobs: + failures.append(f"diagnostic GitLab job missing: {diagnostic}") return failures, True From 3a1d4f928593a0697216016cd9fde17c8a8fc608 Mon Sep 17 00:00:00 2001 From: "spyroot@gmail.com" Date: Sun, 26 Jul 2026 11:47:26 +0400 Subject: [PATCH 2/2] Align focused CI gate contracts --- docs/external/gates.md | 58 ++++++++---------------------- inventory/ci/go-no-go.yaml | 4 +++ tests/gates/test_no_agent_files.py | 6 ++++ tools/agent_name_guard.py | 7 +++- tools/span_root_baseline.txt | 8 ++--- 5 files changed, 35 insertions(+), 48 deletions(-) diff --git a/docs/external/gates.md b/docs/external/gates.md index ad26719d..7d7df159 100644 --- a/docs/external/gates.md +++ b/docs/external/gates.md @@ -11,13 +11,17 @@ optional, unregistered, `allow_failure`, mis-tagged, or merge-request-reachable Kubernetes is the execution authority. `scripts/check.sh` is the entry point: ``` -./scripts/check.sh --list # enumerate every registered gate -./scripts/check.sh --profile merge # run all merge gates (in-cluster only; refuses off-cluster) -./scripts/gates/run.sh # the runner (invoked inside a homelab-k8s runner pod) +./scripts/check.sh --list # enumerate every registered gate +./scripts/check.sh --profile merge # run the full merge profile in-cluster +./scripts/check.sh --profile merge --gate unit.all # run one diagnostic gate in-cluster ``` -Off-cluster, `check.sh --profile` refuses and prints the in-cluster dispatch (`make k8s-ci REF=`) -— a gate never runs on a workstation. +Off-cluster, `check.sh --profile` refuses—a gate never runs on a workstation. To request focused +validation, dispatch a `.gitlab-ci.yml` API/web pipeline at the exact branch commit with its +`FOCUSED_GATE=` variable. Only that file's `focused-gate` job runs. Verify the job's terminal +status and sanitized artifacts against the requested commit. A focused result proves only that gate +at that commit; it is not merge or release evidence. Full merge evidence comes from the required +full pipeline. ## Profiles @@ -27,47 +31,15 @@ Off-cluster, `check.sh --profile` refuses and prints the in-cluster dispatch (`m - **deploy** — live apply. Protected pipeline only, manual, serialized. Never reachable from a merge-request pipeline. -## The gates +## Registered gates -| id | profile | mutates | what it checks | fails when | -| -- | ------- | ------- | -------------- | ---------- | -| `meta.gate-registry` | merge | no | registry is schema-valid, ids unique, commands exist+executable, mandatory present | any registry inconsistency | -| `meta.ci-runner-tags` | merge | no | every GitLab job carries the `homelab-k8s` tag | a job missing the tag | -| `meta.required-jobs` | merge | no | required jobs exist, no `allow_failure`, no live-apply in an MR pipeline | a required job missing/mis-configured | -| `repo.no-secrets` | merge | no | no committed secrets (gitleaks) | a secret is found, or the scanner is absent | -| `repo.shellcheck` | merge | no | shell scripts pass shellcheck (error severity) | a shell error, or shellcheck absent | -| `repo.format` | merge | no | ruff over files changed vs `origin/main` | a lint finding, or ruff absent | -| `repo.yaml` | merge | no | YAML lints/parses | invalid YAML | -| `repo.schemas` | merge | no | schema-backed docs validate (registry vs its JSON schema) | a schema violation | -| `repo.no-agent-names` | merge | no | no AI-agent identity in tracked content or new commit messages | an agent name appears | -| `repo.no-agent-files` | merge | no | no agent instruction/artifact file is tracked in the published mainline | an agent file is tracked | -| `unit.all` | merge | no | the offline unit suite | any test fails | -| `kubernetes.render` | merge | no | manifests + Helm chart render/parse | a render/parse error | -| `kubernetes.schema` | merge | no | manifests validate against the k8s API schemas (kubeconform) | a schema error, or kubeconform absent | -| `kubernetes.policy` | merge | no | manifest security/best-practice policy (kube-linter) | a policy violation, or the linter absent | -| `integration.namespace` | integration | no | the home cluster is reachable (fail-closed smoke) | cluster unreachable | -| `gitlab.project-token.exists` | integration | no | the CI project token authenticates | token invalid/expired | -| `gitlab.project-token.project-bound` | integration | no | the token is the project bot, bound to its project | not a project-bound bot token | -| `gitlab.project-token.api-access` | integration | no | the token carries API scope | `/version` returns 403 (no api scope) | -| `gitlab.project-token.no-cross-project-access` | integration | no | the token sees only its own project (least privilege) | it can reach other projects | -| `mutation.plan-required` | deploy | no | a plan artifact exists before apply | no plan produced | -| `mutation.protected-apply` | deploy | **yes** | apply runs only from a protected pipeline | not protected / an MR pipeline | -| `mutation.same-commit` | deploy | no | apply commit == plan commit | plan/apply commits differ | -| `mutation.serialized` | deploy | no | a mutation lock is held (no concurrent apply) | no lock held | -| `mutation.verify-required` | deploy | no | the applied module exposes a verify step | module has no `verify.sh` | -| `mutation.rollback-required` | deploy | no | the applied module exposes a rollback step | module has no `rollback.sh` | -| `evidence.sanitized` | merge | no | the evidence artifact contains no secret material | a secret-shaped token in the artifact | +`gates/manifest.yaml` is the canonical gate catalog. Run `./scripts/check.sh --list` to read each +gate's id, profile, mutation classification, and command. Keeping the catalog in the registry avoids +a second hand-maintained table drifting from required CI behavior. ## Permissions merge/integration gates run under a **read-only** CI ServiceAccount with no production credentials. Live apply (deploy profile) runs under a **separate, explicitly selected** apply ServiceAccount, only -from a protected pipeline. See `docs/secrets.md` for value-free credential creation and `k8s/base/` for -the ServiceAccount definitions. - -## Failure behavior - -Every gate exits non-zero on failure; `scripts/gates/run.sh` stops at the first failure. A gate whose -required tool is absent **fails** (a skipped gate is never an implicit pass). Required CI jobs never use -`allow_failure`, so a red gate blocks the pipeline. Do not claim a gate passed without terminal or -GitLab pipeline evidence. +from a protected pipeline. See [secret setup](secrets.md) and the +[Kubernetes base](../../k8s/base/) for the ServiceAccount definitions. diff --git a/inventory/ci/go-no-go.yaml b/inventory/ci/go-no-go.yaml index bd5add40..18ba451b 100644 --- a/inventory/ci/go-no-go.yaml +++ b/inventory/ci/go-no-go.yaml @@ -1,3 +1,5 @@ +# Public schema-backed go/no-go contract; all other inventory remains private by default. +# The schema is resolved through the exact shared revision in standards-binding.yaml. apiVersion: standards.spyroot.dev/v1alpha1 kind: GoNoGoProfile metadata: @@ -18,6 +20,7 @@ spec: createOrUpdateAllowed: false checks: + # Gate ids are defined by gates/manifest.yaml. required: - id: unit.all kind: command @@ -58,6 +61,7 @@ spec: optional: [] ci: + # The required status context is the .gitlab-ci.yml gate-merge job. requiredForMerge: true exactCommitRequired: true requiredContext: gate-merge diff --git a/tests/gates/test_no_agent_files.py b/tests/gates/test_no_agent_files.py index bf77f8b3..ed61269f 100644 --- a/tests/gates/test_no_agent_files.py +++ b/tests/gates/test_no_agent_files.py @@ -63,6 +63,12 @@ def test_ordinary_source_is_not_flagged(): assert not agent_name_guard.is_agent_file("README.md") +def test_public_go_no_go_contract_does_not_open_private_inventory(): + """Only the schema-backed go/no-go profile is public under inventory/.""" + assert not agent_name_guard.is_agent_file("inventory/ci/go-no-go.yaml") + assert agent_name_guard.is_agent_file("inventory/ci/private.yaml") + + def test_live_mainline_has_no_tracked_agent_files(): """The published mainline currently tracks zero agent files (the gate passes here).""" assert agent_name_guard._agent_file_findings() == [] diff --git a/tools/agent_name_guard.py b/tools/agent_name_guard.py index 61cce7ab..332b1f69 100644 --- a/tools/agent_name_guard.py +++ b/tools/agent_name_guard.py @@ -38,6 +38,8 @@ # Directory prefixes whose entire contents are agent/internal-only. _AGENT_DIR_PREFIXES = (".codex/", ".claude/", ".agent-review/", ".internal/", "docs/internal/", "inventory/") +# Machine-readable public contracts allowed inside otherwise private inventory. +_PUBLIC_INVENTORY_FILES = frozenset({"inventory/ci/go-no-go.yaml"}) # Agent-tool names (word-bounded) plus specialist-agent role names (either separator). _IDENTITIES = [ @@ -139,8 +141,11 @@ def is_agent_file(path: str) -> bool: """Return whether a repo-relative path is an agent instruction/artifact file. :param path: a repo-relative file path (forward slashes). - :return: True if it matches an agent-file glob or lives under an agent-only directory. + :return: False for an exact public inventory contract; otherwise True for an + agent-file glob or path under an agent-only directory. """ + if path in _PUBLIC_INVENTORY_FILES: + return False if path.startswith(_AGENT_DIR_PREFIXES): return True base = path.rsplit("/", 1)[-1] diff --git a/tools/span_root_baseline.txt b/tools/span_root_baseline.txt index 756f9d73..10032582 100644 --- a/tools/span_root_baseline.txt +++ b/tools/span_root_baseline.txt @@ -4,7 +4,7 @@ redfish_ctl/cmd_wait.py:38 redfish_ctl/discover/cli.py:220 redfish_ctl/discovery/net_scan.py:61 -redfish_ctl/idrac_manager.py:285 -redfish_ctl/idrac_manager.py:293 -redfish_ctl/redfish_manager.py:552 -redfish_ctl/redfish_manager.py:560 +redfish_ctl/idrac_manager.py:273 +redfish_ctl/idrac_manager.py:281 +redfish_ctl/redfish_manager.py:682 +redfish_ctl/redfish_manager.py:690