# .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 invokes the AGGREGATE merge gate — `./ci/gate.sh`, which is # exactly what `make -C ci gate` runs, so CI and the devcontainer gate the same way # (R-TEST-02) — and since 2026-11-08 it runs INSIDE the same image: the job `container:` is the # CI-built devcontainer toolbox (see "THE JOB IMAGE" below), not a generic runner image that # downloads the pinned tools on every run. (See further below for why the script, not `make`, # is invoked here.) It runs every stage in order — # spec-immutability, lint, Kyverno L0, render==golden, L1 offline render — and then applies the # R-TEST-03 decision, printing # "MERGE GATE: PASS" (0) / "BLOCKED" (1) / "UNDETERMINED" (3) / "NOT APPLICABLE" (0). 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. # # WHY THIS STEP DECLARES GATE_STRICT_SKIPS AND TOLERATES EXIT 3 (2026-09-13). A SKIPPED stage is # never green, and since 2026-09-13 gate.sh refuses to print PASS when one did not run (it used to # print "— not green" and then "MERGE GATE: PASS", exit 0 — fail-open in the gate itself). Two # kinds of non-run must be told apart HERE, in the caller, because only the caller knows which # environment it is: # * a pinned tool missing or a seam unwired ON THIS RUNNER is a DEFECT — the toolchain is # baked into the job image and checked against ci/versions.env two steps up. # GATE_STRICT_SKIPS=true DECLARES that (no env sniffing, no # "does this look like CI?" heuristic) and makes those skips exit 1: untolerated, job fails. # * spec-immutability CANNOT be verified here at all: the spec repo is a separate repo and is # not checked out into this runner (SPEC_REPO_SLUG unwired; blueprint in merge-gate.yaml). It # is the ONLY skip that can still reach exit 3 under strict skips, which is what makes # `|| [ "$?" -eq 3 ]` a narrow, reviewed tolerance rather than a blanket one. The log still # says UNDETERMINED and names R-TEST-06 as unverified. Wire the spec checkout (or set # REQUIRE_SPEC_VERIFICATION=true) and this tolerance should be deleted. # Called DIRECTLY, not through `make`: GNU make reports any recipe failure as its own exit 2, so # `make -C ci gate || [ "$?" -eq 3 ]` would see 2 and fail the job. Same reason the # spec-immutability pre-step below calls the script directly. # # The cluster-gated L2/L3 conformance stage SKIPS honestly here (exit 3, surfaced not faked # — HARD RULE 4) because this runner declares no cluster: nothing here sets CLUSTER_AVAILABLE, # so gate.sh reports the touched requirements UNVERIFIED — and NAMES that declared gap in its # PASS headline — 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. The gate step below is the # same script the laptop runs; 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. # # THE JOB IMAGE (2026-11-08, feat/gate-toolbox-container). The gate job runs in # gitea.home.keithjackson.dev/home-network/toolbox, pinned BY DIGEST — the image # .gitea/workflows/images.yaml builds from infra/devcontainer/Containerfile, verifies, and # publishes. It is the SAME image `infra/devcontainer/dev.sh` runs, so a local gate and the CI # gate now execute the identical toolchain (R-TEST-02), not two installs that agree by intent. # Before this, the job ran in ghcr.io/catthehacker/ubuntu:act-22.04 and spent ~11s of every run # re-downloading yamllint, check-jsonschema, kyverno, promtool and chainsaw # (.gitea/workflows/scripts/install-tools.sh l0 + l1; measured on runs 494/495). # * PINNED BY DIGEST, MOVED BY RENOVATE. The `# renovate-digest:` marker above the image line # is the repo-wide convention (renovate.json5, "This repo's own CI-published images"): # Renovate follows the `main` tag and opens a digest-bump PR, whose OWN gate run then # executes on the new image — the bump tests itself. infra/gitea/registry-retention.sh reads # the same marker, so the pinned digest is never pruned. Never hand-edit it to a tag. # * PULL AUTH: none needed. The Home-Network org is public and the registry issues an # anonymous pull token (measured 2026-11-08: anonymous /v2/token -> manifest 200), so the # runner's DinD pulls it like any public image. If the org is ever made private, add # `credentials:` here with a read:package token secret — the automatic Actions token has no # package scope at /v2/token (see images.yaml's header). # * COLD PULL: ~467 MB compressed (1.54 GB unpacked). The DinD store is an emptyDir, so the # first job after a runner-pod restart or a digest bump pays one pull; every other run finds # it cached (forcePull=false), exactly as it already did for the catthehacker image. # * NO NODE in the toolbox, so `actions/checkout` (a Node action) cannot run here. The checkout # step is plain git instead — see its comment for how it keeps fetch-depth-0 semantics. # * install-tools.sh is NOT deleted: merge-gate.yaml (the dormant blueprint) still calls it, # and conformance/tests/l0-static/alert-rules-unit-tested asserts its promtool wiring. --- name: gate # TRIGGERS — deliberately only `pull_request` into main, plus `push` to main itself. # # There is NO per-branch push trigger. There used to be a prefix list (feature/** fix/** # docs/** chore/**) for "fast feedback on the branch", and it was a net negative here: # # * `act_runner` runs at capacity 1. A branch push and its PR fire the SAME gate over the # SAME tree, and the redundant run SERIALISES AHEAD of the PR run — doubling the wall # clock on the one result that actually gates the merge. That cost real time during the # 2026-09-29 plex claim, where the loop is racing a short-lived token. # * The list silently did not match this repo's own convention. Branches here are `feat/**`, # which was absent, so the "fast feedback" never fired for the most common prefix anyway — # a mismatch nobody noticed for a month because its only symptom was a MISSING run. # Any hand-maintained prefix list has that failure mode; deleting it removes the class. # * It is redundant. CLAUDE.md already requires `./ci/gate.sh` in the pinned toolbox before # you push (74s locally), and the PR gate re-runs it authoritatively. A third run of the # same checks buys nothing. # # `push: [main]` is KEPT: post-merge main must still be verified, and it is the only run for # which no PR context exists. on: pull_request: branches: [main] push: branches: - main concurrency: group: gate-${{ github.ref }} cancel-in-progress: true jobs: # Single required status check: the static merge gate. gate: runs-on: ubuntu-latest # The CI-built toolbox — the same image dev.sh runs. See "THE JOB IMAGE" in the header. container: # renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81 steps: # Plain git, because actions/checkout is a Node action and the toolbox has no Node. It # reproduces what actions/checkout@v7 with `fetch-depth: 0` did here (read from its logged # commands on runs 490/495): full history of every branch + tags, so # ci/touched-requirements.sh can diff against origin/; the exact event commit # checked out (detached for a PR, as `-B ` for a push). One difference, on # purpose: the job token is passed to the single fetch through GIT_CONFIG_* environment # variables and is NOT persisted into .git/config — nothing after this step needs # authenticated git (ci/ and conformance/ make no network git calls), so there is no # reason to leave a credential in the workspace for the gate's scripts to read. - name: Checkout (git, full history) env: GIT_TOKEN: ${{ github.token }} SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} SHA: ${{ github.sha }} REF: ${{ github.ref }} run: | set -euo pipefail # act runs steps with umask 0000, so without this every checked-out directory is # world-writable — and Ansible then IGNORES infra/ansible/ansible.cfg as a source # ("Ansible is being run in a world writable directory"), which made the L0 check # prod-baremetal-not-dev-surface fail on this PR's first CI run (run 500). The # toolbox has Ansible, so that check now drives it for real. 0022 = what a # workstation clone gets (reproduced locally: 0000 fails the check, 0022 passes). umask 0022 git config --global --add safe.directory "$PWD" git init -q . git remote add origin "${SERVER_URL}/${REPOSITORY}" auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \ GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \ git -c protocol.version=2 fetch --quiet --no-tags --prune --no-recurse-submodules origin \ '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' \ "+${SHA}:refs/remotes/checkout/head" unset auth GIT_TOKEN case "$REF" in refs/heads/*) git checkout -q --force -B "${REF#refs/heads/}" "$SHA" ;; *) git checkout -q --force --detach "$SHA" ;; esac [ "$(git rev-parse HEAD)" = "$SHA" ] || { echo "::error::HEAD is not ${SHA}" >&2; exit 1; } echo "checked out $(git log -1 --format='%H %s') (ref ${REF}); $(git rev-list --count HEAD) commits of history" # The image is the toolchain, so prove it is the PINNED one before trusting it: every # *_VERSION in ci/versions.env must equal what the image recorded at build time # (/etc/dev-toolchain-versions, written by infra/devcontainer/Containerfile; its `verify` # stage already asserted the version-sensitive binaries report those pins). This replaces # the per-run install: a drifted toolbox fails HERE, naming the tool, instead of gating with # a different engine. ci/versions.env and infra/devcontainer/versions.env must move together. - name: Toolchain is the one ci/versions.env pins run: | set -euo pipefail . ci/versions.env drift=0 for var in $(grep -oE '^[A-Z_]+_VERSION' ci/versions.env); do tool="$(printf '%s' "${var%_VERSION}" | tr 'A-Z_' 'a-z-')" want="${!var}" got="$(sed -n "s/^${tool}=//p" /etc/dev-toolchain-versions)" if [ "$got" = "$want" ]; then printf 'ok %-18s %s\n' "$tool" "$want" else printf '::error::DRIFT %s: ci/versions.env pins %s, the job image has %s\n' "$tool" "$want" "${got:-nothing}" drift=1 fi done [ "$drift" -eq 0 ] # 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 ] # Aggregate merge gate: the same script a human runs in the devcontainer via # `make -C ci gate`. 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 and NAMED in the PASS headline, never faked green. GATE_STRICT_SKIPS + the # tolerated exit 3 are explained in the file header; the tolerance covers exactly one # thing, the unverifiable spec repo, and nothing else can produce a 3 here. # GITEA_SETTINGS_TOKEN feeds ci/gitea-settings.sh, the only check that can see Gitea's # OWN database (branch protection + runner registration) — the blind spot that let `main` # sit unprotected with zero runners for a month while this repo recorded both as working. # Referenced BY NAME only; create it with infra/gitea/set-ci-token.sh. # # WIDENED TOLERANCE, DECLARED: `|| [ "$?" -eq 3 ]` used to cover exactly one skip # (spec-immutability). It now also covers gitea-settings skipping when that secret is # absent. That is a deliberate, temporary weakening: making an unwired credential BLOCK # every PR is the lock-yourself-out failure this repo keeps walking into. Once the secret # exists and the stage has been SEEN green, set REQUIRE_GITEA_SETTINGS=true below and the # tolerance narrows back to spec-immutability alone. - name: Merge gate — ci/gate.sh (what `make -C ci gate` runs) env: GATE_STRICT_SKIPS: "true" SETTINGS_API_TOKEN: ${{ secrets.SETTINGS_API_TOKEN }} # ENABLED 2026-09-29. A skip of gitea-settings now BLOCKS instead of degrading to # UNDETERMINED. Safe because THIS PR's own CI run is the proof: if the stage were # skipping, this line turns that into a failure and the PR goes red instead of # merging. Green here means the token resolved and the live Gitea matched the # declaration in infra/gitea/expected-branch-protection.json. REQUIRE_GITEA_SETTINGS: "true" # ci/approval-policy.sh needs to know WHICH PR it is looking at, so it can read that # PR's changed files and its reviews. Absent on a push event, where the stage is N/A. GITHUB_EVENT_NAME: ${{ github.event_name }} PR_NUMBER: ${{ github.event.pull_request.number }} run: ./ci/gate.sh || [ "$?" -eq 3 ] # 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 # would fail the job even on a green gate. JUnit + coverage are written under # conformance/_artifacts/ in the job workspace; wire artifact upload later via the # Gitea-compatible @v3 action or Gitea's own artifact backend if retention is wanted. # ─────────────────────────────────────────────────────────────────────────── # PLACEHOLDER — NOT WIRED (deferred; concrete plan written, awaiting operator decision). # L2/L3 conformance (Chainsaw behavioural + fault-injection) needs a LIVE cluster and is # gated behind vars.CLUSTER_AVAILABLE + a narrow secrets.CONFORMANCE_TRIGGER_KUBECONFIG. # The intended model: an in-cluster Chainsaw Job (conformance/incluster/) under its OWN # scoped ServiceAccount — CI holds NO broad kubeconfig, only a trigger credential. The # blocker is a real privilege-escalation design question (a `create jobs` credential can # create a Job running as the privileged runner SA), plus a runner image + repo-clone + # results-collection that don't exist yet. Full analysis + step sequence: # docs/plans/l2-l3-conformance-ci-wiring.md # The blueprint job shape is retained dormant in merge-gate.yaml. # Kept `if: false` so it never runs (shows skipped, never fakes green — HARD RULE 4). # ─────────────────────────────────────────────────────────────────────────── conformance: runs-on: ubuntu-latest needs: [gate] if: false steps: - name: Placeholder run: | echo "L2/L3 conformance not wired — see gate.yml header + WS-GITOSS report." >&2 exit 1