Implements the operator intent: "CI/CD pipeline is the end-all-be-all for approval. If the CI pipeline is not good enough to be trusted for this, then it is not good enough to do its intended job and needs to be improved. Manual approvers should only be for architecturally or security relevant changes." Adds ci/approval-policy.sh as a gate stage. A green gate becomes sufficient for an ordinary change; the one exception is structural rather than a matter of taste: A CHANGE TO THE GATE CANNOT BE GATED BY THE GATE. A PR that weakens a check would be approved by the weakened check. The same holds for the admission policies, the Flux wiring that decides what reconciles, the SOPS recipients and the RBAC. Those paths -- and only those -- still require an APPROVED review from someone other than the author. The list is data with a reason per entry (infra/gitea/approval-policy.json) and is itself a sensitive path, so the policy cannot be widened or narrowed without a human. FAIL-CLOSED, and deliberately the opposite of gitea-settings. There, a missing credential must not block every PR -- that is a lock-out. Here, a missing credential would let an unreviewed change to the gate merge, so "cannot evaluate" is a BLOCK. Verified against real PRs rather than fixtures: PR #16 (sensitive paths, approved by jakman85, author gitea-admin) -> PASS PR #6 (.gitea/workflows/gate.yml, ZERO reviews) -> BLOCK, naming the file push event (not a PR) -> N/A, exit 0 bogus token -> BLOCK, not skip Also caught before shipping: the first draft passed the PR file list with a second stdin redirect after a heredoc, which silently wins and leaves the script body unread. Running it found that; reading it would not have. required_approvals is NOT changed here. Flipping it live must happen together with the declaration in expected-branch-protection.json, or gitea-settings correctly fails on the drift -- that is a separate, deliberate change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
168 lines
11 KiB
YAML
168 lines
11 KiB
YAML
# .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): the runner just installs the pinned tools from ci/versions.env first (and see
|
|
# 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
|
|
# installed for us 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.
|
|
---
|
|
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
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0 # full history so ci/touched-requirements.sh can diff vs main
|
|
|
|
- name: Install pinned CI toolchain (ci/versions.env)
|
|
run: |
|
|
./.gitea/workflows/scripts/install-tools.sh l0
|
|
./.gitea/workflows/scripts/install-tools.sh l1
|
|
|
|
# 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
|