Key go-installed tool binaries on the Go toolchain that builds them - #708
Conversation
- Tools built with "go install" are cached at $(DOWNLOAD_DIR)/tools/<tool>@<version>_<os>_<arch>, a path which says nothing about the Go toolchain that built them. - CI persists that download directory between runs, so after a VENDORED_GO_VERSION bump the stale binary is restored and reused indefinitely, even when it can no longer parse the new standard library. - Include the Go version in the path of Go-built tools, so that a Go upgrade forces a rebuild, and depend on the VENDORED_GO_VERSION stamp file so the unversioned symlink is re-pointed. Refs: cert-manager/cert-manager#9174 Signed-off-by: Richard Wall <richard.wall@cyberark.com>
There was a problem hiding this comment.
Pull request overview
Keys Go-installed tool binaries by vendored Go version, preventing stale cached binaries after toolchain upgrades.
Changes:
- Adds per-tool download path variables.
- Adds Go versions to Go-built binary paths.
- Relinks tools when the vendored Go version changes.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- When vendoring is disabled, tools are built with the system Go, so keying the download path on VENDORED_GO_VERSION mislabels binaries in the shared cache and a system Go upgrade never invalidates them. Key the path and the stamp file on the Go version actually used: "go env GOVERSION" for the system Go, go$(VENDORED_GO_VERSION) when vendoring. - Make the versioned binary a normal prerequisite of the unversioned symlink, so a rebuilt binary always re-points the symlink. Previously an existing symlink newer than the stamp files caused the binary to be rebuilt at the new path while the symlink kept pointing at the old one. In the steady state the symlink resolves to the same file as the prerequisite, so nothing is remade. - Update the LN comment for the new path format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
The download directory ($(DOWNLOAD_DIR)) is persisted between CI runs, and for some repositories it is a node-local directory shared with less-trusted jobs that can overwrite a cached binary in place. Until now a tool's SHA-256 was only checked when it was first downloaded, so a binary swapped in the cache afterwards was linked onto PATH and executed unverified. Make the per-tool symlink a .PHONY target and re-hash the cached binary against the reviewed SHA-256 in this file on every build, before linking. A mismatch deletes the binary and re-downloads it, so a poisoned cache cannot be used. The reviewed hash is the trust anchor, so no signing or fork-write-scoping is needed. For this the reviewed SHA must be the hash of the extracted binary, not the downloaded archive: the archive recipes now hash the binary after extraction (the download still fails closed via the lock script if the hash is wrong), etcd and kube-apiserver gain their own binary hashes, and the SHAs for the affected tools have been relearned with "make learn-tools-shas". Tools built from source with "go install" have no reviewed hash here; they are anchored by go.sum/GOSUMDB when built and their staleness is already handled by keying the download path on the Go toolchain version, so the check skips them (empty hash variables are defined to keep --warn-undefined-variables quiet). Stacked on cert-manager#708. Supersedes cert-manager#625 (whole-cache purge run as a separate step you must remember) and cert-manager/cert-manager#8833 (verify at symlink-creation), folding both into cert-manager#708's tool_link_defs seam so the check is automatic and cannot be bypassed by a job that forgets to run it. Known limitation: the vendored-Go tarball is still verified at download only; the shared PATH is process-wide, so this closes cache poisoning, not the separate problem of a target using an undeclared tool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
SgtCoDFish
left a comment
There was a problem hiding this comment.
The design is right and the implementation is correct in the happy path. The refactor — tool_defs publishes $(XXX_DOWNLOAD_PATH), go_dependency overrides it, symlink rules generated afterwards from the variable — is a genuine simplification, and the write-up is exemplary.
I checked out the branch and verified the three behaviours the description claims, against a real gojq build:
steady state: "Prerequisite '.../gojq@v0.12.19_go1.26.7_darwin_arm64' is older than
target '_bin/tools/gojq'" -> No need to remake OK
toolchain bump: rebuilds at gojq@v0.12.19_go9.9.9_darwin_arm64,
symlink re-pointed OK
revert to cached: relinks to gojq@v0.12.19_go1.26.7_darwin_arm64,
no rebuild OK
non-go tools: HELM_DOWNLOAD_PATH unchanged
(helm@v4.2.4_darwin_arm64) OK
My substantive comments are inline, and both land on the one new line that computes the key. Two further points that have no good anchor:
Cache growth — the "no eviction" limitation deserves a follow-up issue
You call this out, and I agree pruning the shared $HOME cache is the wrong move here. But the numbers are worth stating: cert-manager builds ~35 Go tools, several of them large (golangci-lint, cosign, syft, goreleaser; openapi-gen is 25MB on its own). A full generation is on the order of 1GB+, and GitHub Actions caps caches at 10GB per repo, so a few Go bumps will start evicting. Prow's hostPath node caches have no eviction at all. Could you file a follow-up for an opt-in prune-tools-cache target, or at least a note in the module README, so this doesn't quietly become someone's disk-full incident?
No test asserts the new behaviour
tests/e2e-projects/test-project/test-config.sh builds only kind, kubectl and etcd explicitly; Go tools are exercised incidentally via generate/verify, and nothing checks invalidation. A cheap addition that would have caught the whitespace bug below:
targets_to_run+=(
"_bin/tools/gojq"
)plus an assertion in test_e2e.sh that re-running with GO_TOOLCHAIN_VERSION=go0.0.0-test re-points _bin/tools/gojq, and that reverting re-points it back without rebuilding. That is the whole contract of this PR, and it is currently covered only by the manual testing in the description.
Nothing here blocks the fix in principle, but I'd want the two inline findings addressed before merge, since both silently defeat the invalidation this PR exists to provide.
| # The version of the Go toolchain that builds the go_dependencies tools, e.g. | ||
| # "go1.27.0". When vendoring is disabled this is the system Go, which may | ||
| # differ from VENDORED_GO_VERSION. | ||
| GO_TOOLCHAIN_VERSION := $(shell go env GOVERSION 2>/dev/null) |
There was a problem hiding this comment.
Two problems with this line, both silent, and both fixable together.
1. go env GOVERSION can download a toolchain at parse time, and its failure is swallowed
This is a := at the top level of the non-vendored branch, so it runs in $(CURDIR) — a Go module directory — on every make invocation in every downstream repo that doesn't vendor Go, including make help. With the default GOTOOLCHAIN=auto, if the repo's go.mod go/toolchain directive is newer than the installed Go, this triggers a toolchain download during makefile parsing:
$ cat go.mod # module x / go 1.27.0, installed go is 1.26.7
$ GOPROXY=off go env GOVERSION
go: downloading go1.27.0 (darwin/arm64)
go: download go1.27.0 for darwin/arm64: toolchain not available
exit=1When that fails, 2>/dev/null hides the message and $(shell) discards the exit status, so GO_TOOLCHAIN_VERSION is silently empty and every toolchain collapses onto one key:
$ make -n GO_TOOLCHAIN_VERSION= _bin/tools/gojq
... ln -fsn /tmp/mmtest708/dl/tools/gojq@v0.12.19__darwin_arm64 gojqThat is exactly the stale-binary bug this PR fixes, reintroduced — but now invisible, because the path looks keyed. Air-gapped CI, GOPROXY=off, a restricted-egress Prow job, or a bad GOFLAGS all land here.
GOTOOLCHAIN=local fixes both halves, and is the exact analogue of the vendored branch's go$(VENDORED_GO_VERSION) (also a local-toolchain label, not a switched one):
$ GOTOOLCHAIN=local go env GOVERSION # in the same too-new module dir
go1.26.7 # never downloads, never failsYou already note under "Known limitations" that GOTOOLCHAIN=local would make the label exact (#202) — worth pulling that in here, since it buys robustness too, not just accuracy.
2. A whitespace-bearing GOVERSION silently corrupts the generated rules
runtime.Version() for a gotip/devel toolchain is multi-word. Because the value lands unquoted in target names, make word-splits it and the file quietly produces nonsense:
$ make -n 'GO_TOOLCHAIN_VERSION=devel go1.28-abc123 Wed Aug 20' _bin/tools/gojq
source tools//util/lock.sh 20_darwin_arm64; ... go install ...pinact/v4/cmd/pinact@v4.1.1 ...
cd _bin/tools/ && ln -fsn /tmp/.../gojq@v0.12.19_devel go1.28-abc123 Wed Aug 20_darwin_arm64 gojqNote it built pinact while asked for gojq, and ln got four arguments. No error, no warning. GOTOOLCHAIN=local doesn't help here — a devel toolchain is the local one.
Suggested fix
# GOTOOLCHAIN=local: never trigger a toolchain download while parsing this file,
# and match the go$(VENDORED_GO_VERSION) form used when Go is vendored.
# The awk pass keeps the value safe to embed in a target name: a devel toolchain
# reports a multi-word GOVERSION, which would word-split the generated rules.
GO_TOOLCHAIN_VERSION := $(shell GOTOOLCHAIN=local go env GOVERSION 2>/dev/null | awk '{gsub(/[^A-Za-z0-9._-]/,"-"); print}')
ifeq ($(GO_TOOLCHAIN_VERSION),)
GO_TOOLCHAIN_VERSION := unknown
endifI'd keep the empty case non-fatal rather than $(error ...): make help and make non-go-tools currently work with no Go installed and shouldn't regress. unknown is at least self-describing in a filename, and nothing can be built in that state anyway.
There was a problem hiding this comment.
Good catch on both counts. Reproduced the download here: go1.25.5 plus a go 1.99.0 module dir, then GOPROXY=off GOTOOLCHAIN=auto go env GOVERSION → go: downloading go1.99.0, exit 1 — and with 2>/dev/null it is completely silent. I had not realised go env GOVERSION goes through toolchain selection like any other subcommand: only GOTOOLCHAIN, GOMOD and GOWORK are exempt (https://github.com/golang/go/blob/go1.25.5/src/cmd/go/internal/toolchain/select.go#L105-L125).
Taken your suggestion as-is: GOTOOLCHAIN=local, the awk sanitiser, and a non-fatal unknown fallback so make help still works with no Go installed. Verified all three cases locally (too-new module dir with GOPROXY=off now yields the local version; the devel string sanitises to devel-go1.28-abc123-Wed-Aug-20; no Go on PATH yields unknown). PR description updated to match.
with claude fable-5
| # library. Without this, a cached binary is never rebuilt after a Go upgrade: | ||
| # the download directory is persisted between CI runs, so the stale binary is | ||
| # restored and reused indefinitely. | ||
| $(call uc,$1)_DOWNLOAD_PATH := $$(DOWNLOAD_DIR)/tools/$1@$($(call uc,$1)_VERSION)_$$(GO_TOOLCHAIN_VERSION)_$(HOST_OS)_$(HOST_ARCH) |
There was a problem hiding this comment.
Nit: the two _DOWNLOAD_PATH assignments escape differently for the same intent. This one uses $(HOST_OS)/$($(call uc,$1)_VERSION) but $$(GO_TOOLCHAIN_VERSION), while the one in tool_defs uses $$(HOST_OS)/$$($(call uc,$1)_VERSION). Both are := so the results are identical, but a reader has to work that out — and these two lines are precisely the pair you want read side by side. Worth making them consistent.
There was a problem hiding this comment.
Done — the go_dependency assignment now uses the same $$(...) escaping as the tool_defs one, so the two lines read identically.
with claude fable-5
| $$(bin_dir)/tools/$1: $$(bin_dir)/scratch/$(call uc,$1)_VERSION $(if $(filter $1,$(go_tool_names)),$$(bin_dir)/scratch/GO_TOOLCHAIN_VERSION) $$($(call uc,$1)_DOWNLOAD_PATH) | $$(bin_dir)/tools | ||
| @# cd into tools dir and create relative symlink (e.g., ../downloaded/tools/helm@v4.0.1_darwin_arm64) | ||
| @# patsubst converts absolute path to relative by replacing $(bin_dir) with .. |
There was a problem hiding this comment.
Two comment nits in this block:
-
$(bin_dir)/scratch/GO_TOOLCHAIN_VERSIONonly works because the%_VERSIONpattern rule ~250 lines up sets$*toGO_TOOLCHAINand there happens to be aGO_TOOLCHAIN_VERSIONvariable. That's correct and rather neat, but non-obvious enough at this distance to earn half a sentence in the block comment above. -
The two
@#comment lines moved verbatim, but they're misleading and this PR is the natural place to fix them.DOWNLOAD_DIRdefaults to$(HOME)/.cache/makefile-modules/downloaded(or$(CURDIR)/$(bin_dir)/downloadedunder CI), neither of which matches$(bin_dir)/%, so thepatsubstis a no-op and the link is absolute in practice:$ ls -l _bin/tools/gojq _bin/tools/gojq -> /Users/…/.cache/makefile-modules/downloaded/tools/gojq@v0.12.19_go1.26.7_darwin_arm64
The example
../downloaded/tools/helm@v4.0.1_darwin_arm64never happens.
There was a problem hiding this comment.
Fixed both: the block comment now notes that the GO_TOOLCHAIN_VERSION stamp comes from the generic %_VERSION pattern rule stamping the make variable of the same name, and the @# recipe comments now say the link is absolute in practice, with the patsubst only applying when DOWNLOAD_DIR is overridden to live under $(bin_dir).
with claude fable-5
|
(This is the first time I've ever actually let claude post its own review comments - normally I paraphrase what it says... but I know your workflow will be AI heavy and I think the comments are technical enough that paraphrasing won't help, so I'll give you the lossless source) |
…tion Query the system Go with GOTOOLCHAIN=local so that computing the key can never trigger a toolchain download at makefile parse time, nor fail silently to an empty key when the download is impossible. Sanitise the value because a devel toolchain reports a multi-word GOVERSION which would word-split the generated rules, and fall back to a non-fatal "unknown" so that make help still works with no Go installed. Align the escaping of the two _DOWNLOAD_PATH assignments, correct the symlink recipe comments (the link is absolute in practice), and note where the GO_TOOLCHAIN_VERSION stamp file comes from. Assert the invalidation contract in the e2e test: a toolchain version change rebuilds and re-links a go_dependency tool, and reverting re-links the cached binary without rebuilding. Inodes, not mtimes, are compared because the relink recipe touches through the symlink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
|
Thanks for the thorough review — both inline findings were real, and are fixed (replies inline). On the two further points:
with claude fable-5 |
The download directory ($(DOWNLOAD_DIR)) is persisted between CI runs, and for some repositories it is a node-local directory shared with less-trusted jobs that can overwrite a cached binary in place. Until now a tool's SHA-256 was only checked when it was first downloaded, so a binary swapped in the cache afterwards was linked onto PATH and executed unverified. Make the per-tool symlink a .PHONY target and re-hash the cached binary against the reviewed SHA-256 in this file on every build, before linking. A mismatch deletes the binary and re-downloads it, so a poisoned cache cannot be used. The reviewed hash is the trust anchor, so no signing or fork-write-scoping is needed. For this the reviewed SHA must be the hash of the extracted binary, not the downloaded archive: the archive recipes now hash the binary after extraction (the download still fails closed via the lock script if the hash is wrong), etcd and kube-apiserver gain their own binary hashes, and the SHAs for the affected tools have been relearned with "make learn-tools-shas". Tools built from source with "go install" have no reviewed hash here; they are anchored by go.sum/GOSUMDB when built and their staleness is already handled by keying the download path on the Go toolchain version, so the check skips them (empty hash variables are defined to keep --warn-undefined-variables quiet). Stacked on cert-manager#708. Supersedes cert-manager#625 (whole-cache purge run as a separate step you must remember) and cert-manager/cert-manager#8833 (verify at symlink-creation), folding both into cert-manager#708's tool_link_defs seam so the check is automatic and cannot be bypassed by a job that forgets to run it. Known limitation: the vendored-Go tarball is still verified at download only; the shared PATH is process-wide, so this closes cache poisoning, not the separate problem of a target using an undeclared tool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
SgtCoDFish
left a comment
There was a problem hiding this comment.
/lgtm
/approve
/hold
Looks good now I think! Added a hold because Richard doesn't work Mondays - can unhold and merge tomorrow.
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: SgtCoDFish The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
|
/unhold |
The download directory ($(DOWNLOAD_DIR)) is persisted between CI runs, and for some repositories it is a node-local directory shared with less-trusted jobs that can overwrite a cached binary in place. Until now a tool's SHA-256 was only checked when it was first downloaded, so a binary swapped in the cache afterwards was linked onto PATH and executed unverified. Make the per-tool symlink a .PHONY target and re-hash the cached binary against the reviewed SHA-256 in this file on every build, before linking. A mismatch deletes the binary and re-downloads it, so a poisoned cache cannot be used. The reviewed hash is the trust anchor, so no signing or fork-write-scoping is needed. For this the reviewed SHA must be the hash of the extracted binary, not the downloaded archive: the archive recipes now hash the binary after extraction (the download still fails closed via the lock script if the hash is wrong), etcd and kube-apiserver gain their own binary hashes, and the SHAs for the affected tools have been relearned with "make learn-tools-shas". Tools built from source with "go install" have no reviewed hash here; they are anchored by go.sum/GOSUMDB when built and their staleness is already handled by keying the download path on the Go toolchain version, so the check skips them (empty hash variables are defined to keep --warn-undefined-variables quiet). Stacked on cert-manager#708. Supersedes cert-manager#625 (whole-cache purge run as a separate step you must remember) and cert-manager/cert-manager#8833 (verify at symlink-creation), folding both into cert-manager#708's tool_link_defs seam so the check is automatic and cannot be bypassed by a job that forgets to run it. Known limitation: the vendored-Go tarball is still verified at download only; the shared PATH is process-wide, so this closes cache poisoning, not the separate problem of a target using an undeclared tool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
Fixes: #481
Motivation
The Go 1.27 upgrade (#704) is breaking downstream repositories in a way which does not reproduce on a clean checkout — and which comes and goes between CI runs.
In cert-manager/cert-manager#9174 (the Renovate PR which vendors this module at 455529c),
make-verifyfailed because a cachedopenapi-genbinary, built by Go 1.26, can no longer parse the Go 1.27 standard library (build log):A tool built from source is cached at:
That path says nothing about the Go toolchain which produced the binary, so bumping
VENDORED_GO_VERSIONdoes not invalidate it. The download directory is deliberately persisted between CI runs — for cert-manager,preset-local-cachecopies_bin/downloadedforward from a hostPath cache shared per Prow node; for other repositories it is a GitLab CI or GitHub Actions cache.The failure is intermittent because it depends on which node a job lands on. Failed jobs do not write back their cache ("Local cache [update]: Job failed, not updating cache"), and cert-manager's master branch — still on Go 1.26.6, with
openapi-genat the same tool version — keeps re-priming node caches with a Go 1.26-built binary at the same path. A run scheduled on a master-primed node fails; a run on a node with a cold cache rebuilds the tool with Go 1.27 and passes (as the later runs of cert-manager/cert-manager#9174 did, where the job now fails only on the golangci-lint findings addressed by cert-manager/cert-manager#9175). The stale binaries cannot be cleared without purging the node caches by hand.Changes
Include the Go toolchain version in the download path of tools built with
go install:where
<goversion>is the version of the toolchain which actually builds the tool:go$(VENDORED_GO_VERSION)when Go is vendored, otherwise the system Go'sGOTOOLCHAIN=local go env GOVERSION.GOTOOLCHAIN=localstops the query itself triggering a toolchain download at makefile parse time (under the defaultGOTOOLCHAIN=auto,go env GOVERSIONruns after toolchain selection, so ago.modnewer than the installed Go makes it download — or, with the network off, fail silently to an empty key). The value is sanitised because a devel toolchain reports a multi-wordGOVERSIONwhich would word-split the generated rules, and it falls back tounknownwhen no Go is installed so that targets likemake helpkeep working. Keying on the actual toolchain means a system Go upgrade also invalidates the cache, a repository which does not vendor Go cannot mislabel binaries in the shared$HOME/.cache/makefile-modulescache, and such a repository does not rebuild every tool when a module bump changes aVENDORED_GO_VERSIONit never uses.To achieve this:
tool_defsnow exports a$(XXX_DOWNLOAD_PATH)variable instead of declaring the symlink rule directly;go_dependencyoverrides that variable for the tools it builds; and the symlink rule is generated afterwards, for every tool, from$(XXX_DOWNLOAD_PATH).$(bin_dir)/tools/xxxsymlink, so rebuilding the binary always re-points the symlink. In the steady state the symlink resolves to that same file, so nothing is remade. Go-built tools additionally depend on a$(bin_dir)/scratch/GO_TOOLCHAIN_VERSIONstamp file, which catches reverting to an older, already-cached toolchain, where file modification times cannot be trusted.Downloaded (non-Go) tools are unaffected: their paths and their learned checksums are unchanged, so
make learn-tools-shasneeds no rerun.Known limitations:
_go*_binaries would thrash the$HOMEcache shared between repositories on different toolchains. tools: no eviction of superseded Go-toolchain-keyed binaries in the download cache #711 tracks an opt-in prune target as a follow-up.go.modcarries atoolchaindirective which upgrades beyond the invoking Go, the label understates the truth: the label query is pinned withGOTOOLCHAIN=local, but thego installwhich builds the tool still runs under the defaultGOTOOLCHAIN=auto. That does not affect the problem being fixed here, which is that a Go upgrade must invalidate previously built binaries. This is an instance of theGOTOOLCHAIN=autoambiguity described in Makefile Modules, Go Versions and Vendoring #202; adoptingGOTOOLCHAIN=localmodule-wide there would make the label exact.Testing
Verified locally against the two repositories where the breakage was observed, by copying the patched module over their vendored copy:
_bin/tools/openapi-genand rebuilt: the binary was rebuilt atopenapi-gen@v0.0.0-20260721132016-d427ff9ee9ad_go1.27.0_linux_amd64andmake generate-codegenthen produced no diff, i.e. themake-verifyfailure on that pull request is entirely the stale-binary problem.klone@v0.3.0_go1.27.0_linux_amd64; a repeat run was a no-op; settingVENDORED_GO_VERSION := 1.26.5rebuilt and relinked toklone@v0.3.0_go1.26.5_linux_amd64, and switching back relinked again without rebuilding.goonPATH: a fresh build cachedklone@v0.3.0_go1.24.5_linux_amd64— the system toolchain, notVENDORED_GO_VERSION; a repeat run was a no-op; a toolchain bump rebuilt and re-pointed the symlink; a downgrade re-pointed to the already-cached binary without rebuilding; and a pre-existing symlink whose modification time was newer than every stamp file was still re-pointed after the rebuild.helm@v4.2.4_linux_amd64, no suffix, no re-download.make test-e2epasses in this repository, and now asserts the contract of this PR directly: building_bin/tools/gojq, overridingGO_TOOLCHAIN_VERSION=go0.0.0-testrebuilds and re-points the symlink, and reverting re-points it back to the cached binary without rebuilding.scripts/learn_tools_shas.shwas exercised in dry-run mode for one Go tool and one downloaded tool, to confirm the helper makefile still evaluates cleanly under--warn-undefined-variables.with claude fable-5