Skip to content

🔧 build: pin and verify the developer toolchain in one place - #465

Merged
konih merged 41 commits into
mainfrom
feat/developer-toolchain-pins
Oct 16, 2026
Merged

konih merged 41 commits into
mainfrom
feat/developer-toolchain-pins

Conversation

@konih

@konih konih commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

TL;DR

  • What: Task 3.52.0 at every site, govulncheck behind one GOVULNCHECK_VERSION variable, a new guard hack/test/consistency_toolchain_pins_test.sh that replaces dev_mise_pin_drift_test.sh, live Renovate managers for every pin site, and mandatory checksums in the git-cliff and helm-docs installers.
  • Why: tool versions were stated in several files each, half the Renovate managers matched nothing, and two installers ran unverified downloads, one of them in a job that holds a ruleset-bypass token.
  • Risk: the new guard is fail-closed and runs in required lint, so a guard bug or a Renovate bump it misreads blocks every PR until fixed.
  • Reviewer action: check renovate.json (managers and groups) and the installer checksum paths; approving also confirms the two model-aligned decisions below.

Closes #442.

The same tool was pinned in up to four files with nothing keeping them in step, and the Renovate managers meant to move them were partly dead, so "no update PR" looked the same as "up to date". This PR makes every tool's sites agree, gives every site a live Renovate manager, and makes the guard red when they drift.

Most important things

  • New guard replaces dev_mise_pin_drift_test.sh. hack/test/consistency_toolchain_pins_test.sh absorbs the old guard's 12 cases 1:1 and adds a 13th. It checks that Task, gitleaks, git-cliff and shellcheck sites agree and meet their floors (never exact values, so a bot bump that moves every site stays green); that shellcheck's installer has a digest for its pin; that task vulncheck runs the annotated GOVULNCHECK_VERSION; that mise.toml and Taskfile.yml comments restate no version away from its pin; and that every Renovate custom manager is alive, covers every pin site and keeps each tool in one group. 146 self-test cases, including an all-pins bump control so a correct Renovate bump cannot red the guard.
  • Renovate. The dead gitleaks and shellcheck managers are replaced by ones matching the real pins. New managers cover gitleaks, shellcheck, shellcheck-py (pre-commit), git-cliff (Taskfile.yml, release.yaml, hack/verify-changelog.sh) and golangci-lint (Makefile, .custom-gcl.yml). shellcheck and golangci-lint each get their own group: a shellcheck bump is red until its digest is added, and a red golangci-lint bump must not hold back Task, gitleaks, git-cliff or govulncheck. A packageRule stops the github-actions manager bumping node-version/python-version inputs without mise.toml.
  • Installer checksums are mandatory. git-cliff verifies its per-asset .sha512; helm-docs verifies against checksums.txt (64-hex enforced). UNVERIFIED_BY_DESIGN in ci_fetch_lib_hardening_test.sh is now empty and limited to empty. hack/lib/verify-sha256.sh and the git-cliff path fall back to shasum -a on macOS and refuse when neither tool exists. That fallback has had no real Darwin run; it is covered by tests on Linux only.
  • CFC-2 tightened (change 4's guard). hack/test/consistency_tool_pins_test.sh now requires a full semver for literal and Taskfile-variable pins, so latest or a go query such as v1 no longer passes; its mutants were re-anchored because govulncheck moved into a variable. This is a test change to another change's guard.
  • Self-test moved, not added to lint. The new guard's --self-test runs in the existing required job tool-pins-selftest, next to CFC-2's. The job now takes about 15 min (45 min timeout). No new job, so no ruleset change. CFC-6 (ci_workflow_security_test.sh) now pins both self-tests to that job.
  • Two model-aligned decisions, pending operator validation (recorded in proposal.md): A, pin ownership: golangci-lint and govulncheck get no floor or agreement check here, because CFC-2 already owns those pins. C, guard shape: a new consistency_* guard instead of extending dev_mise_pin_drift_test.sh, which is folded in and deleted.

Risk

  • What can break: a false red in the guard blocks every PR, since it runs plain in lint and with --self-test in tool-pins-selftest. A Renovate bump that changes a file shape the guard's Renovate model does not know (presets, minimatch edge cases) could red the bot PR. The macOS checksum path could fail on a real Mac.
  • Blast radius: CI gating, Renovate behaviour and the two installers (changelog-sync.yaml runs git-cliff). No product code changes.
  • Rollback: revert the PR. No ruleset or required-check changes to undo.
  • Residual risk accepted: BACKLOG CFC2-MAKEFILE-LATEST (a Makefile go-install-tool var or literal @latest still floats past CFC-2; ENVTEST_VERSION's branch pin needs its own decision), DTP-SHA512-MACOS, the shasum fallback for install-helm/polaris, and case-sensitive setup-go matching in change 4's go-toolchain guard. The guard's Renovate model is a Python reading of renovate.json, checked against Renovate 44.115.4 extract dry-runs (41 deps), not Renovate itself.

Files to look at first

  • hack/test/consistency_toolchain_pins_test.sh: the new guard; start at the Renovate model and the all-pins bump control.
  • hack/test/consistency_tool_pins_test.sh: CFC-2's full-semver rule and re-anchored mutants.
  • hack/test/ci_workflow_security_test.sh: CFC-6 now pins both self-tests to tool-pins-selftest; also fixes a grep -v | grep -q SIGPIPE flake under pipefail.
  • .github/workflows/ci.yaml: self-test step moved to tool-pins-selftest, old dev_mise step removed.
  • renovate.json: new managers, own groups for shellcheck and golangci-lint, node/python uses-with disabled.
  • hack/install-git-cliff.sh, hack/install-helm-docs.sh, hack/lib/verify-sha256.sh: supply-chain change.
  • .github/workflows/*, .github/actions/kind-e2e-setup/action.yml, mise.toml, Taskfile.yml: Task 3.52.0 at every site and GOVULNCHECK_VERSION.

Review this commit-by-commit, not as one flattened diff. Each check landed with the fix it gates, and the review fixes are separate commits; most of the file count is OpenSpec evidence under openspec/changes/.

Testing protocol

  • Locally: guard plain and --self-test, CFC-2 and CFC-6 both modes, task lint:shell, task lint:markdown, task spec:validate, task vulncheck, evidence-markdown guard; task check green at both branch-review rounds.
  • Real git-cliff and helm-docs installs on linux/amd64; Renovate 44.115.4 extract and lookup dry-runs.
  • Scrub grep and gitleaks detect over origin/main..HEAD clean.
  • Every check on the PR head SHA is green, SonarCloud included; tool-pins-selftest shows both self-tests (15-20 min expected).
  • Rebase-merge: gh pr merge <n> --rebase --delete-branch.
  • On a Mac: bash hack/install-git-cliff.sh and bash hack/install-helm-docs.sh complete; a tampered archive fails with checksum mismatch.

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.

Where a human should look first (from the loop state)

  • hack/test/consistency_toolchain_pins_test.sh: new guard (absorbs dev_mise_pin_drift_test.sh; 146 self-test cases incl. all-pins bump control and a Python Renovate model).
  • hack/test/consistency_tool_pins_test.sh: change 4's CFC-2 tightened; re-anchored mutants.
  • hack/test/ci_workflow_security_test.sh: CFC-6 pins both self-tests to tool-pins-selftest; guard_modes SIGPIPE flake fix.
  • .github/workflows/ci.yaml: toolchain self-test in tool-pins-selftest; old dev_mise step removed.
  • renovate.json: new managers (gitleaks, shellcheck, shellcheck-py, git-cliff, golangci-lint), own groups for shellcheck and golangci-lint, uses-with node/python disabled.
  • hack/install-git-cliff.sh, hack/install-helm-docs.sh, hack/lib/verify-sha256.sh: checksums mandatory, macOS shasum fallback.
  • .github/workflows/* + mise.toml: Task 3.52.0 at 19 sites; govulncheck via GOVULNCHECK_VERSION.

Why the self-test left lint

The guard's --self-test took about 233 s locally. The operator ruled on 2026-10-16 that slow self-tests stay out of lint, so it runs as a second step in tool-pins-selftest, which is already required. A new job would have needed a ruleset change after merge.

Why golangci-lint got its own group

All custom.regex dependencies shared the "pinned build tools" group. A golangci-lint bump often needs code fixes, and a red golangci-lint PR would have held back every other tool in that group.

Review history

Spec-set review, a per-task loop with independent reviewers, and two branch-review rounds; every reproduced finding was fixed with a mutant, and the rest are recorded with dispositions in openspec/changes/developer-toolchain-pins/loop.md (Triage table). The G6 fixes were split into ten commits from one tested tree; all eleven passed a per-commit gate in round 2.

konih added 30 commits October 16, 2026 11:54
…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.
Fixes R1-R13, R15 and R17 from reviews/R-spec-set/verification.md: shellcheck
gets its own Renovate group and a three-site agreement check with a digest-table
case, git-cliff gains its release.yaml and verify-changelog.sh sites, each red
check moves into the task that turns it green, DTP-5 defines comment attachment,
DTP-7 splits the unwired scenario into CWS-6 and CFC-6 cases, and the installer
tests gain a valid-download control and a ratchet to an empty exception list.
New hack/test/consistency_toolchain_pins_test.sh checks that gitleaks, git-cliff and shellcheck agree across their pin sites and meet their floors, that shellcheck has digests for its pin, and that UNVERIFIED_BY_DESIGN only shrinks (DTP-3, DTP-6). Its --self-test breaks one clause per tree copy and also proves CWS-6/CFC-6 red when its lint steps are removed (DTP-7). Wired into the lint job, plain and --self-test, in the same commit because CFC-6 reds an unwired consistency guard.

guard_modes in ci_workflow_security_test.sh now greps to /dev/null instead of grep -q: under pipefail the upstream grep died of SIGPIPE and the --self-test mode was dropped at random, which made the new wiring case flaky.
DEV-MISE-01 now runs inside hack/test/consistency_toolchain_pins_test.sh. Each of the old guard's twelve cases is a --self-test mutant on a copy of the real tree, red for its own clause only; a thirteenth pins the [tools] scope, which no old case covered. The old guard, its ci.yaml step and its self-wiring grep go: CWS-6 and CFC-6 hold the new guard in the lint job in both modes. Live references, including the CWS-6 composite-action mutant and the renovate.json description, now name the new guard. Task 1.2 of developer-toolchain-pins; evidence in evidence/T1.2.md.
hack/test/consistency_toolchain_pins_test.sh now checks Task: the with: version: input of every
go-task/setup-task step under .github and mise.toml's task must agree and be at least 3.52.0. A
step without the input now fails; it used to be skipped, which let Task install unpinned. The
step reader git-cliff already used is shared, and DEV-MISE-01 keeps only the presence of mise's
task entry, so each mutant reds once. Self-test cases cover a missed site, a set below the floor,
a bot bump to a newer release (green), a step without version:, a lost named site and an empty
scan. The 18 setup-task inputs and mise.toml move from 3.51.1 to 3.52.0, and setup.md names the
input instead of restating the version. Task 2.1 of developer-toolchain-pins; evidence in
evidence/T2.1.md.
task vulncheck carried govulncheck@v1.9.0 inline, where no Renovate
manager sees it. Move it to GOVULNCHECK_VERSION in Taskfile.yml under a
datasource=go annotation (the Go module datasource; a github-releases
annotation would extract but never look up) and have vulncheck read it.

The toolchain-pins guard now fails when vulncheck pins inline, reads
another variable, sets its own task-level GOVULNCHECK_VERSION, or when
the definition is missing, outside vars:, not a plain vN.N.N, or not
directly under the exact annotation the Taskfile manager extracts.

Change 4's two CFC-2 mutants that rewrote the literal govulncheck@v...
no longer landed; they now rewrite the GOVULNCHECK_VERSION line, with
unchanged sentinels.
Add the DTP-5 comment rule to the toolchain-pins guard: a version in a
mise.toml or Taskfile.yml comment is allowed only in the comment run
directly above (or inline on) the pin it names, at that pin's value, and
any count of pin sites fails. Rewrite the four stale mise.toml comments
(git-cliff/helm-docs, helm, go, the task site count) as pointers to the
file that owns each pin; the task comment drops its version because
Renovate bumps the pin but not the comment.
The gitleaks and shellcheck custom managers matched ci.yaml but extracted
nothing since CI moved to the install scripts, so no bot bump reached the
three gitleaks, three shellcheck, two golangci-lint or two git-cliff sites
outside Taskfile.yml.

The toolchain-pins guard now reads renovate.json with a python model of
Renovate's regex managers and packageRules order (the last matching
groupName wins): every manager must hit a tracked file and extract a
dependency from it, no file pattern may match nothing, every gitleaks,
git-cliff, shellcheck, Task, govulncheck and golangci-lint site must be extracted at its
line and value, each tool's sites must share one group, and shellcheck's
group must hold nothing else. Shapes the model does not implement (RE2-foreign
regex syntax included) fail.
The model's extraction was checked identical to Renovate 44.115.4's on this
tree; Renovate itself is not run in lint (its group lookup needs the GitHub
API and a token).

renovate.json: the dead managers are replaced (the version captured without
the v so the pre-commit rev keeps it), shellcheck-py's four-part rev is its
own github-tags dependency, golangci-lint and the remaining git-cliff sites
get managers, and a shellcheck group rule sits before the go toolchain rule:
a shellcheck bump stays red until its digests are added, and now holds back
no other tool.
Probe, mechanism choice, red-on-base output, review records and gate runs for the DTP-4 manager checks.
install-git-cliff.sh now checks the tarball with sha512sum against the
per-asset .sha512 upstream publishes (there is no .sha256), and
install-helm-docs.sh checks it with verify_sha256 against checksums.txt.
Both fetch the checksum first and install nothing on a mismatch, a
missing checksum file or a file with no line for the asset.

The fetch-lib hardening gate's checksum scan accepts sha512sum, and
UNVERIFIED_BY_DESIGN is empty, as is the toolchain-pins guard's limit
for it. The guard's self-test runs each installer against a fake
fetch_to on valid, tampered, missing-checksum and other-asset
downloads, and on a copy with its verification removed (DTP-6).
The checksum comes from the same release as the tarball, so it catches a corrupted or swapped download, not a compromised release. The comments no longer promise more.
The git-cliff sentinels, probes and env-mutant values hard-coded v2.13.1, so a correct git-cliff bump redded --self-test and held the shared Renovate group back (stage-B B1). They now read Taskfile.yml, and a new control bumps every pin the Renovate model extracts to its next patch release (shellcheck digests added by hand, as its PR would) and requires plain mode and a nested --self-test to pass on that copy. Task 6.1.
The self-test takes about 8.5 minutes with the bump control; lint runs it no more, following the operator ruling (2026-10-16) that keeps slow self-tests out of lint. It joins the CFC-2 self-test in the existing required tool-pins-selftest job (timeout 45 min kept; the two took about 16 minutes locally). CFC-6 now pins both self-tests to that job, with a mutant that moves the toolchain one back into lint; the tool-pins move mutant moves only its own step, and the not-required mutant names either self-test. Task 6.2.
Stock macOS has no sha512sum or sha256sum, so the git-cliff installer and verify_sha256 (helm-docs) failed closed there with 'command not found'. Both now fall back to shasum -a 512 / -a 256 and refuse, saying why, only when neither tool exists. helm-docs also requires its digest to be 64 hex before comparing (git-cliff already required 128). The toolchain-pins self-test runs both installers with sha*sum hidden from PATH (shasum only: valid installs, tampered refused; no hash tool: refused, nothing installed) and on a checksum line one hex digit short. Tasks 6.3 and 6.4.
A {{.VAR}} pin resolved through Taskfile.yml was used as-is, so GOVULNCHECK_VERSION: latest resolved through go mod download and passed while a literal @latest is skipped as no pin. A resolved value that is not a version is now reported as unresolved. Test change on change 4's guard: one new self-test mutant. Task 6.5.
GitHub resolves owner/repo case-insensitively, so a step using Go-Task/Setup-Task@ installs the same Task, but the DTP-1 and git-cliff readers matched the lower-case spelling only and skipped such a site, drift included. They now match case-insensitively; renovate.json's managers still match lower case only, so such a site also reds as uncovered (DTP-4). New self-test mutants: a drifted mixed-case setup-task and git-cliff-action step, and task = "latest" in mise.toml. Task 6.6.
golangci-lint shared 'pinned build tools', so a golangci-lint bump that is red on new findings, or on a Go it needs, held Task, gitleaks, git-cliff and govulncheck back. It now has its own group, after 'pinned build tools' and before 'go toolchain'. The toolchain-pins guard's alone-group check (shellcheck until now) covers golangci-lint too, with a mutant that removes the rule; the split-group mutant's sentinel names the new group. Task 6.7.
konih added 11 commits October 16, 2026 11:54
Drop the CWS-6 line number from DTP-7 and state where the guard is stronger than the spec: DTP-5 reads any dotted or quoted comment number as a version, DTP-4 fails a manager on one rotted file pattern. Record the G6 behaviour: golangci-lint's own group (DTP-4), the shasum fallback, no-hash-tool refusal and full-hex digest (DTP-6), the self-test's job and the all-pins bump control (DTP-7). The Renovate model's comment names its extends-presets limit. Task 6.8 and the spec side of 6.1, 6.3, 6.4 and 6.7.
A partial version such as v1 passed the v-digit check, and go mod download resolves it as a query to the newest v1 release, so a literal or Taskfile-variable pin of v1 floated. Literal and Taskfile-variable pins must now be full semantic versions (pre-release and pseudo-versions allowed), with a self-test mutant for a v1 variable. Review follow-up to task 6.5.
The inline and env: govulncheck mutants restated v1.9.0, which stops being today's pin after a Renovate bump; they now read GOVULNCHECK_VERSION like the git-cliff cases. The nested run says it skips the bump control, the Renovate model's extends comment says its dry-runs predate the golangci-lint group, and verify-sha256_test.sh covers the shasum fallback and the no-hash-tool refusal of the shared helper directly. Review follow-up to tasks 6.1, 6.3 and 6.8.
Evidence for the stage-B round-1 fixes (tasks 6.1 to 6.8): red runs, hand mutants, gate runs on the final tree, two review rounds and their dispositions. Ticks 6.1 to 6.8.
…ns group

Renovate's github-actions manager extracts the node-version and
python-version inputs of actions/setup-node and actions/setup-python
(depType uses-with), so a bump would land in the "github actions" group
without mise.toml and the DEV-MISE-01 check would red that whole group
PR. A last packageRule disables those updates; they stay hand-bumped
with mise.toml. The toolchain-pins guard's Renovate model now reads
those inputs and fails if any stays enabled, with mutants for the rule
removed and re-enabled by a later rule. The ci.yaml comment that said
nothing else manages these inputs is corrected.
@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!

@sonarqubecloud

Copy link
Copy Markdown

@konih
konih merged commit 7ced03a into main Oct 16, 2026
42 checks passed
@konih
konih deleted the feat/developer-toolchain-pins branch October 16, 2026 10:50
konih added a commit that referenced this pull request Oct 16, 2026
Fill the Verification rows from the evidence files (DTP-1..DTP-7) and
the PR row (PR #465 head 6f5dc8e: 39 checks green, 3 skipped; cold
review REQUEST_CHANGES with 2 non-blocking P2 findings deferred to the
backlog; main post-merge CI by headSha 7ced03a: all workflows
success), tick 5.1, close the loop hand-off, and archive the change.
The living spec lands in openspec/specs/developer-toolchain/.

Refs #442
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.

Align tool pins, revive dead Renovate managers, verify installer checksums

1 participant