Skip to content

✨ feat(ci): cross-file consistency gates and Go 1.27.2 toolchain - #463

Merged
konih merged 69 commits into
mainfrom
feat/cross-file-consistency-gates
Oct 16, 2026
Merged

konih merged 69 commits into
mainfrom
feat/cross-file-consistency-gates

Conversation

@konih

@konih konih commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

TL;DR

  • What: five hack/test/consistency_* / patch-lag guards plus an openspec evidence guard, each with a --self-test, wired into lint and task check; go.mod bumped to Go 1.27.2 with the analysis tools that break under it; a new required job tool-pins-selftest; a weekly go-patch-lag workflow.
  • Why: facts that live in two files (Go version, tool pins, chart RBAC vs role.yaml, metric names) drifted silently; the shipped binary was built with Go 1.27.2 while tests and govulncheck ran on 1.26.9.
  • Risk: the new guards are fail-closed and run in required jobs, so a guard bug blocks every PR until fixed.
  • Reviewer action: check the CI wiring and the RBAC exceptions file (a trust boundary); approving means accepting Go 1.27.2 as the module's Go.

Closes #441.

go.mod said go 1.26.9 while both Dockerfiles built with golang:1.27.2, so no test or vulnerability scan ran under the toolchain that produced the shipped image. This PR makes the module's Go equal to the image's, then adds guards so this drift, and three similar ones, fail CI.

Most important things

  • Go 1.27.2 forces three tool bumps. Tools embedding golang.org/x/tools cannot analyse a newer Go: golangci-lint v2.13.1 → v2.14.0, go-arch-lint v1.15.0 → v1.19.0, govulncheck v1.1.4 → v1.9.0. The go-arch-lint bump was the same failure, version-only, and was not part of the operator's original ruling.
  • New required job tool-pins-selftest (45 min timeout, listed in hack/release/verify-eligibility.sh). It is separate from lint because the tool-pins self-test builds every pinned tool and takes about 5 min locally. It has to be added to the protect-main ruleset after this merges, because before the merge no PR on main can report it.
  • CFC-1 fails on any $ in a FROM image. No variable image is parsed around, golang: included. A comment line inside a FROM \ continuation is skipped the way BuildKit skips it.
  • CFC-3 RBAC exceptions are a closed list. hack/test/testdata/consistency-rbac-exceptions.txt holds 37 tenant-mode exceptions, each mode-scoped with a closed reason class. Workload reads are exempt per team-operator.md §3 (operator ruling A, 2026-10-15). There is no drift in default mode.
  • Evidence stays Markdown-only. Probe scripts under openspec/changes/**/evidence/ were analysed by SonarCloud as uncovered new code and failed its quality gate. They are now inlined into the evidence .md files, and a new guard, hack/test/openspec_evidence_markdown_only_test.sh (plain and --self-test, run in lint), keeps that directory Markdown-only. sonar.exclusions stays as it was.
  • Real drift fixed: five registered metrics were missing from docs/operator-manual/metrics.md (docs-only commit).

Risk

  • What can break: a false red in a guard blocks every PR, because lint and tool-pins-selftest are required. tool-pins-selftest downloads modules and the vuln DB, so a network stall shows up as a timeout. go-patch-lag turns red on the first Monday after a Go patch release, which is intended.
  • Blast radius: CI gating and the build toolchain. Product code changes only in internal/metrics/metrics.go (registration made testable) and its tests; the binary now builds and is tested on 1.27.2 everywhere.
  • Rollback: revert the PR. Reverting before the ruleset step is clean. After it, also remove tool-pins-selftest from protect-main (ruleset 18802410), or every PR waits on a check that never runs.
  • Residual risk accepted: BACKLOG items CFC1-REGISTRY-PREFIX (localhost:5000/golang:x is not recognised as golang), a heredoc FROM ${B} false red (fails closed), and collectors whose names are built by concatenation or Sprintf being invisible to the metrics literal scan (there are none today).

Files to look at first

  • .github/workflows/ci.yaml: 12 new lint steps plus the new job tool-pins-selftest.
  • hack/test/ci_workflow_security_test.sh: new CFC-6 rule that fails on an unwired guard. This changes what green means for CI wiring.
  • hack/test/openspec_evidence_markdown_only_test.sh: new guard that fails on any non-Markdown file under openspec evidence.
  • hack/test/testdata/consistency-rbac-exceptions.txt: 37 tenant exceptions (trust boundary: RBAC).
  • .github/workflows/go-patch-lag.yaml + hack/ci/go-patch-lag.sh: new scheduled sensor with network access, contents: read, fails closed.
  • internal/metrics/metrics_test.go: TestRegister now uses registerOnce, because a second Register panics.
  • hack/test/task_check_test.sh: one mutant re-anchored.
  • hack/test/consistency_go_toolchain_test.sh: the golang:${GO_VERSION} mutant sentinel changed.
  • go.mod, Dockerfile*, .devcontainer/devcontainer.json, Makefile, Taskfile.yml, hack/tooling/.custom-gcl.yml, renovate.json: the Go 1.27.2 bump, tool pins, and a Renovate group so the go directive and the golang images move in one PR.

Review this commit-by-commit, not as one flattened diff. Each guard landed test-first with its review fixes as separate commits, and the 99-file diff is mostly OpenSpec evidence under openspec/changes/.

Testing protocol

  • Locally: task lint, task test (under 1.27.2), task vulncheck, task spec:validate, task lint:markdown, each guard plain and with --self-test.
  • Scrub grep and gitleaks detect over origin/main..HEAD clean.
  • Every check on the PR head SHA is green, SonarCloud Code Analysis included, as are lint (all twelve new steps appear in its log) and tool-pins-selftest (10-20 min expected).
  • Rebase-merge: gh pr merge <n> --rebase --delete-branch.
  • Right after merge: add tool-pins-selftest (integration_id 15368) to ruleset 18802410, then read it back and diff against the backup. Only that context may differ.
  • gh workflow run go-patch-lag.yaml --ref main; it must finish success while go.mod is on the current 1.27 patch. A red run with "lookup failed" means go.dev was unreachable, not lag.
  • Main's post-merge CI, filtered on the merge headSha, is green.

Optional reading — the long version

Nothing above depends on this. Read it if you want the full story, the road not taken, or the deployment fine print. Skip it if the top half already told you what you needed.

The six requirements. CFC-1 Go equality (go.mod, Dockerfiles, devcontainer, Renovate group). CFC-2 tool pins: each go run/go install pin builds under exactly go.mod's Go (GOTOOLCHAIN=go<version>), and each analysis tool runs once under it. CFC-3 chart RBAC covers config/rbac/role.yaml in default and tenant mode. CFC-4 registered metric names match the docs and prometheusrule.yaml, and a Go test compares the live registry with registeredMetricNames. CFC-5 is the weekly patch-lag sensor. CFC-6 requires every guard to have a negative self-test and to be wired in CI.

Review history. Spec set reviewed before #437. Tasks G1-G8 each had independent review legs (two rounds where applicable). The branch review ran two rounds; round 2 findings C1 and C2 were fixed in G8 with mutants, and the rest were deferred to BACKLOG. Full records are in openspec/changes/cross-file-consistency-gates/loop.md, reviews/ and evidence/G1.md-G8.md.

Rejected alternatives. Running the tool-pins self-test inside lint would add about 5 min to the job every PR waits on. A toolchain line in go.mod was rejected because the equality guard is simpler when there is one version. A key-by-key values.yaml vs schema gate would be vacuous (additionalProperties: true).

Out of scope here. Archiving the change waits for a follow-up evidence PR once the first scheduled go-patch-lag run exists (task 6.2). Reporting go-patch-lag red runs belongs to change 7.

konih added 30 commits October 14, 2026 23:59
…evcontainer image

go.mod is now 1.26.9 and the Dockerfiles ship 1.27.2, not 1.26.6/1.27.1.
The devcontainer pins golang:1.26.9 and no change covered it, so CFC-1
now includes it. Adds the spec-loop state and task prompt.
hack/test/consistency_go_toolchain_test.sh fails when go.mod's go directive, the Dockerfile golang images and the devcontainer image disagree, when go.mod gains a toolchain line, when a CI Go setup uses a go-version literal or does not read go.mod, when no golang image is found, and when renovate.json lacks the go/golang group rule (or places it before the rules it must override) or the go directive bump. --self-test mutates copies of the tree, one invariant each, plus a no-op control.

Red on this tree by design: go.mod says 1.26.9 while both Dockerfiles ship golang:1.27.2 and Renovate has no group rule. The next commits fix that.
Version-only. Both Dockerfiles already build with golang:1.27.2, so the shipped binary was built by a Go that tests and govulncheck (which run go.mod's version) never saw. The devcontainer image uses the same golang:1.27.2 index digest as the Dockerfiles. No toolchain line; go mod tidy leaves go.sum unchanged.
The gomod manager does not bump the go directive by default, and the dockerfile and devcontainer managers put the golang image in other branches, so Renovate moved the image alone and go.mod fell behind what ships. One rule bumps the root go.mod's go directive (rangeStrategy bump); a second groups depNames go and golang into 'go toolchain'. It sits after the 'go minor and patch dependencies' and 'docker images' rules because a later rule's groupName wins. A local Renovate 41.173.1 lookup dry-run put go.mod, both Dockerfiles and the devcontainer image in renovate/go-toolchain; with the group rule first, go.mod and the Dockerfiles fell back to their old groups.
Read compact devcontainer JSON and fail when the devcontainer has no golang image; treat untagged or digest-only FROM golang as not comparable; accept quoted setup-go uses; require the go directive bump rule to name dep go; fail when a later Renovate rule can pull go or golang into another group. Each gap has a self-test mutant.
A later packageRule's groupName wins, so any group rule after it (a catch-all, a custom.regex group, a matchPackageNames filter) could pull the go directive or a golang image out of the group. As the last rule that sets a groupName it cannot be overridden, and the guard can check that one position instead of reasoning about every matcher. A Renovate 41.173.1 lookup dry-run still puts go.mod, both Dockerfiles and the devcontainer in renovate/go-toolchain.
Join Dockerfile backslash continuations before reading FROM; reject golang:${VAR}; read quoted YAML keys on setup-go steps and go-version literals; ignore JSONC comment lines in devcontainer.json; require the go directive bump rule not to filter to another package; and fail when any rule after the go toolchain group sets another groupName, since Renovate's last match wins. Each has a mutant or a green control in --self-test.
The 1.27.2 bump broke golangci-lint v2.13.1 and govulncheck v1.1.4,
which CFC-2 as written could not see: it only compared the tool go
directive against go.mod. Operator ruling 2026-10-15.
v2.13.1 carries golang.org/x/tools v0.49.0, which cannot read Go 1.27 export data ("export data version 5 is greater than maximum supported version 4"), so task lint failed with typecheck errors once go.mod moved to 1.27.2. v2.14.0 (x/tools v0.50.0) with the logcheck plugin custom build reports 0 issues. Makefile and hack/tooling/.custom-gcl.yml move together.
v1.15.0 fails under Go 1.27 (golang.org/x/tools loader: "package context without types was imported"), so task lint's arch-lint step went red after the go.mod bump to 1.27.2; it passes under go1.26.9. v1.19.0 reports no warnings on the same .go-arch-lint.yml, and its graph command still takes --type, --include-vendors and --out.
v1.1.4 carries golang.org/x/tools v0.29.0, whose SSA builder panics (unexpected expr: *ast.KeyValueExpr) in about 3 of 5 runs under Go 1.27.2, so task vulncheck flaked after the go.mod bump. v1.9.0 ran clean 5 of 5 on the same tree.
Add hack/test/consistency_tool_pins_test.sh (CFC-2, CFC-6). It resolves
every go run/go install pin through go mod download -json, fails when a
pin needs a newer Go than go.mod or cannot be resolved, and smoke-runs
golangci-lint (the repo's custom build), govulncheck and go-arch-lint once
against the module under go.mod's Go, so an analysis tool too old for the
Go (as v2.13.1/v1.1.4/v1.15.0 were for 1.27.2) reds in one named step.
--self-test mutates copies of the tracked tree, including a file:// proxy
fixture that needs go 1.99.0. Not wired into CI until task 6.1.
Every temp dir now lives under one WORKROOT made in the parent shell, so
the EXIT trap removes copies and tool builds (new_tmp ran in command
substitutions and the list never reached the trap). A golangci-lint
pinned outside the Makefile is built and smoke-run at its own version,
and the Makefile build must report the pinned version. Every pin token
on a go run/install line is scanned, whatever flags precede it, and
Makefile recipes are scanned too. Four new self-test mutants; review
records for round 1 added.
The tool-pin scan joins backslash continuations and drops quotes before
looking for pins, so `go install \` + `pkg@v` on the next line and
`pkg@"v1.2.3"` are checked instead of silently skipped. The EXIT trap's
module-cache cleanup can no longer turn a green run red. Two new
self-test mutants; round-2 review records, G2 evidence and the task 2.1
tick.
GOTOOLCHAIN=local would test whatever Go the host has; the guard runs
exactly the go.mod version, which is what the requirement means.
Also switches the loop task prompt to the duo review sizing.
Task 3.1. Default render covers every role.yaml triple; tenant mode misses 36,
30 of them cluster-scoped and named by the spec. The other six (deployments,
certificates get/list/watch) are documented as left to the team, which the
spec does not name: task 3.2 waits on that decision.
… check

team-operator.md section 3 leaves workload get/list/watch to each team in
tenant mode, so CFC-3 lists those triples as reasoned exceptions instead
of requiring the chart Role to grant them. Operator ruling 2026-10-15.
consistency_rbac_test.sh expands config/rbac/role.yaml and the chart's
rendered manager roles into (apiGroup, resource, verb) triples and fails
on every role.yaml triple the chart does not grant, in the default and
the tenantMode render. Exceptions carry their mode on each line, so a
tenant-only exception cannot excuse a default-mode gap; each needs a
reason, tenant lines a checked class (cluster-scoped or team-workload),
and a stale line fails. No real drift: the 36 tenant gaps are the
cluster-scoped and team-granted workload reads the CFC-3 ruling lists.
Round-1 review of the RBAC consistency guard. The default render now
counts only ClusterRoles, so the namespaced leader-election Role can no
longer stand in for a cluster-wide grant; the tenant render counts only
Roles, and a Role never covers a cluster-scoped resource. team-workload
exceptions are limited to the workload kinds the CFC-3 ruling names, so
a dropped reconciler read cannot be filed as team workload. The tenant
Role's selfsubjectaccessreviews rule grants nothing, so it becomes a
reasoned cluster-scoped exception. Records the round-1 review.
Round-2 review of the RBAC consistency guard. A tenant exception whose
reason is only its class token now fails. The self-test isolates the
read-only clause of team-workload (a write on a team-workload kind), adds
a cluster-scoped subresource listed in a Role, and proves that a wildcard
group and resource cover. Records the round-2 review and closes task 3.2
with gaps: the spec does not name the selfsubjectaccessreviews exception,
and CI wiring waits for task 6.1.
hack/ci/go-patch-lag.sh compares go.mod's go directive with the newest
stable release of its minor in the go.dev listing (include=all, since the
default listing only carries the two newest minors). Behind is exit 1; an
unreachable or unparsable listing, a minor the listing lacks, a go.mod
ahead of it, or a non-canonical or oversized number is exit 2, so the
lookup fails closed (bash arithmetic would otherwise turn a leading-zero
or overflowing patch into a false green). A newer minor is only a note.
curl is bounded inside the job timeout.

.github/workflows/go-patch-lag.yaml runs it weekly and on dispatch with
contents: read and no secrets. hack/test/go_patch_lag_test.sh pins the
verdicts on a fixture generated from the live listing and the workflow's
shape; --self-test proves each check can fail. Task 4.1 of
cross-file-consistency-gates (CFC-5).
kollect_access_cache_total, kollect_export_shard_warn_total,
kollect_namespace_fingerprint_cache_total, kollect_static_ref_resolution_total and
kollect_watch_map_list_errors_total are registered in internal/metrics but were missing
from the operator metrics page. Also stop calling metrics_catalog.go a source of truth for
registration: it documents names, metrics.go and aggregation*.go register them.
konih added 11 commits October 16, 2026 01:36
The registerTo test never called Register(), so pointing Register at another registry stayed green. After Register(), re-registering each checked collector in controller-runtime's metrics.Registry must return AlreadyRegisteredError. TestRegister and the new test share one sync.Once, since a second Register() in a process panics.
A variable "tag" is not safe either: FROM localhost:${B} with B=5000/golang:1.99.0 names another repository, and so can debian:${T}. Every $ in a FROM image now fails, except golang:${V}, which the literal-version check already fails. The debian:${DEB_TAG} green control becomes a mutant.
The variable-image rule exempted golang:-prefixed images and left them to the
literal-version check. That check strips the digest and the tag suffix, so
golang:1.27.2@${D} and golang:1.27.2-${X} passed with the variable stripped.
Any $ in a FROM image now fails, golang included; two mutants cover both
spellings and the golang:${GO_VERSION} mutant now reds on the variable rule.
…Docker

The FROM parser joined every line of a backslash continuation, so a comment
line inside it became the image word and FROM \ + # note + golang:1.99.0 passed.
A comment ending in a backslash also swallowed the next line, hiding its FROM.
Comment and blank lines are now skipped inside a continuation and a comment
never continues, as BuildKit parses them (checked with docker buildx --check).
Three mutants cover the spellings.
Evidence for 8.1 and 8.2 (red-before/green-after mutants, BuildKit parse
probe), ticks 8.1-8.3, and the Verification table statuses from G1-G8. The
first scheduled CFC-5 run stays post-merge and the PR-head lint row not-run.
@codecov

codecov Bot commented Oct 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

konih added 10 commits October 16, 2026 02:23
The five probe scripts under the change's evidence/ (G3 RBAC probe and
gate mutants, G5 gate mutants, G8 parser and BuildKit probes) were
analysed by SonarCloud as new code: 44 of 47 uncovered lines and two
taint findings came from the G3 Python probe, which turned the quality
gate red although the branch adds no uncovered production code.

They are records of how a claim was checked, never run by CI. Each one
now lives verbatim in the evidence Markdown that cites it, as a fenced
block with the command line it was run with and its input revision; the
recorded output is unchanged. Sonar does not analyse Markdown, so no
exclusion list grows.
Fails when a git-tracked file under openspec/changes/**/evidence/,
archived changes included, is not .md, and names every offender. A scan
that reads nothing (no openspec/, no evidence left, or a root that is
not the top of its work tree) fails rather than passing vacuously.

--self-test copies the tracked openspec/ tree into a fresh git work
tree per case and asserts each mutant reds with its own message: a .py
in a live change's evidence, a .sh in an archived one, a nested probe,
a second offender, a .md.sh suffix, no openspec/, no evidence left and
both bad roots; a no-op copy and a log outside evidence/ stay green.
Plain and --self-test run as separate steps next to the cross-file
consistency gates, so the lint job (a required context that also runs on
docs-only PRs) reds when non-Markdown evidence lands under openspec/ and
when the guard can no longer fail.
A tracked openspec/ file deleted from the working tree made tar abort
the self-test with only its own error. It now fails with a message
saying nothing was tested and why.
The CWS-6 loop only checks guards that CI already invokes, so deleting
both evidence-guard steps from ci.yaml passed every guard. Listing it
with the CFC-6 guards makes the workflow-security test red when either
mode is no longer run by a required job.
The self-test staged each copy with plain `git add -A`, so a global
core.excludesFile ignoring *.log or *.txt kept a control or mutant from
landing and the self-test failed on a clean tree. Staging with -f makes
the copy independent of the host's ignore rules.
A self-test mutant deletes both evidence-guard steps from ci.yaml and
expects the CFC-6 rule to red, so dropping the guard from the list is
caught too. The CWS-6 header names the guard, and the failure message
says "CFC-6: guard" now that the list holds more than consistency gates.
G9.md holds the verification matrix, sensors and both review rounds for
moving the evidence probes into Markdown and guarding it. The inlined
probes now say they ran from the change directory, with the file then
under evidence/, and that a re-run belongs in scratch space, never back
under evidence/ where the guard reds it.
@sonarqubecloud

Copy link
Copy Markdown

@konih
konih merged commit 4e8c7b4 into main Oct 16, 2026
45 checks passed
@konih
konih deleted the feat/cross-file-consistency-gates branch October 16, 2026 01:06
konih added a commit that referenced this pull request Oct 16, 2026
Fill the post-merge Verification rows (CFC-5 first run via a
workflow_dispatch of go-patch-lag on main@4e8c7b40d; PR #463 head
checks and main post-merge CI), tick 6.2, close the loop hand-off, and
archive the change. The living spec lands in
openspec/specs/cross-file-consistency/.

Refs #441
konih added a commit that referenced this pull request Oct 16, 2026
…wnership decisions

Re-measured against main after cross-file-consistency-gates (#463):
govulncheck v1.9.0 at Taskfile.yml:204, gitleaks env at ci.yaml:164,
golangci-lint v2.14.0, a second dead Renovate manager (shellcheck), and
git-cliff publishing only per-asset .sha512 checksums.

Pin ownership A: golangci-lint and govulncheck floors and the
Makefile/.custom-gcl.yml agreement move out of this change; CFC-2 owns
them. Guard shape C: the checks live in consistency_toolchain_pins_test.sh,
enrolled by CFC-6, and dev_mise_pin_drift_test.sh is folded into it
mutant for mutant. Every behaviour task now follows the task that can
fail on it, and manager liveness is defined only after the probe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bump go.mod to the shipped Go; guard Go, tool pins, RBAC and metrics drift

1 participant