From abf87d94be1c5c611101689ce277c1cbfcdccec3 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 11 Jun 2026 12:04:06 -0400 Subject: [PATCH 1/6] streamline how dev dependencies are setup --- .github/CODEOWNERS | 3 + .github/workflows/ci-core.yml | 2 + .tool-versions | 2 +- GNUmakefile | 41 ++++++++--- go.md | 24 +++++-- tools/README.md | 27 +++++++ tools/bin/check-tool-versions | 48 +++++++++++++ tools/bin/tool-version | 124 +++++++++++++++++++++++++++++++++ tools/bin/tool-version_test.sh | 102 +++++++++++++++++++++++++++ tools/go-tools.txt | 15 ++++ 10 files changed, 375 insertions(+), 13 deletions(-) create mode 100755 tools/bin/check-tool-versions create mode 100755 tools/bin/tool-version create mode 100755 tools/bin/tool-version_test.sh create mode 100644 tools/go-tools.txt diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 75db009aa47..9a38acaea35 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -103,6 +103,9 @@ # Dependencies .tool-versions @smartcontractkit/core +/tools/go-tools.txt @smartcontractkit/core +/tools/bin/tool-version @smartcontractkit/core +/tools/bin/check-tool-versions @smartcontractkit/core go.md @smartcontractkit/core @smartcontractkit/foundations go.mod @smartcontractkit/core @smartcontractkit/foundations go.sum @smartcontractkit/core @smartcontractkit/foundations diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 3a15bd0c66e..e40e9f32f40 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -638,6 +638,8 @@ jobs: https://github.com/smartcontractkit/wsrpc/raw/main/cmd/protoc-gen-go-wsrpc/protoc-gen-go-wsrpc --output "$HOME/go/bin/protoc-gen-go-wsrpc" && chmod +x "$HOME/go/bin/protoc-gen-go-wsrpc" + - name: Check tool versions + run: make check-tool-versions - name: make generate run: | make rm-mocked diff --git a/.tool-versions b/.tool-versions index fbe4c6f89b4..27e7da9bd6c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ -golang 1.26.3 +golang 1.26.4 mockery 2.53.0 nodejs 20.13.1 pnpm 10.6.5 diff --git a/GNUmakefile b/GNUmakefile index 490535635c3..c243c1e7417 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -16,9 +16,17 @@ CL_LOOPINSTALL_OUTPUT_DIR ?= LOOPINSTALL_PUBLIC_ARGS := $(if $(strip $(CL_LOOPINSTALL_OUTPUT_DIR)),--output-installation-artifacts $(CL_LOOPINSTALL_OUTPUT_DIR)/public.json) LOOPINSTALL_PRIVATE_ARGS := $(if $(strip $(CL_LOOPINSTALL_OUTPUT_DIR)),--output-installation-artifacts $(CL_LOOPINSTALL_OUTPUT_DIR)/private.json) LOOPINSTALL_TESTING_ARGS := $(if $(strip $(CL_LOOPINSTALL_OUTPUT_DIR)),--output-installation-artifacts $(CL_LOOPINSTALL_OUTPUT_DIR)/testing.json) -GOLANGCI_LINT_VERSION = "v2.11.4" +# Dev tool versions come from the manifests via tools/bin/tool-version, never +# hardcoded here (see `make check-tool-versions`). Lazy `=` so unrelated targets +# (e.g. `make chainlink`) don't pay for the parser. +TOOL_VERSION := tools/bin/tool-version +MOCKERY_VERSION = v$(shell $(TOOL_VERSION) get mockery) +PROTOC_VERSION = $(shell $(TOOL_VERSION) get protoc) +GOLANGCI_LINT_VERSION = v$(shell $(TOOL_VERSION) get golangci-lint) # Pin path so `make generate` does not pick up a different mockery (e.g. v3) from PATH. -MOCKERY_BIN ?= $(shell GOBIN="$$(go env GOBIN)"; if [ -n "$$GOBIN" ]; then echo "$$GOBIN/mockery"; else echo "$$(go env GOPATH)/bin/mockery"; fi) +# Honor GOBIN if set, otherwise GOPATH/bin. +DEV_BIN = $(shell GOBIN="$$(go env GOBIN)"; if [ -n "$$GOBIN" ]; then echo "$$GOBIN"; else echo "$$(go env GOPATH)/bin"; fi) +MOCKERY_BIN ?= $(DEV_BIN)/mockery .PHONY: install install: install-chainlink-autoinstall ## Install chainlink and all its dependencies. @@ -33,10 +41,23 @@ install-chainlink-autoinstall: | gomod install-chainlink ## Autoinstall chainlin .PHONY: gomod gomod: ## Ensure chainlink's go dependencies are installed. @if [ -z "`which gencodec`" ]; then \ - go install github.com/smartcontractkit/gencodec@latest; \ + $(TOOL_VERSION) go-install github.com/smartcontractkit/gencodec; \ fi || true go mod download +.PHONY: install-dev-tools +install-dev-tools: ## Install all Go dev tools at pinned versions (works without mise/asdf). + $(TOOL_VERSION) go-install mockery + $(TOOL_VERSION) go-install github.com/jmank88/gomods + $(TOOL_VERSION) go-install github.com/jmank88/modgraph + $(TOOL_VERSION) go-install github.com/ugorji/go/codec/codecgen + $(TOOL_VERSION) go-install github.com/smartcontractkit/gencodec + $(MAKE) protoc-plugins + +.PHONY: check-tool-versions +check-tool-versions: ## Fail if GNUmakefile drifts from the version manifests. + tools/bin/check-tool-versions + .PHONY: gomodtidy gomodtidy: gomods ## Run go mod tidy on all modules. gomods tidy @@ -213,7 +234,7 @@ testdb-user-only: ## Prepares the test database with user only. .PHONY: gomods gomods: ## Install gomods - go install github.com/jmank88/gomods@v0.1.7 + $(TOOL_VERSION) go-install github.com/jmank88/gomods .PHONY: gomodslocalupdate gomodslocalupdate: gomods ## Run gomod-local-update @@ -223,15 +244,19 @@ gomodslocalupdate: gomods ## Run gomod-local-update .PHONY: mockery mockery: $(mockery) ## Install mockery. - go install github.com/vektra/mockery/v2@v2.53.0 + $(TOOL_VERSION) go-install mockery .PHONY: codecgen codecgen: $(codecgen) ## Install codecgen - go install github.com/ugorji/go/codec/codecgen@v1.2.10 + $(TOOL_VERSION) go-install github.com/ugorji/go/codec/codecgen .PHONY: protoc protoc: ## Install protoc - core/scripts/install-protoc.sh 29.3 / + core/scripts/install-protoc.sh $(PROTOC_VERSION) / + $(MAKE) protoc-plugins + +.PHONY: protoc-plugins +protoc-plugins: ## Install protoc plugins (versions tracked by go.mod, not the manifests). go install google.golang.org/protobuf/cmd/protoc-gen-go@`go list -m -json google.golang.org/protobuf | jq -r .Version` go install github.com/smartcontractkit/wsrpc/cmd/protoc-gen-go-wsrpc@`go list -m -json github.com/smartcontractkit/wsrpc | jq -r .Version` @@ -263,7 +288,7 @@ lint-fix: gomods ## Run golangci-lint with --fix for all modules .PHONY: modgraph modgraph: - go install github.com/jmank88/modgraph@v0.1.4 + $(TOOL_VERSION) go-install github.com/jmank88/modgraph ./tools/bin/modgraph > go.md .PHONY: test-short diff --git a/go.md b/go.md index 9dd054ae0b0..b7115af3c29 100644 --- a/go.md +++ b/go.md @@ -320,6 +320,7 @@ flowchart LR chainlink-automation --> chainlink-common click chainlink-automation href "https://github.com/smartcontractkit/chainlink-automation" chainlink-ccip --> chainlink-common + chainlink-ccip --> chainlink-evm/gethwrappers/helpers chainlink-ccip --> chainlink-protos/rmn/v1.6/go click chainlink-ccip href "https://github.com/smartcontractkit/chainlink-ccip" chainlink-ccip/ccv/chains/evm @@ -329,8 +330,8 @@ flowchart LR chainlink-ccip/chains/evm --> chainlink-ccv chainlink-ccip/chains/evm --> chainlink-ccv/deployment click chainlink-ccip/chains/evm href "https://github.com/smartcontractkit/chainlink-ccip" + chainlink-ccip/chains/solana --> chainlink-ccip chainlink-ccip/chains/solana --> chainlink-ccip/chains/solana/gobindings - chainlink-ccip/chains/solana --> chainlink-common click chainlink-ccip/chains/solana href "https://github.com/smartcontractkit/chainlink-ccip" chainlink-ccip/chains/solana/gobindings click chainlink-ccip/chains/solana/gobindings href "https://github.com/smartcontractkit/chainlink-ccip" @@ -363,7 +364,8 @@ flowchart LR click chainlink-common/keystore href "https://github.com/smartcontractkit/chainlink-common" chainlink-common/pkg/chipingress click chainlink-common/pkg/chipingress href "https://github.com/smartcontractkit/chainlink-common" - chainlink-common/pkg/monitoring + chainlink-common/pkg/monitoring --> chainlink-common + chainlink-common/pkg/monitoring --> chainlink-common/pkg/values click chainlink-common/pkg/monitoring href "https://github.com/smartcontractkit/chainlink-common" chainlink-common/pkg/values click chainlink-common/pkg/values href "https://github.com/smartcontractkit/chainlink-common" @@ -374,6 +376,7 @@ flowchart LR click chainlink-data-streams href "https://github.com/smartcontractkit/chainlink-data-streams" chainlink-deployments-framework --> ccip-owner-contracts chainlink-deployments-framework --> chainlink-ccip/chains/evm + chainlink-deployments-framework --> chainlink-protos/chainlink-catalog chainlink-deployments-framework --> chainlink-protos/job-distributor chainlink-deployments-framework --> chainlink-protos/op-catalog chainlink-deployments-framework --> mcms @@ -403,6 +406,8 @@ flowchart LR click chainlink-framework/multinode href "https://github.com/smartcontractkit/chainlink-framework" chainlink-protos/billing/go --> chainlink-protos/workflows/go click chainlink-protos/billing/go href "https://github.com/smartcontractkit/chainlink-protos" + chainlink-protos/chainlink-catalog + click chainlink-protos/chainlink-catalog href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/chainlink-ccv/committee-verifier --> chainlink-protos/chainlink-ccv/verifier click chainlink-protos/chainlink-ccv/committee-verifier href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/chainlink-ccv/heartbeat @@ -435,16 +440,20 @@ flowchart LR click chainlink-protos/svr href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/workflows/go click chainlink-protos/workflows/go href "https://github.com/smartcontractkit/chainlink-protos" - chainlink-solana --> chainlink-ccip chainlink-solana --> chainlink-ccip/chains/solana chainlink-solana --> chainlink-common/keystore chainlink-solana --> chainlink-common/pkg/monitoring + chainlink-solana --> chainlink-framework/capabilities chainlink-solana --> chainlink-framework/multinode click chainlink-solana href "https://github.com/smartcontractkit/chainlink-solana" chainlink-solana/contracts --> chainlink-deployments-framework click chainlink-solana/contracts href "https://github.com/smartcontractkit/chainlink-solana" + chainlink-solana/integration-tests --> chainlink-testing-framework/framework/components/fake + chainlink-solana/integration-tests --> chainlink/integration-tests + click chainlink-solana/integration-tests href "https://github.com/smartcontractkit/chainlink-solana" chainlink-sui --> chainlink-aptos chainlink-sui --> chainlink-ccip + chainlink-sui --> chainlink-common/pkg/values click chainlink-sui href "https://github.com/smartcontractkit/chainlink-sui" chainlink-sui/deployment --> chainlink-ccip/deployment click chainlink-sui/deployment href "https://github.com/smartcontractkit/chainlink-sui" @@ -468,6 +477,8 @@ flowchart LR click chainlink-testing-framework/lib/grafana href "https://github.com/smartcontractkit/chainlink-testing-framework" chainlink-testing-framework/parrot click chainlink-testing-framework/parrot href "https://github.com/smartcontractkit/chainlink-testing-framework" + chainlink-testing-framework/sentinel + click chainlink-testing-framework/sentinel href "https://github.com/smartcontractkit/chainlink-testing-framework" chainlink-testing-framework/seth click chainlink-testing-framework/seth href "https://github.com/smartcontractkit/chainlink-testing-framework" chainlink-testing-framework/wasp --> chainlink-testing-framework/lib @@ -480,6 +491,7 @@ flowchart LR chainlink-ton/deployment click chainlink-ton/deployment href "https://github.com/smartcontractkit/chainlink-ton" chainlink-tron/relayer --> chainlink-common + chainlink-tron/relayer --> chainlink-common/pkg/values click chainlink-tron/relayer href "https://github.com/smartcontractkit/chainlink-tron" chainlink/core/scripts --> chainlink/core/scripts/cre/environment/examples/workflows/proof-of-reserve/cron-based chainlink/core/scripts --> chainlink/system-tests/lib @@ -504,9 +516,10 @@ flowchart LR click chainlink/devenv href "https://github.com/smartcontractkit/chainlink" chainlink/devenv/fakes --> chainlink-testing-framework/framework/components/fake click chainlink/devenv/fakes href "https://github.com/smartcontractkit/chainlink" + chainlink/integration-tests --> chainlink-testing-framework/havoc + chainlink/integration-tests --> chainlink-testing-framework/sentinel chainlink/integration-tests --> chainlink/deployment click chainlink/integration-tests href "https://github.com/smartcontractkit/chainlink" - chainlink/load-tests --> chainlink-testing-framework/havoc chainlink/load-tests --> chainlink/integration-tests click chainlink/load-tests href "https://github.com/smartcontractkit/chainlink" chainlink/system-tests/lib --> chainlink-testing-framework/framework/components/chiprouter @@ -715,6 +728,7 @@ flowchart LR subgraph chainlink-protos-repo[chainlink-protos] chainlink-protos/billing/go + chainlink-protos/chainlink-catalog chainlink-protos/chainlink-ccv/committee-verifier chainlink-protos/chainlink-ccv/heartbeat chainlink-protos/chainlink-ccv/message-discovery @@ -737,6 +751,7 @@ flowchart LR subgraph chainlink-solana-repo[chainlink-solana] chainlink-solana chainlink-solana/contracts + chainlink-solana/integration-tests end click chainlink-solana-repo href "https://github.com/smartcontractkit/chainlink-solana" @@ -755,6 +770,7 @@ flowchart LR chainlink-testing-framework/lib chainlink-testing-framework/lib/grafana chainlink-testing-framework/parrot + chainlink-testing-framework/sentinel chainlink-testing-framework/seth chainlink-testing-framework/wasp end diff --git a/tools/README.md b/tools/README.md index 500a34f550d..1cc87f20f3c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -7,3 +7,30 @@ Manage Docker for development and testing ## [test](./test/) A harness for running /chainlink tests. From the repo root use **`make test`** (see [tools/test/README.md](./test/README.md)), e.g. `make test ARGS="./core/..."`. + +## Dev tool versions + +Two manifests, split by whether a version manager can install the tool: + +- [`.tool-versions`](../.tool-versions) — runtimes with an asdf/mise plugin (go, node, mockery, protoc, golangci-lint, ...). Strict ` `. +- [`go-tools.txt`](./go-tools.txt) — pure `go install` CLIs with no plugin (gomods, modgraph, codecgen, gencodec). ` `. + +Do **not** put `go install` CLIs in `.tool-versions`: asdf reads the first token as a plugin name and aborts on anything that isn't one. + +Install (pick one): + +```sh +# Plain Go (no version manager) +make install-dev-tools +make rm-mocked generate + +# mise +mise install +make install-dev-tools + +# asdf +asdf install +make install-dev-tools +``` + +`make` and CI read both manifests through [`bin/tool-version`](./bin/tool-version); no version manager is required. `make check-tool-versions` fails if the Makefile hardcodes a managed version or if `.tool-versions` golang drifts from `go.mod`. diff --git a/tools/bin/check-tool-versions b/tools/bin/check-tool-versions new file mode 100755 index 00000000000..62388d6aefa --- /dev/null +++ b/tools/bin/check-tool-versions @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Drift guard. Fails if: +# 1. GNUmakefile hardcodes a version for any tool managed by the manifests +# (it must route through $(TOOL_VERSION) instead). +# 2. The `golang` line in .tool-versions disagrees with the `go` directive in +# go.mod (go.mod is authoritative for the toolchain; .tool-versions tracks it). +# +# Run: make check-tool-versions (or: tools/bin/check-tool-versions) + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +TOOL_VERSION="$ROOT/tools/bin/tool-version" +MAKEFILE="$ROOT/GNUmakefile" + +rc=0 +err() { echo "check-tool-versions: $*" >&2; rc=1; } + +# 1. No hardcoded pins for managed tools in GNUmakefile. +# Managed modules = mockery + every import path in go-tools.txt. +modules="$("$TOOL_VERSION" list | awk -F'\t' '$1 ~ /\// {print $1}')" +modules="github.com/vektra/mockery/v2 +$modules" + +while IFS= read -r module; do + [ -n "$module" ] || continue + # A literal pin looks like @v1.2.3 or @. + # The module-coupled protoc plugins use `@\`go list ...\`` (backtick), not matched. + if grep -nE "${module}@(v[0-9]|[0-9a-f]{7,})" "$MAKEFILE" >/dev/null; then + err "GNUmakefile hardcodes a version for $module — use \$(TOOL_VERSION) go-install instead:" + grep -nE "${module}@(v[0-9]|[0-9a-f]{7,})" "$MAKEFILE" >&2 + fi +done < `) +# tools/go-tools.txt pure go-install CLIs that have no plugin +# (` `) +# +# No mise/asdf dependency — this is the portable path that Make and CI use. +# +# Usage: +# tool-version get print the version for a tool +# tool-version target print the `go install` arg: module@version +# tool-version go-install run `go install` for the tool +# tool-version list print `nameversion` for every entry +# +# is a short name (mockery, protoc, golang) for runtimes, or an import +# path (github.com/jmank88/gomods) for go-tools.txt CLIs. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +TOOL_VERSIONS_FILE="${TOOL_VERSIONS_FILE:-$ROOT/.tool-versions}" +GO_TOOLS_FILE="${GO_TOOLS_FILE:-$ROOT/tools/go-tools.txt}" + +die() { echo "tool-version: $*" >&2; exit 1; } + +# Short runtime name -> go module path, for the tools we install with `go install` +# but still manage in .tool-versions (plugin name != import path). +module_for() { + case "$1" in + mockery) echo "github.com/vektra/mockery/v2" ;; + *) return 1 ;; + esac +} + +# Look up a version in a manifest file. Ignores blank lines and # comments. +# Matches the first whitespace-separated field exactly. +lookup() { + local key="$1" file="$2" line first rest + [ -f "$file" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|\#*) continue ;; esac + first="${line%%[[:space:]]*}" + [ "$first" = "$key" ] || continue + rest="${line#"$first"}" + rest="${rest#"${rest%%[![:space:]]*}"}" # ltrim + echo "${rest%%[[:space:]]*}" # first remaining token + return 0 + done <"$file" + return 1 +} + +# Resolve a key to its version, searching the right manifest. +get_version() { + local key="$1" v + case "$key" in + */*) # import path -> go-tools.txt + v="$(lookup "$key" "$GO_TOOLS_FILE")" || die "unknown go tool: $key" + ;; + *) # short name -> .tool-versions + v="$(lookup "$key" "$TOOL_VERSIONS_FILE")" || die "unknown tool: $key" + ;; + esac + echo "$v" +} + +# Prepend `v` only for semver-looking versions (have a dot, start with a digit). +# SHA / pseudo-version pins are passed through verbatim. +version_ref() { + case "$1" in + [0-9]*.[0-9]*) echo "v$1" ;; + *) echo "$1" ;; + esac +} + +# Resolve a key to the `go install` argument: module@version. +target() { + local key="$1" module version + case "$key" in + */*) module="$key" ;; # import path is the module + *) module="$(module_for "$key")" || die "no go module mapping for: $key" ;; + esac + version="$(get_version "$key")" + echo "${module}@$(version_ref "$version")" +} + +cmd="${1:-}" +case "$cmd" in + get) + [ $# -eq 2 ] || die "usage: tool-version get " + get_version "$2" + ;; + target) + [ $# -eq 2 ] || die "usage: tool-version target " + target "$2" + ;; + go-install) + [ $# -eq 2 ] || die "usage: tool-version go-install " + arg="$(target "$2")" + echo "go install $arg" >&2 + exec go install "$arg" + ;; + list) + for f in "$TOOL_VERSIONS_FILE" "$GO_TOOLS_FILE"; do + [ -f "$f" ] || continue + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|\#*) continue ;; esac + name="${line%%[[:space:]]*}" + rest="${line#"$name"}" + rest="${rest#"${rest%%[![:space:]]*}"}" + printf '%s\t%s\n' "$name" "${rest%%[[:space:]]*}" + done <"$f" + done + ;; + ''|-h|--help|help) + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' + [ -n "$cmd" ] || exit 1 + ;; + *) + die "unknown command: $cmd (try: get, target, go-install, list)" + ;; +esac diff --git a/tools/bin/tool-version_test.sh b/tools/bin/tool-version_test.sh new file mode 100755 index 00000000000..aad07b375c0 --- /dev/null +++ b/tools/bin/tool-version_test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# +# Tests for tools/bin/tool-version. No network, no go install — uses the +# TOOL_VERSIONS_FILE / GO_TOOLS_FILE overrides to point at fixtures. +# +# Run: bash tools/bin/tool-version_test.sh + +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +TOOL_VERSION="$HERE/tool-version" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +cat >"$tmp/.tool-versions" <<'EOF' +golang 1.26.4 +mockery 2.53.0 +protoc 29.3 +golangci-lint 2.12.2 +nodejs 20.13.1 +EOF + +cat >"$tmp/go-tools.txt" <<'EOF' +# comment line, ignored +github.com/jmank88/gomods 0.1.7 +github.com/ugorji/go/codec/codecgen 1.2.10 +github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 +EOF + +export TOOL_VERSIONS_FILE="$tmp/.tool-versions" +export GO_TOOLS_FILE="$tmp/go-tools.txt" + +fail=0 +check() { # name, expected, actual + if [ "$2" = "$3" ]; then + echo "ok - $1" + else + echo "FAIL - $1" + echo " expected: [$2]" + echo " actual: [$3]" + fail=1 + fi +} + +check_rc() { # name, expected_rc, actual_rc + if [ "$2" = "$3" ]; then + echo "ok - $1" + else + echo "FAIL - $1 (expected rc $2, got $3)" + fail=1 + fi +} + +# --- get: runtimes from .tool-versions --- +check "get mockery" "2.53.0" "$("$TOOL_VERSION" get mockery)" +check "get protoc" "29.3" "$("$TOOL_VERSION" get protoc)" +check "get golangci-lint" "2.12.2" "$("$TOOL_VERSION" get golangci-lint)" +check "get golang" "1.26.4" "$("$TOOL_VERSION" get golang)" + +# --- get: CLIs from go-tools.txt (by import path) --- +check "get gomods path" "0.1.7" "$("$TOOL_VERSION" get github.com/jmank88/gomods)" +check "get codecgen path" "1.2.10" "$("$TOOL_VERSION" get github.com/ugorji/go/codec/codecgen)" + +# --- target: the `go install` argument (module@version) --- +check "target mockery maps name->module, prepends v" \ + "github.com/vektra/mockery/v2@v2.53.0" "$("$TOOL_VERSION" target mockery)" +check "target gomods uses path verbatim, prepends v" \ + "github.com/jmank88/gomods@v0.1.7" "$("$TOOL_VERSION" target github.com/jmank88/gomods)" +check "target codecgen" \ + "github.com/ugorji/go/codec/codecgen@v1.2.10" "$("$TOOL_VERSION" target github.com/ugorji/go/codec/codecgen)" + +# --- target: non-semver pin (SHA) is NOT given a v prefix --- +check "target gencodec SHA pin has no v prefix" \ + "github.com/smartcontractkit/gencodec@42dc7da8c2874db550e91c656f98d05fca3c2f98" \ + "$("$TOOL_VERSION" target github.com/smartcontractkit/gencodec)" + +# --- unknown key fails --- +"$TOOL_VERSION" get does-not-exist >/dev/null 2>&1 +check_rc "get unknown key exits non-zero" "1" "$?" + +# --- target on a non-go runtime (protoc) fails (no module mapping) --- +"$TOOL_VERSION" target protoc >/dev/null 2>&1 +check_rc "target protoc (no go module) exits non-zero" "1" "$?" + +# --- list emits all pairs from both files --- +list_out="$("$TOOL_VERSION" list)" +case "$list_out" in + *"mockery"*"2.53.0"*) echo "ok - list includes mockery" ;; + *) echo "FAIL - list missing mockery"; fail=1 ;; +esac +case "$list_out" in + *"github.com/jmank88/gomods"*"0.1.7"*) echo "ok - list includes gomods" ;; + *) echo "FAIL - list missing gomods"; fail=1 ;; +esac + +if [ "$fail" -eq 0 ]; then + echo "PASS" +else + echo "SOME TESTS FAILED" +fi +exit "$fail" diff --git a/tools/go-tools.txt b/tools/go-tools.txt new file mode 100644 index 00000000000..2850f58ee3c --- /dev/null +++ b/tools/go-tools.txt @@ -0,0 +1,15 @@ +# Go-install dev tool CLIs (import-path version). +# +# These have NO asdf/mise plugin, so they must NOT go in .tool-versions +# (asdf parses that file strictly as ` ` and breaks on +# anything else). They are installed by `make install-dev-tools` for everyone. +# +# Versions are semver where the upstream tags releases. gencodec has no tags, +# so it is pinned to a commit SHA (what `@latest` resolved to). +# +# Read by tools/bin/tool-version. Edit this file to bump a CLI version. + +github.com/jmank88/gomods 0.1.7 +github.com/jmank88/modgraph 0.1.4 +github.com/ugorji/go/codec/codecgen 1.2.10 +github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 From 20d38b000593322d3a3f3467b967c7bb15a5f64a Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 11 Jun 2026 12:05:34 -0400 Subject: [PATCH 2/6] codeowners --- .github/CODEOWNERS | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a38acaea35..a40bd94c39b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -102,10 +102,10 @@ /core/chainlink.Dockerfile @smartcontractkit/devex-cicd @smartcontractkit/foundations @smartcontractkit/core # Dependencies -.tool-versions @smartcontractkit/core -/tools/go-tools.txt @smartcontractkit/core -/tools/bin/tool-version @smartcontractkit/core -/tools/bin/check-tool-versions @smartcontractkit/core +.tool-versions @smartcontractkit/core @smartcontractkit/devex +/tools/go-tools.txt @smartcontractkit/core @smartcontractkit/devex +/tools/bin/tool-version @smartcontractkit/core @smartcontractkit/devex +/tools/bin/check-tool-versions @smartcontractkit/core @smartcontractkit/devex go.md @smartcontractkit/core @smartcontractkit/foundations go.mod @smartcontractkit/core @smartcontractkit/foundations go.sum @smartcontractkit/core @smartcontractkit/foundations From 70975181925c3025f443b7800a47838bc67d3cdd Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 11 Jun 2026 12:37:39 -0400 Subject: [PATCH 3/6] convert from bash to Go --- .github/CODEOWNERS | 4 +- .github/actions/golangci-lint/action.yml | 7 +- .github/actions/setup-nodejs/action.yaml | 11 +- .github/workflows/changeset.yml | 11 +- .github/workflows/changesets-preview-pr.yml | 11 +- .github/workflows/ci-core.yml | 4 +- .../cre-regression-system-tests.yaml | 2 +- .github/workflows/cre-soak-memory-leak.yml | 2 +- .github/workflows/cre-system-tests.yaml | 2 +- .github/workflows/cre-wf-caching-test.yml | 2 +- .github/workflows/go-mod-cache.yml | 2 +- .tool-versions | 3 + GNUmakefile | 19 +- core/chainlink.Dockerfile | 4 +- core/scripts/cre/environment/.tool-versions | 1 - integration-tests/.tool-versions | 6 - integration-tests/Makefile | 2 +- plugins/chainlink.Dockerfile | 4 +- tools/README.md | 16 +- tools/bin/check-tool-versions | 48 ---- tools/bin/go_core_tests | 2 +- tools/bin/go_core_tests_integration | 2 +- tools/bin/go_deployment_tests | 2 +- tools/bin/tool-version | 124 --------- tools/bin/tool-version_test.sh | 102 -------- tools/go-tools.txt | 7 +- tools/toolversion/internal/drift/check.go | 235 ++++++++++++++++++ .../toolversion/internal/drift/check_test.go | 107 ++++++++ .../toolversion/internal/drift/exceptions.go | 48 ++++ .../toolversion/internal/manifest/manifest.go | 136 ++++++++++ .../internal/manifest/manifest_test.go | 107 ++++++++ .../internal/modulemap/modulemap.go | 27 ++ .../internal/modulemap/modulemap_test.go | 18 ++ tools/toolversion/internal/paths/paths.go | 71 ++++++ .../toolversion/internal/paths/paths_test.go | 30 +++ tools/toolversion/internal/ref/ref.go | 36 +++ tools/toolversion/internal/ref/ref_test.go | 24 ++ tools/toolversion/internal/resolve/resolve.go | 59 +++++ tools/toolversion/main.go | 227 +++++++++++++++++ tools/toolversion/main_test.go | 61 +++++ tools/version-exceptions.yaml | 7 + 41 files changed, 1280 insertions(+), 313 deletions(-) delete mode 100644 core/scripts/cre/environment/.tool-versions delete mode 100644 integration-tests/.tool-versions delete mode 100755 tools/bin/check-tool-versions delete mode 100755 tools/bin/tool-version delete mode 100755 tools/bin/tool-version_test.sh create mode 100644 tools/toolversion/internal/drift/check.go create mode 100644 tools/toolversion/internal/drift/check_test.go create mode 100644 tools/toolversion/internal/drift/exceptions.go create mode 100644 tools/toolversion/internal/manifest/manifest.go create mode 100644 tools/toolversion/internal/manifest/manifest_test.go create mode 100644 tools/toolversion/internal/modulemap/modulemap.go create mode 100644 tools/toolversion/internal/modulemap/modulemap_test.go create mode 100644 tools/toolversion/internal/paths/paths.go create mode 100644 tools/toolversion/internal/paths/paths_test.go create mode 100644 tools/toolversion/internal/ref/ref.go create mode 100644 tools/toolversion/internal/ref/ref_test.go create mode 100644 tools/toolversion/internal/resolve/resolve.go create mode 100644 tools/toolversion/main.go create mode 100644 tools/toolversion/main_test.go create mode 100644 tools/version-exceptions.yaml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a40bd94c39b..410d5ee80ca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -104,8 +104,8 @@ # Dependencies .tool-versions @smartcontractkit/core @smartcontractkit/devex /tools/go-tools.txt @smartcontractkit/core @smartcontractkit/devex -/tools/bin/tool-version @smartcontractkit/core @smartcontractkit/devex -/tools/bin/check-tool-versions @smartcontractkit/core @smartcontractkit/devex +/tools/version-exceptions.yaml @smartcontractkit/core @smartcontractkit/devex +/tools/toolversion/ @smartcontractkit/core @smartcontractkit/devex go.md @smartcontractkit/core @smartcontractkit/foundations go.mod @smartcontractkit/core @smartcontractkit/foundations go.sum @smartcontractkit/core @smartcontractkit/foundations diff --git a/.github/actions/golangci-lint/action.yml b/.github/actions/golangci-lint/action.yml index 4e90254e1b9..72031b95f7e 100644 --- a/.github/actions/golangci-lint/action.yml +++ b/.github/actions/golangci-lint/action.yml @@ -64,13 +64,18 @@ runs: echo "golangci-lint-working-directory=${{ inputs.go-directory }}/" >> "${GITHUB_OUTPUT}" fi + - name: Resolve golangci-lint version + shell: bash + id: golangci-version + run: echo "version=$(go run ./tools/toolversion ref golangci-lint)" >> "${GITHUB_OUTPUT}" + - name: Golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 env: # golangci-lint runs with absolute path mode: --path-mode=abs REPORT_PATH: ${{ github.workspace }}/${{ steps.set-working-directory.outputs.golangci-lint-working-directory }}golangci-lint-report.xml with: - version: v2.11.4 + version: ${{ steps.golangci-version.outputs.version }} only-new-issues: true args: --output.checkstyle.path=${{ env.REPORT_PATH }} working-directory: ${{ steps.set-working-directory.outputs.golangci-lint-working-directory }} diff --git a/.github/actions/setup-nodejs/action.yaml b/.github/actions/setup-nodejs/action.yaml index 27f1f05590e..4c172cf5208 100644 --- a/.github/actions/setup-nodejs/action.yaml +++ b/.github/actions/setup-nodejs/action.yaml @@ -11,13 +11,20 @@ description: Setup pnpm for contracts runs: using: composite steps: + - name: Resolve node and pnpm versions + shell: bash + id: tool-versions + run: | + echo "nodejs=$(go run ./tools/toolversion get nodejs)" >> "${GITHUB_OUTPUT}" + echo "pnpm=$(go run ./tools/toolversion get pnpm)" >> "${GITHUB_OUTPUT}" + - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 with: - version: ^10.0.0 + version: ${{ steps.tool-versions.outputs.pnpm }} - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: ${{ steps.tool-versions.outputs.nodejs }} cache: "pnpm" cache-dependency-path: "${{ inputs.base-path }}/contracts/pnpm-lock.yaml" diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index d540e7a9ad2..9b2cb4cc252 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -62,17 +62,24 @@ jobs: shell: bash run: bash ./.github/scripts/check-changeset-tags.sh ${{ steps.files-changed.outputs.core-changeset_files }} + - name: Resolve node and pnpm versions + if: ${{ steps.files-changed.outputs.core == 'true' || steps.files-changed.outputs.shared == 'true' }} + id: tool-versions + run: | + echo "nodejs=$(go run ./tools/toolversion get nodejs)" >> "${GITHUB_OUTPUT}" + echo "pnpm=$(go run ./tools/toolversion get pnpm)" >> "${GITHUB_OUTPUT}" + - name: Setup pnpm uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 if: ${{ steps.files-changed.outputs.core == 'true' || steps.files-changed.outputs.shared == 'true' }} with: - version: ^10.0.0 + version: ${{ steps.tool-versions.outputs.pnpm }} - name: Setup node uses: actions/setup-node@v4 if: ${{ steps.files-changed.outputs.core == 'true' || steps.files-changed.outputs.shared == 'true' }} with: - node-version: 20 + node-version: ${{ steps.tool-versions.outputs.nodejs }} cache: pnpm cache-dependency-path: ./pnpm-lock.yaml diff --git a/.github/workflows/changesets-preview-pr.yml b/.github/workflows/changesets-preview-pr.yml index 19787d7faf2..6a7aafc4043 100644 --- a/.github/workflows/changesets-preview-pr.yml +++ b/.github/workflows/changesets-preview-pr.yml @@ -29,17 +29,24 @@ jobs: core-changeset: - '.changeset/**' + - name: Resolve node and pnpm versions + if: steps.change.outputs.core-changeset == 'true' + id: tool-versions + run: | + echo "nodejs=$(go run ./tools/toolversion get nodejs)" >> "${GITHUB_OUTPUT}" + echo "pnpm=$(go run ./tools/toolversion get pnpm)" >> "${GITHUB_OUTPUT}" + - name: Setup pnpm uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 if: steps.change.outputs.core-changeset == 'true' with: - version: ^10.0.0 + version: ${{ steps.tool-versions.outputs.pnpm }} - name: Setup node uses: actions/setup-node@v4 if: steps.change.outputs.core-changeset == 'true' with: - node-version: 20 + node-version: ${{ steps.tool-versions.outputs.nodejs }} cache: pnpm cache-dependency-path: ./pnpm-lock.yaml diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index e40e9f32f40..c964b5ea5c2 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -639,7 +639,9 @@ jobs: --output "$HOME/go/bin/protoc-gen-go-wsrpc" && chmod +x "$HOME/go/bin/protoc-gen-go-wsrpc" - name: Check tool versions - run: make check-tool-versions + run: | + make test-tool-versions + make check-tool-versions - name: make generate run: | make rm-mocked diff --git a/.github/workflows/cre-regression-system-tests.yaml b/.github/workflows/cre-regression-system-tests.yaml index 86f8d4085bf..3372f62e00f 100644 --- a/.github/workflows/cre-regression-system-tests.yaml +++ b/.github/workflows/cre-regression-system-tests.yaml @@ -193,7 +193,7 @@ jobs: shell: bash run: | echo "::group::Install gotestsum" - go install gotest.tools/gotestsum@v1.12.3 + go run ./tools/toolversion go-install gotest.tools/gotestsum echo "::endgroup::" - name: Resolve Chainlink image diff --git a/.github/workflows/cre-soak-memory-leak.yml b/.github/workflows/cre-soak-memory-leak.yml index 087908e4d15..9cb5064bca6 100644 --- a/.github/workflows/cre-soak-memory-leak.yml +++ b/.github/workflows/cre-soak-memory-leak.yml @@ -114,7 +114,7 @@ jobs: - name: Install gotestsum shell: bash - run: go install gotest.tools/gotestsum@v1.12.3 + run: go run ./tools/toolversion go-install gotest.tools/gotestsum - name: Run CRE Resource Regression Test id: run-soak diff --git a/.github/workflows/cre-system-tests.yaml b/.github/workflows/cre-system-tests.yaml index b96a27a9d66..99427aaf645 100644 --- a/.github/workflows/cre-system-tests.yaml +++ b/.github/workflows/cre-system-tests.yaml @@ -235,7 +235,7 @@ jobs: shell: bash run: | echo "::group::Install gotestsum" - go install gotest.tools/gotestsum@v1.13.0 + go run ./tools/toolversion go-install gotest.tools/gotestsum echo "::endgroup::" - name: Install Aptos CLI diff --git a/.github/workflows/cre-wf-caching-test.yml b/.github/workflows/cre-wf-caching-test.yml index 0aa1d35a921..8664978e2b5 100644 --- a/.github/workflows/cre-wf-caching-test.yml +++ b/.github/workflows/cre-wf-caching-test.yml @@ -116,7 +116,7 @@ jobs: - name: Install gotestsum shell: bash - run: go install gotest.tools/gotestsum@v1.12.3 + run: go run ./tools/toolversion go-install gotest.tools/gotestsum - name: Run CRE Workflow Caching Test id: run-caching diff --git a/.github/workflows/go-mod-cache.yml b/.github/workflows/go-mod-cache.yml index c1b7e65508f..22718598def 100644 --- a/.github/workflows/go-mod-cache.yml +++ b/.github/workflows/go-mod-cache.yml @@ -89,5 +89,5 @@ jobs: echo "::endgroup::" echo "::group::Install testing tools" - go install gotest.tools/gotestsum@v1.12.3 + go run ./tools/toolversion go-install gotest.tools/gotestsum echo "::endgroup::" diff --git a/.tool-versions b/.tool-versions index 27e7da9bd6c..94ac4fbc326 100644 --- a/.tool-versions +++ b/.tool-versions @@ -7,3 +7,6 @@ helm 3.18.4 golangci-lint 2.12.2 protoc 29.3 python 3.10.5 +k3d 5.4.6 +kubectl 1.25.5 +task 3.35.1 diff --git a/GNUmakefile b/GNUmakefile index c243c1e7417..9e78c717f3d 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -16,13 +16,12 @@ CL_LOOPINSTALL_OUTPUT_DIR ?= LOOPINSTALL_PUBLIC_ARGS := $(if $(strip $(CL_LOOPINSTALL_OUTPUT_DIR)),--output-installation-artifacts $(CL_LOOPINSTALL_OUTPUT_DIR)/public.json) LOOPINSTALL_PRIVATE_ARGS := $(if $(strip $(CL_LOOPINSTALL_OUTPUT_DIR)),--output-installation-artifacts $(CL_LOOPINSTALL_OUTPUT_DIR)/private.json) LOOPINSTALL_TESTING_ARGS := $(if $(strip $(CL_LOOPINSTALL_OUTPUT_DIR)),--output-installation-artifacts $(CL_LOOPINSTALL_OUTPUT_DIR)/testing.json) -# Dev tool versions come from the manifests via tools/bin/tool-version, never +# Dev tool versions come from the manifests via tools/toolversion, never # hardcoded here (see `make check-tool-versions`). Lazy `=` so unrelated targets # (e.g. `make chainlink`) don't pay for the parser. -TOOL_VERSION := tools/bin/tool-version -MOCKERY_VERSION = v$(shell $(TOOL_VERSION) get mockery) +TOOL_VERSION = go run ./tools/toolversion PROTOC_VERSION = $(shell $(TOOL_VERSION) get protoc) -GOLANGCI_LINT_VERSION = v$(shell $(TOOL_VERSION) get golangci-lint) +GOLANGCI_LINT_VERSION = $(shell $(TOOL_VERSION) ref golangci-lint) # Pin path so `make generate` does not pick up a different mockery (e.g. v3) from PATH. # Honor GOBIN if set, otherwise GOPATH/bin. DEV_BIN = $(shell GOBIN="$$(go env GOBIN)"; if [ -n "$$GOBIN" ]; then echo "$$GOBIN"; else echo "$$(go env GOPATH)/bin"; fi) @@ -55,8 +54,12 @@ install-dev-tools: ## Install all Go dev tools at pinned versions (works without $(MAKE) protoc-plugins .PHONY: check-tool-versions -check-tool-versions: ## Fail if GNUmakefile drifts from the version manifests. - tools/bin/check-tool-versions +check-tool-versions: ## Fail if consumers drift from the version manifests. + $(TOOL_VERSION) check + +.PHONY: test-tool-versions +test-tool-versions: ## Run toolversion unit tests. + go test ./tools/toolversion/... .PHONY: gomodtidy gomodtidy: gomods ## Run go mod tidy on all modules. @@ -69,7 +72,7 @@ tidy: gomodtidy ## Tidy all modules and add to git. .PHONY: docs docs: ## Install and run pkgsite to view Go docs - go install golang.org/x/pkgsite/cmd/pkgsite@latest + $(TOOL_VERSION) go-install golang.org/x/pkgsite/cmd/pkgsite # http://localhost:8080/pkg/github.com/smartcontractkit/chainlink/v2/ pkgsite @@ -204,7 +207,7 @@ rm-mocked: .PHONY: testscripts testscripts: chainlink-test ## Install and run testscript against testdata/scripts/* files. - go install github.com/rogpeppe/go-internal/cmd/testscript@latest + $(TOOL_VERSION) go-install github.com/rogpeppe/go-internal/cmd/testscript go run ./tools/txtar/cmd/lstxtardirs -recurse=true | PATH="$(CURDIR):${PATH}" xargs -I % \ sh -c 'testscript -e COMMIT_SHA=$(COMMIT_SHA) -e HOME="$(TMPDIR)/home" -e VERSION=$(VERSION) -e VERSION_TAG=$(VERSION_TAG) $(TS_FLAGS) %/*.txtar' diff --git a/core/chainlink.Dockerfile b/core/chainlink.Dockerfile index 9b18c1c4ede..3324fd9be4d 100644 --- a/core/chainlink.Dockerfile +++ b/core/chainlink.Dockerfile @@ -35,7 +35,9 @@ COPY . . # Stage: Delve debugger (no source needed, branches from deps-base) FROM deps-base AS build-delve -RUN go install github.com/go-delve/delve/cmd/dlv@v1.24.2 +COPY .tool-versions tools/go-tools.txt ./ +COPY tools/toolversion ./tools/toolversion +RUN go run ./tools/toolversion go-install github.com/go-delve/delve/cmd/dlv # Stage: Remote plugins — only manifest YAMLs, no source tree. # Cached as long as go.mod/go.sum and plugin manifests are unchanged, diff --git a/core/scripts/cre/environment/.tool-versions b/core/scripts/cre/environment/.tool-versions deleted file mode 100644 index 421e398dffd..00000000000 --- a/core/scripts/cre/environment/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -helm 3.17.3 \ No newline at end of file diff --git a/integration-tests/.tool-versions b/integration-tests/.tool-versions deleted file mode 100644 index dee79b8931f..00000000000 --- a/integration-tests/.tool-versions +++ /dev/null @@ -1,6 +0,0 @@ -golang 1.26.3 -k3d 5.4.6 -kubectl 1.25.5 -nodejs 20.13.1 -golangci-lint 2.11.4 -task 3.35.1 diff --git a/integration-tests/Makefile b/integration-tests/Makefile index 7e70886df6d..c49b6453919 100644 --- a/integration-tests/Makefile +++ b/integration-tests/Makefile @@ -53,7 +53,7 @@ endif .PHONY: install_gotestloghelper install_gotestloghelper: - go install github.com/smartcontractkit/chainlink-testing-framework/tools/gotestloghelper@latest + go run ../tools/toolversion go-install github.com/smartcontractkit/chainlink-testing-framework/tools/gotestloghelper set -euo pipefail lint: diff --git a/plugins/chainlink.Dockerfile b/plugins/chainlink.Dockerfile index 855e6c99e06..fb13459ec33 100644 --- a/plugins/chainlink.Dockerfile +++ b/plugins/chainlink.Dockerfile @@ -34,7 +34,9 @@ COPY . . # Stage: Delve debugger (no source needed, branches from deps-base) FROM deps-base AS build-delve -RUN go install github.com/go-delve/delve/cmd/dlv@v1.24.2 +COPY .tool-versions tools/go-tools.txt ./ +COPY tools/toolversion ./tools/toolversion +RUN go run ./tools/toolversion go-install github.com/go-delve/delve/cmd/dlv # Stage: Remote plugins — only manifest YAMLs, no source tree. # Cached as long as go.mod/go.sum and plugin manifests are unchanged, diff --git a/tools/README.md b/tools/README.md index 1cc87f20f3c..bc9cd564c58 100644 --- a/tools/README.md +++ b/tools/README.md @@ -13,7 +13,7 @@ A harness for running /chainlink tests. From the repo root use **`make test`** ( Two manifests, split by whether a version manager can install the tool: - [`.tool-versions`](../.tool-versions) — runtimes with an asdf/mise plugin (go, node, mockery, protoc, golangci-lint, ...). Strict ` `. -- [`go-tools.txt`](./go-tools.txt) — pure `go install` CLIs with no plugin (gomods, modgraph, codecgen, gencodec). ` `. +- [`go-tools.txt`](./go-tools.txt) — pure `go install` CLIs with no plugin (gomods, modgraph, codecgen, gencodec, gotestsum, ...). ` `. Do **not** put `go install` CLIs in `.tool-versions`: asdf reads the first token as a plugin name and aborts on anything that isn't one. @@ -33,4 +33,16 @@ asdf install make install-dev-tools ``` -`make` and CI read both manifests through [`bin/tool-version`](./bin/tool-version); no version manager is required. `make check-tool-versions` fails if the Makefile hardcodes a managed version or if `.tool-versions` golang drifts from `go.mod`. +[`tools/toolversion`](./toolversion) is the Go CLI that reads both manifests (no version manager required): + +```sh +go run ./tools/toolversion get mockery +go run ./tools/toolversion ref golangci-lint +go run ./tools/toolversion go-install github.com/jmank88/gomods +go run ./tools/toolversion check # drift guard +go test ./tools/toolversion/... +make test-tool-versions +make check-tool-versions +``` + +Module-coupled protoc plugins (`protoc-gen-go`, `protoc-gen-go-wsrpc`) track `go.mod` and are listed in [`version-exceptions.yaml`](./version-exceptions.yaml). diff --git a/tools/bin/check-tool-versions b/tools/bin/check-tool-versions deleted file mode 100755 index 62388d6aefa..00000000000 --- a/tools/bin/check-tool-versions +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# -# Drift guard. Fails if: -# 1. GNUmakefile hardcodes a version for any tool managed by the manifests -# (it must route through $(TOOL_VERSION) instead). -# 2. The `golang` line in .tool-versions disagrees with the `go` directive in -# go.mod (go.mod is authoritative for the toolchain; .tool-versions tracks it). -# -# Run: make check-tool-versions (or: tools/bin/check-tool-versions) - -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -TOOL_VERSION="$ROOT/tools/bin/tool-version" -MAKEFILE="$ROOT/GNUmakefile" - -rc=0 -err() { echo "check-tool-versions: $*" >&2; rc=1; } - -# 1. No hardcoded pins for managed tools in GNUmakefile. -# Managed modules = mockery + every import path in go-tools.txt. -modules="$("$TOOL_VERSION" list | awk -F'\t' '$1 ~ /\// {print $1}')" -modules="github.com/vektra/mockery/v2 -$modules" - -while IFS= read -r module; do - [ -n "$module" ] || continue - # A literal pin looks like @v1.2.3 or @. - # The module-coupled protoc plugins use `@\`go list ...\`` (backtick), not matched. - if grep -nE "${module}@(v[0-9]|[0-9a-f]{7,})" "$MAKEFILE" >/dev/null; then - err "GNUmakefile hardcodes a version for $module — use \$(TOOL_VERSION) go-install instead:" - grep -nE "${module}@(v[0-9]|[0-9a-f]{7,})" "$MAKEFILE" >&2 - fi -done < `) -# tools/go-tools.txt pure go-install CLIs that have no plugin -# (` `) -# -# No mise/asdf dependency — this is the portable path that Make and CI use. -# -# Usage: -# tool-version get print the version for a tool -# tool-version target print the `go install` arg: module@version -# tool-version go-install run `go install` for the tool -# tool-version list print `nameversion` for every entry -# -# is a short name (mockery, protoc, golang) for runtimes, or an import -# path (github.com/jmank88/gomods) for go-tools.txt CLIs. - -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -TOOL_VERSIONS_FILE="${TOOL_VERSIONS_FILE:-$ROOT/.tool-versions}" -GO_TOOLS_FILE="${GO_TOOLS_FILE:-$ROOT/tools/go-tools.txt}" - -die() { echo "tool-version: $*" >&2; exit 1; } - -# Short runtime name -> go module path, for the tools we install with `go install` -# but still manage in .tool-versions (plugin name != import path). -module_for() { - case "$1" in - mockery) echo "github.com/vektra/mockery/v2" ;; - *) return 1 ;; - esac -} - -# Look up a version in a manifest file. Ignores blank lines and # comments. -# Matches the first whitespace-separated field exactly. -lookup() { - local key="$1" file="$2" line first rest - [ -f "$file" ] || return 1 - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in ''|\#*) continue ;; esac - first="${line%%[[:space:]]*}" - [ "$first" = "$key" ] || continue - rest="${line#"$first"}" - rest="${rest#"${rest%%[![:space:]]*}"}" # ltrim - echo "${rest%%[[:space:]]*}" # first remaining token - return 0 - done <"$file" - return 1 -} - -# Resolve a key to its version, searching the right manifest. -get_version() { - local key="$1" v - case "$key" in - */*) # import path -> go-tools.txt - v="$(lookup "$key" "$GO_TOOLS_FILE")" || die "unknown go tool: $key" - ;; - *) # short name -> .tool-versions - v="$(lookup "$key" "$TOOL_VERSIONS_FILE")" || die "unknown tool: $key" - ;; - esac - echo "$v" -} - -# Prepend `v` only for semver-looking versions (have a dot, start with a digit). -# SHA / pseudo-version pins are passed through verbatim. -version_ref() { - case "$1" in - [0-9]*.[0-9]*) echo "v$1" ;; - *) echo "$1" ;; - esac -} - -# Resolve a key to the `go install` argument: module@version. -target() { - local key="$1" module version - case "$key" in - */*) module="$key" ;; # import path is the module - *) module="$(module_for "$key")" || die "no go module mapping for: $key" ;; - esac - version="$(get_version "$key")" - echo "${module}@$(version_ref "$version")" -} - -cmd="${1:-}" -case "$cmd" in - get) - [ $# -eq 2 ] || die "usage: tool-version get " - get_version "$2" - ;; - target) - [ $# -eq 2 ] || die "usage: tool-version target " - target "$2" - ;; - go-install) - [ $# -eq 2 ] || die "usage: tool-version go-install " - arg="$(target "$2")" - echo "go install $arg" >&2 - exec go install "$arg" - ;; - list) - for f in "$TOOL_VERSIONS_FILE" "$GO_TOOLS_FILE"; do - [ -f "$f" ] || continue - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in ''|\#*) continue ;; esac - name="${line%%[[:space:]]*}" - rest="${line#"$name"}" - rest="${rest#"${rest%%[![:space:]]*}"}" - printf '%s\t%s\n' "$name" "${rest%%[[:space:]]*}" - done <"$f" - done - ;; - ''|-h|--help|help) - sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' - [ -n "$cmd" ] || exit 1 - ;; - *) - die "unknown command: $cmd (try: get, target, go-install, list)" - ;; -esac diff --git a/tools/bin/tool-version_test.sh b/tools/bin/tool-version_test.sh deleted file mode 100755 index aad07b375c0..00000000000 --- a/tools/bin/tool-version_test.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# -# Tests for tools/bin/tool-version. No network, no go install — uses the -# TOOL_VERSIONS_FILE / GO_TOOLS_FILE overrides to point at fixtures. -# -# Run: bash tools/bin/tool-version_test.sh - -set -u - -HERE="$(cd "$(dirname "$0")" && pwd)" -TOOL_VERSION="$HERE/tool-version" - -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT - -cat >"$tmp/.tool-versions" <<'EOF' -golang 1.26.4 -mockery 2.53.0 -protoc 29.3 -golangci-lint 2.12.2 -nodejs 20.13.1 -EOF - -cat >"$tmp/go-tools.txt" <<'EOF' -# comment line, ignored -github.com/jmank88/gomods 0.1.7 -github.com/ugorji/go/codec/codecgen 1.2.10 -github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 -EOF - -export TOOL_VERSIONS_FILE="$tmp/.tool-versions" -export GO_TOOLS_FILE="$tmp/go-tools.txt" - -fail=0 -check() { # name, expected, actual - if [ "$2" = "$3" ]; then - echo "ok - $1" - else - echo "FAIL - $1" - echo " expected: [$2]" - echo " actual: [$3]" - fail=1 - fi -} - -check_rc() { # name, expected_rc, actual_rc - if [ "$2" = "$3" ]; then - echo "ok - $1" - else - echo "FAIL - $1 (expected rc $2, got $3)" - fail=1 - fi -} - -# --- get: runtimes from .tool-versions --- -check "get mockery" "2.53.0" "$("$TOOL_VERSION" get mockery)" -check "get protoc" "29.3" "$("$TOOL_VERSION" get protoc)" -check "get golangci-lint" "2.12.2" "$("$TOOL_VERSION" get golangci-lint)" -check "get golang" "1.26.4" "$("$TOOL_VERSION" get golang)" - -# --- get: CLIs from go-tools.txt (by import path) --- -check "get gomods path" "0.1.7" "$("$TOOL_VERSION" get github.com/jmank88/gomods)" -check "get codecgen path" "1.2.10" "$("$TOOL_VERSION" get github.com/ugorji/go/codec/codecgen)" - -# --- target: the `go install` argument (module@version) --- -check "target mockery maps name->module, prepends v" \ - "github.com/vektra/mockery/v2@v2.53.0" "$("$TOOL_VERSION" target mockery)" -check "target gomods uses path verbatim, prepends v" \ - "github.com/jmank88/gomods@v0.1.7" "$("$TOOL_VERSION" target github.com/jmank88/gomods)" -check "target codecgen" \ - "github.com/ugorji/go/codec/codecgen@v1.2.10" "$("$TOOL_VERSION" target github.com/ugorji/go/codec/codecgen)" - -# --- target: non-semver pin (SHA) is NOT given a v prefix --- -check "target gencodec SHA pin has no v prefix" \ - "github.com/smartcontractkit/gencodec@42dc7da8c2874db550e91c656f98d05fca3c2f98" \ - "$("$TOOL_VERSION" target github.com/smartcontractkit/gencodec)" - -# --- unknown key fails --- -"$TOOL_VERSION" get does-not-exist >/dev/null 2>&1 -check_rc "get unknown key exits non-zero" "1" "$?" - -# --- target on a non-go runtime (protoc) fails (no module mapping) --- -"$TOOL_VERSION" target protoc >/dev/null 2>&1 -check_rc "target protoc (no go module) exits non-zero" "1" "$?" - -# --- list emits all pairs from both files --- -list_out="$("$TOOL_VERSION" list)" -case "$list_out" in - *"mockery"*"2.53.0"*) echo "ok - list includes mockery" ;; - *) echo "FAIL - list missing mockery"; fail=1 ;; -esac -case "$list_out" in - *"github.com/jmank88/gomods"*"0.1.7"*) echo "ok - list includes gomods" ;; - *) echo "FAIL - list missing gomods"; fail=1 ;; -esac - -if [ "$fail" -eq 0 ]; then - echo "PASS" -else - echo "SOME TESTS FAILED" -fi -exit "$fail" diff --git a/tools/go-tools.txt b/tools/go-tools.txt index 2850f58ee3c..7b08c6de9d1 100644 --- a/tools/go-tools.txt +++ b/tools/go-tools.txt @@ -7,9 +7,14 @@ # Versions are semver where the upstream tags releases. gencodec has no tags, # so it is pinned to a commit SHA (what `@latest` resolved to). # -# Read by tools/bin/tool-version. Edit this file to bump a CLI version. +# Read by tools/toolversion. Edit this file to bump a CLI version. github.com/jmank88/gomods 0.1.7 github.com/jmank88/modgraph 0.1.4 github.com/ugorji/go/codec/codecgen 1.2.10 github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 +gotest.tools/gotestsum 1.13.0 +github.com/go-delve/delve/cmd/dlv 1.24.2 +golang.org/x/pkgsite/cmd/pkgsite 0.1.0 +github.com/rogpeppe/go-internal/cmd/testscript 1.15.0 +github.com/smartcontractkit/chainlink-testing-framework/tools/gotestloghelper 1.50.0 diff --git a/tools/toolversion/internal/drift/check.go b/tools/toolversion/internal/drift/check.go new file mode 100644 index 00000000000..a8750f2f7b9 --- /dev/null +++ b/tools/toolversion/internal/drift/check.go @@ -0,0 +1,235 @@ +// Package drift detects version pins outside the canonical manifests. +package drift + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/paths" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/resolve" +) + +// Checker validates that consumers do not hardcode managed tool versions. +type Checker struct { + cfg paths.Config + resolver *resolve.Resolver +} + +func NewChecker(cfg paths.Config, resolver *resolve.Resolver) *Checker { + return &Checker{cfg: cfg, resolver: resolver} +} + +// Check runs all drift rules and returns a combined error if any fail. +func (c *Checker) Check() error { + var errs []string + if err := c.checkMakefilePins(); err != nil { + errs = append(errs, err.Error()) + } + if err := c.checkGolangMirror(); err != nil { + errs = append(errs, err.Error()) + } + if err := c.checkStrayToolVersions(); err != nil { + errs = append(errs, err.Error()) + } + if err := c.checkRepoWidePins(); err != nil { + errs = append(errs, err.Error()) + } + if len(errs) == 0 { + return nil + } + return fmt.Errorf("%s", strings.Join(errs, "\n")) +} + +func (c *Checker) checkMakefilePins() error { + data, err := os.ReadFile(c.cfg.Makefile) + if err != nil { + return fmt.Errorf("read GNUmakefile: %w", err) + } + content := string(data) + var violations []string + for _, mod := range c.resolver.ManagedModules() { + pat := regexp.MustCompile(regexp.QuoteMeta(mod) + `@(v[0-9]|[0-9a-f]{7,})`) + for i, line := range strings.Split(content, "\n") { + if pat.MatchString(line) && !isAllowedException(c.cfg.Root, "GNUmakefile", line) { + violations = append(violations, fmt.Sprintf("GNUmakefile:%d: hardcoded pin for %s — use $(TOOL_VERSION) go-install instead:\n %s", i+1, mod, strings.TrimSpace(line))) + } + } + } + if len(violations) > 0 { + return fmt.Errorf("check-tool-versions:\n%s", strings.Join(violations, "\n")) + } + return nil +} + +func (c *Checker) checkGolangMirror() error { + tvGo, err := c.resolver.Get("golang") + if err != nil { + return err + } + modGo, err := parseGoModDirective(c.cfg.GoMod) + if err != nil { + return err + } + if tvGo != modGo { + return fmt.Errorf("check-tool-versions: golang in .tool-versions (%s) != go directive in go.mod (%s); align .tool-versions to go.mod", tvGo, modGo) + } + return nil +} + +func parseGoModDirective(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("read go.mod: %w", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "go ") { + fields := strings.Fields(line) + if len(fields) >= 2 { + return fields[1], nil + } + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("no go directive in %s", path) +} + +func (c *Checker) checkStrayToolVersions() error { + rootTV := filepath.Clean(c.cfg.ToolVersionsFile) + var violations []string + err := filepath.WalkDir(c.cfg.Root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if skipDir(d.Name(), path) { + return filepath.SkipDir + } + return nil + } + if d.Name() != ".tool-versions" { + return nil + } + if filepath.Clean(path) == rootTV || isFixturePath(path) { + return nil + } + rel, _ := filepath.Rel(c.cfg.Root, path) + violations = append(violations, fmt.Sprintf("check-tool-versions: stray .tool-versions at %s — only repo root .tool-versions is allowed", rel)) + return nil + }) + if err != nil { + return err + } + if len(violations) > 0 { + return fmt.Errorf("%s", strings.Join(violations, "\n")) + } + return nil +} + +var goInstallPin = regexp.MustCompile(`go install [^[:space:]]+@(v[0-9]+(\.[0-9]+)*|latest|[0-9a-f]{7,})`) + +func (c *Checker) checkRepoWidePins() error { + var violations []string + + err := filepath.WalkDir(c.cfg.Root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if skipDir(d.Name(), path) { + return filepath.SkipDir + } + return nil + } + if shouldSkipScan(path, c.cfg) { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + rel, _ := filepath.Rel(c.cfg.Root, path) + for i, line := range strings.Split(string(data), "\n") { + if isCommentLine(line) { + continue + } + if !goInstallPin.MatchString(line) { + continue + } + if isAllowedException(c.cfg.Root, rel, line) { + continue + } + violations = append(violations, fmt.Sprintf("%s:%d: hardcoded go install pin — use go run ./tools/toolversion go-install :\n %s", rel, i+1, strings.TrimSpace(line))) + } + return nil + }) + if err != nil { + return err + } + if len(violations) > 0 { + return fmt.Errorf("check-tool-versions:\n%s", strings.Join(violations, "\n")) + } + return nil +} + +func skipDir(name, path string) bool { + switch name { + case ".git", "vendor", "node_modules", ".cursor", ".bin": + return true + } + return isFixturePath(path) +} + +func isFixturePath(path string) bool { + return strings.Contains(filepath.ToSlash(path), "/temp-repo/") +} + +func isCommentLine(line string) bool { + trimmed := strings.TrimSpace(line) + return trimmed == "" || strings.HasPrefix(trimmed, "#") +} + +func shouldSkipScan(path string, cfg paths.Config) bool { + if isFixturePath(path) { + return true + } + clean := filepath.Clean(path) + if clean == filepath.Clean(cfg.ToolVersionsFile) || clean == filepath.Clean(cfg.GoToolsFile) { + return true + } + if strings.Contains(path, string(filepath.Join("tools", "toolversion"))) { + return true + } + base := filepath.Base(path) + if !scannableFile(base, path) { + return true + } + return false +} + +func scannableFile(base, path string) bool { + switch base { + case "GNUmakefile", "Makefile": + return true + } + ext := filepath.Ext(path) + switch ext { + case ".go", ".sh", ".yml", ".yaml", ".md", ".nix", ".mk", ".Dockerfile", ".dockerfile": + return true + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".gz", ".zip", ".wasm", ".pb", ".bin", "": + return false + } + if strings.HasSuffix(base, "Dockerfile") { + return true + } + return false +} diff --git a/tools/toolversion/internal/drift/check_test.go b/tools/toolversion/internal/drift/check_test.go new file mode 100644 index 00000000000..a209ea00a1e --- /dev/null +++ b/tools/toolversion/internal/drift/check_test.go @@ -0,0 +1,107 @@ +package drift + +import ( + "os" + "path/filepath" + "testing" + + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/manifest" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/paths" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/resolve" +) + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func setupRepo(t *testing.T) (paths.Config, *resolve.Resolver) { + t.Helper() + root := t.TempDir() + writeFile(t, root, ".tool-versions", "golang 1.26.4\nmockery 2.53.0\n") + writeFile(t, root, "go.mod", "module example.com/test\n\ngo 1.26.4\n") + if err := os.Mkdir(filepath.Join(root, "tools"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, "tools"), "go-tools.txt", "github.com/jmank88/gomods 0.1.7\n") + writeFile(t, root, "GNUmakefile", "install:\n\t$(TOOL_VERSION) go-install mockery\n") + + cfg := paths.Config{ + Root: root, + ToolVersionsFile: filepath.Join(root, ".tool-versions"), + GoToolsFile: filepath.Join(root, "tools", "go-tools.txt"), + Makefile: filepath.Join(root, "GNUmakefile"), + GoMod: filepath.Join(root, "go.mod"), + } + store, err := manifest.New(cfg.ToolVersionsFile, cfg.GoToolsFile) + if err != nil { + t.Fatal(err) + } + return cfg, resolve.New(store) +} + +func TestCheckMakefilePinViolation(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + writeFile(t, cfg.Root, "GNUmakefile", "install:\n\tgo install github.com/vektra/mockery/v2@v2.99.0\n") + + err := NewChecker(cfg, resolver).Check() + if err == nil { + t.Fatal("expected violation") + } +} + +func TestCheckGolangMirror(t *testing.T) { + t.Parallel() + cfg, _ := setupRepo(t) + writeFile(t, cfg.Root, ".tool-versions", "golang 1.26.3\nmockery 2.53.0\n") + store, err := manifest.New(cfg.ToolVersionsFile, cfg.GoToolsFile) + if err != nil { + t.Fatal(err) + } + resolver := resolve.New(store) + + err = NewChecker(cfg, resolver).Check() + if err == nil { + t.Fatal("expected golang mirror violation") + } +} + +func TestCheckStrayToolVersions(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + if err := os.Mkdir(filepath.Join(cfg.Root, "integration-tests"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(cfg.Root, "integration-tests"), ".tool-versions", "golang 1.26.4\n") + + err := NewChecker(cfg, resolver).Check() + if err == nil { + t.Fatal("expected stray .tool-versions violation") + } +} + +func TestCheckAllowedProtocException(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + writeFile(t, filepath.Join(cfg.Root, "tools"), "version-exceptions.yaml", "- file: GNUmakefile\n contains: \"protoc-gen-go@\"\n reason: module-coupled\n") + writeFile(t, cfg.Root, "GNUmakefile", "install:\n\tgo install google.golang.org/protobuf/cmd/protoc-gen-go@`go list -m -json google.golang.org/protobuf | jq -r .Version`\n") + + err := NewChecker(cfg, resolver).checkMakefilePins() + if err != nil { + t.Fatalf("expected protoc exception to pass makefile check: %v", err) + } +} + +func TestCheckRepoWideGoInstall(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + writeFile(t, cfg.Root, "script.sh", "go install gotest.tools/gotestsum@v9.9.9\n") + + err := NewChecker(cfg, resolver).Check() + if err == nil { + t.Fatal("expected repo-wide go install violation") + } +} diff --git a/tools/toolversion/internal/drift/exceptions.go b/tools/toolversion/internal/drift/exceptions.go new file mode 100644 index 00000000000..95806b9856d --- /dev/null +++ b/tools/toolversion/internal/drift/exceptions.go @@ -0,0 +1,48 @@ +package drift + +import ( + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type exception struct { + File string `yaml:"file"` + Contains string `yaml:"contains"` + Reason string `yaml:"reason"` +} + +func loadExceptions(root string) ([]exception, error) { + path := filepath.Join(root, "tools", "version-exceptions.yaml") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []exception + if err := yaml.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +func isAllowedException(root, relFile, line string) bool { + exceptions, err := loadExceptions(root) + if err != nil { + return false + } + relFile = filepath.ToSlash(relFile) + for _, ex := range exceptions { + if !strings.HasSuffix(relFile, filepath.ToSlash(ex.File)) && relFile != filepath.ToSlash(ex.File) { + continue + } + if strings.Contains(line, ex.Contains) { + return true + } + } + return false +} diff --git a/tools/toolversion/internal/manifest/manifest.go b/tools/toolversion/internal/manifest/manifest.go new file mode 100644 index 00000000000..56a049c6a26 --- /dev/null +++ b/tools/toolversion/internal/manifest/manifest.go @@ -0,0 +1,136 @@ +// Package manifest loads dev-tool version entries from .tool-versions and go-tools.txt. +package manifest + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// Entry is a single name/version pair from a manifest file. +type Entry struct { + Name string + Version string +} + +// Store holds parsed entries from both manifest files. +type Store struct { + ToolVersionsPath string + GoToolsPath string + runtimes map[string]string + goTools map[string]string +} + +// New loads both manifest files from the given paths. +func New(toolVersionsPath, goToolsPath string) (*Store, error) { + runtimes, err := parseFile(toolVersionsPath) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", toolVersionsPath, err) + } + goTools, err := parseFile(goToolsPath) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", goToolsPath, err) + } + return &Store{ + ToolVersionsPath: toolVersionsPath, + GoToolsPath: goToolsPath, + runtimes: runtimes, + goTools: goTools, + }, nil +} + +func parseFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + entries := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + name, version, ok := parseLine(scanner.Text()) + if !ok { + continue + } + entries[name] = version + } + if err := scanner.Err(); err != nil { + return nil, err + } + return entries, nil +} + +func parseLine(line string) (name, version string, ok bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false + } + fields := strings.Fields(line) + if len(fields) < 2 { + return "", "", false + } + return fields[0], fields[1], true +} + +// Lookup returns the version for key. Import paths (containing "/") resolve from +// go-tools.txt; short names resolve from .tool-versions. +func (s *Store) Lookup(key string) (string, error) { + if strings.Contains(key, "/") { + v, ok := s.goTools[key] + if !ok { + return "", fmt.Errorf("unknown go tool: %s", key) + } + return v, nil + } + v, ok := s.runtimes[key] + if !ok { + return "", fmt.Errorf("unknown tool: %s", key) + } + return v, nil +} + +// List returns all entries from both manifests in file order (.tool-versions first). +func (s *Store) List() ([]Entry, error) { + var out []Entry + for _, path := range []string{s.ToolVersionsPath, s.GoToolsPath} { + entries, err := listFile(path) + if err != nil { + return nil, err + } + out = append(out, entries...) + } + return out, nil +} + +func listFile(path string) ([]Entry, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var out []Entry + scanner := bufio.NewScanner(f) + for scanner.Scan() { + name, version, ok := parseLine(scanner.Text()) + if !ok { + continue + } + out = append(out, Entry{Name: name, Version: version}) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return out, nil +} + +// GoToolModules returns import paths from go-tools.txt. +func (s *Store) GoToolModules() []string { + mods := make([]string, 0, len(s.goTools)) + for name := range s.goTools { + mods = append(mods, name) + } + return mods +} diff --git a/tools/toolversion/internal/manifest/manifest_test.go b/tools/toolversion/internal/manifest/manifest_test.go new file mode 100644 index 00000000000..a334b0571bc --- /dev/null +++ b/tools/toolversion/internal/manifest/manifest_test.go @@ -0,0 +1,107 @@ +package manifest + +import ( + "os" + "path/filepath" + "testing" +) + +func writeManifest(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestParseLine(t *testing.T) { + t.Parallel() + tests := []struct { + line string + wantName string + wantVersion string + wantOK bool + }{ + {"", "", "", false}, + {"# comment", "", "", false}, + {"golang 1.26.4", "golang", "1.26.4", true}, + {"github.com/jmank88/gomods 0.1.7", "github.com/jmank88/gomods", "0.1.7", true}, + } + for _, tt := range tests { + name, version, ok := parseLine(tt.line) + if ok != tt.wantOK || name != tt.wantName || version != tt.wantVersion { + t.Errorf("parseLine(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.line, name, version, ok, tt.wantName, tt.wantVersion, tt.wantOK) + } + } +} + +func TestLookup(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tv := writeManifest(t, dir, ".tool-versions", `golang 1.26.4 +mockery 2.53.0 +protoc 29.3 +`) + gt := writeManifest(t, dir, "go-tools.txt", `# comment +github.com/jmank88/gomods 0.1.7 +`) + + store, err := New(tv, gt) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + key string + want string + wantErr bool + }{ + {"mockery", "2.53.0", false}, + {"protoc", "29.3", false}, + {"github.com/jmank88/gomods", "0.1.7", false}, + {"missing", "", true}, + {"github.com/missing/tool", "", true}, + } + for _, tt := range tests { + got, err := store.Lookup(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("Lookup(%q) error = %v, wantErr %v", tt.key, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("Lookup(%q) = %q, want %q", tt.key, got, tt.want) + } + } +} + +func TestList(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tv := writeManifest(t, dir, ".tool-versions", "mockery 2.53.0\n") + gt := writeManifest(t, dir, "go-tools.txt", "github.com/jmank88/gomods 0.1.7\n") + + store, err := New(tv, gt) + if err != nil { + t.Fatal(err) + } + entries, err := store.List() + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("len(List()) = %d, want 2", len(entries)) + } + if entries[0].Name != "mockery" || entries[1].Name != "github.com/jmank88/gomods" { + t.Fatalf("List() = %+v", entries) + } +} + +func TestNewFileNotFound(t *testing.T) { + t.Parallel() + _, err := New("/nonexistent/.tool-versions", "/nonexistent/go-tools.txt") + if err == nil { + t.Fatal("expected error for missing files") + } +} diff --git a/tools/toolversion/internal/modulemap/modulemap.go b/tools/toolversion/internal/modulemap/modulemap.go new file mode 100644 index 00000000000..60a99ba2e7b --- /dev/null +++ b/tools/toolversion/internal/modulemap/modulemap.go @@ -0,0 +1,27 @@ +// Package modulemap maps short runtime names to go module import paths. +package modulemap + +import "fmt" + +// runtimeToModule maps .tool-versions plugin names to go install module paths. +var runtimeToModule = map[string]string{ + "mockery": "github.com/vektra/mockery/v2", +} + +// ModulePath returns the go module path for a short runtime name. +func ModulePath(runtime string) (string, error) { + mod, ok := runtimeToModule[runtime] + if !ok { + return "", fmt.Errorf("no go module mapping for: %s", runtime) + } + return mod, nil +} + +// Modules returns all mapped module paths. +func Modules() []string { + out := make([]string, 0, len(runtimeToModule)) + for _, mod := range runtimeToModule { + out = append(out, mod) + } + return out +} diff --git a/tools/toolversion/internal/modulemap/modulemap_test.go b/tools/toolversion/internal/modulemap/modulemap_test.go new file mode 100644 index 00000000000..7f5c23ccaec --- /dev/null +++ b/tools/toolversion/internal/modulemap/modulemap_test.go @@ -0,0 +1,18 @@ +package modulemap + +import "testing" + +func TestModulePath(t *testing.T) { + t.Parallel() + mod, err := ModulePath("mockery") + if err != nil { + t.Fatal(err) + } + if mod != "github.com/vektra/mockery/v2" { + t.Fatalf("got %q", mod) + } + _, err = ModulePath("protoc") + if err == nil { + t.Fatal("expected error for protoc") + } +} diff --git a/tools/toolversion/internal/paths/paths.go b/tools/toolversion/internal/paths/paths.go new file mode 100644 index 00000000000..a15818ce5f7 --- /dev/null +++ b/tools/toolversion/internal/paths/paths.go @@ -0,0 +1,71 @@ +package paths + +import ( + "os" + "path/filepath" +) + +// Config holds manifest file locations. +type Config struct { + Root string + ToolVersionsFile string + GoToolsFile string + Makefile string + GoMod string +} + +// FromEnv resolves paths from CHAINLINK_ROOT (or repo root discovery) and manifest overrides. +func FromEnv() (Config, error) { + root := os.Getenv("CHAINLINK_ROOT") + if root == "" { + var err error + root, err = findRepoRoot() + if err != nil { + return Config{}, err + } + } + root, err := filepath.Abs(root) + if err != nil { + return Config{}, err + } + + tv := os.Getenv("TOOL_VERSIONS_FILE") + if tv == "" { + tv = filepath.Join(root, ".tool-versions") + } + gt := os.Getenv("GO_TOOLS_FILE") + if gt == "" { + gt = filepath.Join(root, "tools", "go-tools.txt") + } + + return Config{ + Root: root, + ToolVersionsFile: tv, + GoToolsFile: gt, + Makefile: filepath.Join(root, "GNUmakefile"), + GoMod: filepath.Join(root, "go.mod"), + }, nil +} + +func findRepoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if fileExists(filepath.Join(dir, "go.mod")) && fileExists(filepath.Join(dir, ".tool-versions")) { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", os.ErrNotExist +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/tools/toolversion/internal/paths/paths_test.go b/tools/toolversion/internal/paths/paths_test.go new file mode 100644 index 00000000000..86295ba8bd6 --- /dev/null +++ b/tools/toolversion/internal/paths/paths_test.go @@ -0,0 +1,30 @@ +package paths + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindRepoRootFromSubdir(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module test\n\ngo 1.26.4\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".tool-versions"), []byte("golang 1.26.4\n"), 0o600); err != nil { + t.Fatal(err) + } + sub := filepath.Join(root, "integration-tests") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(sub) + + got, err := findRepoRoot() + if err != nil { + t.Fatal(err) + } + if got != root { + t.Fatalf("findRepoRoot() = %q, want %q", got, root) + } +} diff --git a/tools/toolversion/internal/ref/ref.go b/tools/toolversion/internal/ref/ref.go new file mode 100644 index 00000000000..ed79db184ff --- /dev/null +++ b/tools/toolversion/internal/ref/ref.go @@ -0,0 +1,36 @@ +// Package ref formats manifest versions for consumers (go install, docker tags, etc.). +package ref + +import ( + "strings" +) + +// ForInstall prepends "v" to semver-looking versions; SHA and other pins pass through. +func ForInstall(version string) string { + if semverLike(version) { + return "v" + version + } + return version +} + +// ForConsumer is an alias for ForInstall (docker tags, golangci-lint-action, etc.). +func ForConsumer(version string) string { + return ForInstall(version) +} + +func semverLike(version string) bool { + if version == "" { + return false + } + dot := strings.IndexByte(version, '.') + if dot <= 0 { + return false + } + if version[0] < '0' || version[0] > '9' { + return false + } + if dot+1 >= len(version) { + return false + } + return version[dot+1] >= '0' && version[dot+1] <= '9' +} diff --git a/tools/toolversion/internal/ref/ref_test.go b/tools/toolversion/internal/ref/ref_test.go new file mode 100644 index 00000000000..73fa6f8ddb5 --- /dev/null +++ b/tools/toolversion/internal/ref/ref_test.go @@ -0,0 +1,24 @@ +package ref + +import "testing" + +func TestForInstall(t *testing.T) { + t.Parallel() + tests := []struct { + in string + want string + }{ + {"2.53.0", "v2.53.0"}, + {"2.12.2", "v2.12.2"}, + {"0.1.7", "v0.1.7"}, + {"29.3", "v29.3"}, + {"1.26.4", "v1.26.4"}, + {"42dc7da8c2874db550e91c656f98d05fca3c2f98", "42dc7da8c2874db550e91c656f98d05fca3c2f98"}, + {"v1.2.3", "v1.2.3"}, + } + for _, tt := range tests { + if got := ForInstall(tt.in); got != tt.want { + t.Errorf("ForInstall(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/tools/toolversion/internal/resolve/resolve.go b/tools/toolversion/internal/resolve/resolve.go new file mode 100644 index 00000000000..7b4ef78a547 --- /dev/null +++ b/tools/toolversion/internal/resolve/resolve.go @@ -0,0 +1,59 @@ +package resolve + +import ( + "fmt" + "strings" + + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/manifest" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/modulemap" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/ref" +) + +// Resolver reads versions from manifests and formats install targets. +type Resolver struct { + store *manifest.Store +} + +func New(store *manifest.Store) *Resolver { + return &Resolver{store: store} +} + +func (r *Resolver) Get(key string) (string, error) { + return r.store.Lookup(key) +} + +func (r *Resolver) Ref(key string) (string, error) { + v, err := r.store.Lookup(key) + if err != nil { + return "", err + } + return ref.ForConsumer(v), nil +} + +func (r *Resolver) Target(key string) (string, error) { + var module string + if strings.Contains(key, "/") { + module = key + } else { + var err error + module, err = modulemap.ModulePath(key) + if err != nil { + return "", err + } + } + version, err := r.store.Lookup(key) + if err != nil { + return "", err + } + return fmt.Sprintf("%s@%s", module, ref.ForInstall(version)), nil +} + +func (r *Resolver) List() ([]manifest.Entry, error) { + return r.store.List() +} + +func (r *Resolver) ManagedModules() []string { + mods := modulemap.Modules() + mods = append(mods, r.store.GoToolModules()...) + return mods +} diff --git a/tools/toolversion/main.go b/tools/toolversion/main.go new file mode 100644 index 00000000000..00afd3b65a2 --- /dev/null +++ b/tools/toolversion/main.go @@ -0,0 +1,227 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/drift" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/manifest" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/paths" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/resolve" +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintf(os.Stderr, "toolversion: %v\n", err) + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "toolversion", + Short: "Read dev-tool versions from .tool-versions and tools/go-tools.txt", + } + + root.AddCommand( + cmdGet(), + cmdRef(), + cmdTarget(), + cmdGoInstall(), + cmdList(), + cmdModules(), + cmdCheck(), + cmdMakeVars(), + ) + return root +} + +func loadResolver() (*resolve.Resolver, paths.Config, error) { + cfg, err := paths.FromEnv() + if err != nil { + return nil, cfg, err + } + store, err := manifest.New(cfg.ToolVersionsFile, cfg.GoToolsFile) + if err != nil { + return nil, cfg, err + } + return resolve.New(store), cfg, nil +} + +func cmdGet() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Print the raw version for a tool", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, _, err := loadResolver() + if err != nil { + return err + } + v, err := r.Get(args[0]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), v) + return nil + }, + } +} + +func cmdRef() *cobra.Command { + return &cobra.Command{ + Use: "ref ", + Short: "Print a consumer-ready version reference (v-prefix for semver)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, _, err := loadResolver() + if err != nil { + return err + } + v, err := r.Ref(args[0]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), v) + return nil + }, + } +} + +func cmdTarget() *cobra.Command { + return &cobra.Command{ + Use: "target ", + Short: "Print the go install argument: module@version", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, _, err := loadResolver() + if err != nil { + return err + } + t, err := r.Target(args[0]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), t) + return nil + }, + } +} + +func cmdGoInstall() *cobra.Command { + return &cobra.Command{ + Use: "go-install ", + Short: "Run go install for the tool at the pinned version", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, _, err := loadResolver() + if err != nil { + return err + } + target, err := r.Target(args[0]) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "go install %s\n", target) + c := exec.Command("go", "install", target) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + }, + } +} + +func cmdList() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "Print all name/version pairs from both manifests", + RunE: func(cmd *cobra.Command, args []string) error { + r, _, err := loadResolver() + if err != nil { + return err + } + entries, err := r.List() + if err != nil { + return err + } + for _, e := range entries { + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", e.Name, e.Version) + } + return nil + }, + } +} + +func cmdModules() *cobra.Command { + return &cobra.Command{ + Use: "modules", + Short: "Print all managed go module import paths", + RunE: func(cmd *cobra.Command, args []string) error { + r, _, err := loadResolver() + if err != nil { + return err + } + for _, m := range r.ManagedModules() { + fmt.Fprintln(cmd.OutOrStdout(), m) + } + return nil + }, + } +} + +func cmdCheck() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Fail if version pins drift from the manifests", + RunE: func(cmd *cobra.Command, args []string) error { + r, cfg, err := loadResolver() + if err != nil { + return err + } + if err := drift.NewChecker(cfg, r).Check(); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "check-tool-versions: ok") + return nil + }, + } +} + +func cmdMakeVars() *cobra.Command { + return &cobra.Command{ + Use: "make-vars", + Short: "Print Makefile variable assignments for common tool versions", + RunE: func(cmd *cobra.Command, args []string) error { + r, _, err := loadResolver() + if err != nil { + return err + } + vars := []struct { + key string + name string + }{ + {"golangci-lint", "GOLANGCI_LINT_VERSION"}, + {"protoc", "PROTOC_VERSION"}, + } + var b strings.Builder + for _, v := range vars { + var val string + if v.key == "protoc" { + val, err = r.Get(v.key) + } else { + val, err = r.Ref(v.key) + } + if err != nil { + return err + } + fmt.Fprintf(&b, "%s=%s\n", v.name, val) + } + fmt.Fprint(cmd.OutOrStdout(), b.String()) + return nil + }, + } +} diff --git a/tools/toolversion/main_test.go b/tools/toolversion/main_test.go new file mode 100644 index 00000000000..e62d323ff28 --- /dev/null +++ b/tools/toolversion/main_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestCLIGetAndTarget(t *testing.T) { + dir := t.TempDir() + writeManifest(t, dir, ".tool-versions", `mockery 2.53.0 +protoc 29.3 +golangci-lint 2.12.2 +golang 1.26.4 +`) + if err := os.MkdirAll(filepath.Join(dir, "tools"), 0o755); err != nil { + t.Fatal(err) + } + writeManifest(t, filepath.Join(dir, "tools"), "go-tools.txt", `github.com/jmank88/gomods 0.1.7 +github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 +`) + t.Setenv("CHAINLINK_ROOT", dir) + t.Setenv("TOOL_VERSIONS_FILE", filepath.Join(dir, ".tool-versions")) + t.Setenv("GO_TOOLS_FILE", filepath.Join(dir, "tools", "go-tools.txt")) + + tests := []struct { + args []string + want string + }{ + {[]string{"get", "mockery"}, "2.53.0"}, + {[]string{"ref", "golangci-lint"}, "v2.12.2"}, + {[]string{"target", "mockery"}, "github.com/vektra/mockery/v2@v2.53.0"}, + {[]string{"target", "github.com/jmank88/gomods"}, "github.com/jmank88/gomods@v0.1.7"}, + {[]string{"target", "github.com/smartcontractkit/gencodec"}, "github.com/smartcontractkit/gencodec@42dc7da8c2874db550e91c656f98d05fca3c2f98"}, + } + for _, tt := range tests { + var buf bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(tt.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("args %v: %v", tt.args, err) + } + got := bytes.TrimSpace(buf.Bytes()) + if string(got) != tt.want { + t.Errorf("args %v = %q, want %q", tt.args, got, tt.want) + } + } +} + +func writeManifest(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/tools/version-exceptions.yaml b/tools/version-exceptions.yaml new file mode 100644 index 00000000000..f777ed17c40 --- /dev/null +++ b/tools/version-exceptions.yaml @@ -0,0 +1,7 @@ +# Module-coupled installs; version must track go.mod dependencies. +- file: GNUmakefile + contains: "protoc-gen-go@`go list" + reason: protoc plugin tracks google.golang.org/protobuf in go.mod +- file: GNUmakefile + contains: "protoc-gen-go-wsrpc@`go list" + reason: protoc plugin tracks wsrpc in go.mod From ec597c1503c10ca1be121adecc5233c80ef9c990 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 11 Jun 2026 12:51:21 -0400 Subject: [PATCH 4/6] lint --- tools/toolversion/internal/drift/check.go | 68 ++++++++++--------- .../toolversion/internal/drift/check_test.go | 42 ++++-------- .../internal/manifest/manifest_test.go | 57 +++++++--------- .../internal/modulemap/modulemap_test.go | 20 +++--- .../toolversion/internal/paths/paths_test.go | 31 ++++----- tools/toolversion/internal/ref/ref_test.go | 10 +-- tools/toolversion/main.go | 2 +- tools/toolversion/main_test.go | 36 ++++------ 8 files changed, 119 insertions(+), 147 deletions(-) diff --git a/tools/toolversion/internal/drift/check.go b/tools/toolversion/internal/drift/check.go index a8750f2f7b9..eb33e874f1b 100644 --- a/tools/toolversion/internal/drift/check.go +++ b/tools/toolversion/internal/drift/check.go @@ -4,6 +4,7 @@ package drift import ( "bufio" "fmt" + "io/fs" "os" "path/filepath" "regexp" @@ -105,24 +106,30 @@ func parseGoModDirective(path string) (string, error) { func (c *Checker) checkStrayToolVersions() error { rootTV := filepath.Clean(c.cfg.ToolVersionsFile) + root, err := os.OpenRoot(c.cfg.Root) + if err != nil { + return err + } + defer root.Close() + var violations []string - err := filepath.WalkDir(c.cfg.Root, func(path string, d os.DirEntry, err error) error { + err = fs.WalkDir(root.FS(), ".", func(rel string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { - if skipDir(d.Name(), path) { - return filepath.SkipDir + if skipDir(d.Name(), rel) { + return fs.SkipDir } return nil } if d.Name() != ".tool-versions" { return nil } - if filepath.Clean(path) == rootTV || isFixturePath(path) { + abs := filepath.Join(c.cfg.Root, rel) + if filepath.Clean(abs) == rootTV || isFixturePath(rel) { return nil } - rel, _ := filepath.Rel(c.cfg.Root, path) violations = append(violations, fmt.Sprintf("check-tool-versions: stray .tool-versions at %s — only repo root .tool-versions is allowed", rel)) return nil }) @@ -138,26 +145,30 @@ func (c *Checker) checkStrayToolVersions() error { var goInstallPin = regexp.MustCompile(`go install [^[:space:]]+@(v[0-9]+(\.[0-9]+)*|latest|[0-9a-f]{7,})`) func (c *Checker) checkRepoWidePins() error { - var violations []string + root, err := os.OpenRoot(c.cfg.Root) + if err != nil { + return err + } + defer root.Close() - err := filepath.WalkDir(c.cfg.Root, func(path string, d os.DirEntry, err error) error { + var violations []string + err = fs.WalkDir(root.FS(), ".", func(rel string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { - if skipDir(d.Name(), path) { - return filepath.SkipDir + if skipDir(d.Name(), rel) { + return fs.SkipDir } return nil } - if shouldSkipScan(path, c.cfg) { + if shouldSkipScan(rel, c.cfg) { return nil } - data, err := os.ReadFile(path) + data, err := root.ReadFile(rel) if err != nil { return nil } - rel, _ := filepath.Rel(c.cfg.Root, path) for i, line := range strings.Split(string(data), "\n") { if isCommentLine(line) { continue @@ -181,16 +192,17 @@ func (c *Checker) checkRepoWidePins() error { return nil } -func skipDir(name, path string) bool { +func skipDir(name, rel string) bool { switch name { case ".git", "vendor", "node_modules", ".cursor", ".bin": return true } - return isFixturePath(path) + return isFixturePath(rel) } -func isFixturePath(path string) bool { - return strings.Contains(filepath.ToSlash(path), "/temp-repo/") +func isFixturePath(rel string) bool { + slash := filepath.ToSlash(rel) + return strings.Contains(slash, "/temp-repo/") || strings.HasPrefix(slash, "temp-repo/") } func isCommentLine(line string) bool { @@ -198,38 +210,32 @@ func isCommentLine(line string) bool { return trimmed == "" || strings.HasPrefix(trimmed, "#") } -func shouldSkipScan(path string, cfg paths.Config) bool { - if isFixturePath(path) { +func shouldSkipScan(rel string, cfg paths.Config) bool { + if isFixturePath(rel) { return true } - clean := filepath.Clean(path) + abs := filepath.Join(cfg.Root, rel) + clean := filepath.Clean(abs) if clean == filepath.Clean(cfg.ToolVersionsFile) || clean == filepath.Clean(cfg.GoToolsFile) { return true } - if strings.Contains(path, string(filepath.Join("tools", "toolversion"))) { - return true - } - base := filepath.Base(path) - if !scannableFile(base, path) { + if strings.Contains(filepath.ToSlash(rel), "tools/toolversion") { return true } - return false + return !scannableFile(filepath.Base(rel), rel) } -func scannableFile(base, path string) bool { +func scannableFile(base, rel string) bool { switch base { case "GNUmakefile", "Makefile": return true } - ext := filepath.Ext(path) + ext := filepath.Ext(rel) switch ext { case ".go", ".sh", ".yml", ".yaml", ".md", ".nix", ".mk", ".Dockerfile", ".dockerfile": return true case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".gz", ".zip", ".wasm", ".pb", ".bin", "": return false } - if strings.HasSuffix(base, "Dockerfile") { - return true - } - return false + return strings.HasSuffix(base, "Dockerfile") } diff --git a/tools/toolversion/internal/drift/check_test.go b/tools/toolversion/internal/drift/check_test.go index a209ea00a1e..0ac40325c72 100644 --- a/tools/toolversion/internal/drift/check_test.go +++ b/tools/toolversion/internal/drift/check_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/require" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/manifest" "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/paths" "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/resolve" @@ -12,9 +14,7 @@ import ( func writeFile(t *testing.T, dir, name, content string) { t.Helper() - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { - t.Fatal(err) - } + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) } func setupRepo(t *testing.T) (paths.Config, *resolve.Resolver) { @@ -22,9 +22,7 @@ func setupRepo(t *testing.T) (paths.Config, *resolve.Resolver) { root := t.TempDir() writeFile(t, root, ".tool-versions", "golang 1.26.4\nmockery 2.53.0\n") writeFile(t, root, "go.mod", "module example.com/test\n\ngo 1.26.4\n") - if err := os.Mkdir(filepath.Join(root, "tools"), 0o755); err != nil { - t.Fatal(err) - } + require.NoError(t, os.Mkdir(filepath.Join(root, "tools"), 0o755)) writeFile(t, filepath.Join(root, "tools"), "go-tools.txt", "github.com/jmank88/gomods 0.1.7\n") writeFile(t, root, "GNUmakefile", "install:\n\t$(TOOL_VERSION) go-install mockery\n") @@ -36,9 +34,7 @@ func setupRepo(t *testing.T) (paths.Config, *resolve.Resolver) { GoMod: filepath.Join(root, "go.mod"), } store, err := manifest.New(cfg.ToolVersionsFile, cfg.GoToolsFile) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) return cfg, resolve.New(store) } @@ -48,9 +44,7 @@ func TestCheckMakefilePinViolation(t *testing.T) { writeFile(t, cfg.Root, "GNUmakefile", "install:\n\tgo install github.com/vektra/mockery/v2@v2.99.0\n") err := NewChecker(cfg, resolver).Check() - if err == nil { - t.Fatal("expected violation") - } + require.Error(t, err) } func TestCheckGolangMirror(t *testing.T) { @@ -58,29 +52,21 @@ func TestCheckGolangMirror(t *testing.T) { cfg, _ := setupRepo(t) writeFile(t, cfg.Root, ".tool-versions", "golang 1.26.3\nmockery 2.53.0\n") store, err := manifest.New(cfg.ToolVersionsFile, cfg.GoToolsFile) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) resolver := resolve.New(store) err = NewChecker(cfg, resolver).Check() - if err == nil { - t.Fatal("expected golang mirror violation") - } + require.Error(t, err) } func TestCheckStrayToolVersions(t *testing.T) { t.Parallel() cfg, resolver := setupRepo(t) - if err := os.Mkdir(filepath.Join(cfg.Root, "integration-tests"), 0o755); err != nil { - t.Fatal(err) - } + require.NoError(t, os.Mkdir(filepath.Join(cfg.Root, "integration-tests"), 0o755)) writeFile(t, filepath.Join(cfg.Root, "integration-tests"), ".tool-versions", "golang 1.26.4\n") err := NewChecker(cfg, resolver).Check() - if err == nil { - t.Fatal("expected stray .tool-versions violation") - } + require.Error(t, err) } func TestCheckAllowedProtocException(t *testing.T) { @@ -90,9 +76,7 @@ func TestCheckAllowedProtocException(t *testing.T) { writeFile(t, cfg.Root, "GNUmakefile", "install:\n\tgo install google.golang.org/protobuf/cmd/protoc-gen-go@`go list -m -json google.golang.org/protobuf | jq -r .Version`\n") err := NewChecker(cfg, resolver).checkMakefilePins() - if err != nil { - t.Fatalf("expected protoc exception to pass makefile check: %v", err) - } + require.NoError(t, err) } func TestCheckRepoWideGoInstall(t *testing.T) { @@ -101,7 +85,5 @@ func TestCheckRepoWideGoInstall(t *testing.T) { writeFile(t, cfg.Root, "script.sh", "go install gotest.tools/gotestsum@v9.9.9\n") err := NewChecker(cfg, resolver).Check() - if err == nil { - t.Fatal("expected repo-wide go install violation") - } + require.Error(t, err) } diff --git a/tools/toolversion/internal/manifest/manifest_test.go b/tools/toolversion/internal/manifest/manifest_test.go index a334b0571bc..99faae55e77 100644 --- a/tools/toolversion/internal/manifest/manifest_test.go +++ b/tools/toolversion/internal/manifest/manifest_test.go @@ -4,24 +4,25 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func writeManifest(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) return path } func TestParseLine(t *testing.T) { t.Parallel() tests := []struct { - line string - wantName string - wantVersion string - wantOK bool + line string + wantName string + wantVersion string + wantOK bool }{ {"", "", "", false}, {"# comment", "", "", false}, @@ -30,10 +31,9 @@ func TestParseLine(t *testing.T) { } for _, tt := range tests { name, version, ok := parseLine(tt.line) - if ok != tt.wantOK || name != tt.wantName || version != tt.wantVersion { - t.Errorf("parseLine(%q) = (%q, %q, %v), want (%q, %q, %v)", - tt.line, name, version, ok, tt.wantName, tt.wantVersion, tt.wantOK) - } + assert.Equal(t, tt.wantOK, ok, "parseLine(%q) ok", tt.line) + assert.Equal(t, tt.wantName, name, "parseLine(%q) name", tt.line) + assert.Equal(t, tt.wantVersion, version, "parseLine(%q) version", tt.line) } } @@ -49,9 +49,7 @@ github.com/jmank88/gomods 0.1.7 `) store, err := New(tv, gt) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) tests := []struct { key string @@ -66,13 +64,12 @@ github.com/jmank88/gomods 0.1.7 } for _, tt := range tests { got, err := store.Lookup(tt.key) - if (err != nil) != tt.wantErr { - t.Errorf("Lookup(%q) error = %v, wantErr %v", tt.key, err, tt.wantErr) + if tt.wantErr { + require.Error(t, err, "Lookup(%q)", tt.key) continue } - if got != tt.want { - t.Errorf("Lookup(%q) = %q, want %q", tt.key, got, tt.want) - } + require.NoError(t, err, "Lookup(%q)", tt.key) + assert.Equal(t, tt.want, got, "Lookup(%q)", tt.key) } } @@ -83,25 +80,17 @@ func TestList(t *testing.T) { gt := writeManifest(t, dir, "go-tools.txt", "github.com/jmank88/gomods 0.1.7\n") store, err := New(tv, gt) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + entries, err := store.List() - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("len(List()) = %d, want 2", len(entries)) - } - if entries[0].Name != "mockery" || entries[1].Name != "github.com/jmank88/gomods" { - t.Fatalf("List() = %+v", entries) - } + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "mockery", entries[0].Name) + assert.Equal(t, "github.com/jmank88/gomods", entries[1].Name) } func TestNewFileNotFound(t *testing.T) { t.Parallel() _, err := New("/nonexistent/.tool-versions", "/nonexistent/go-tools.txt") - if err == nil { - t.Fatal("expected error for missing files") - } + require.Error(t, err) } diff --git a/tools/toolversion/internal/modulemap/modulemap_test.go b/tools/toolversion/internal/modulemap/modulemap_test.go index 7f5c23ccaec..e3d98c673f1 100644 --- a/tools/toolversion/internal/modulemap/modulemap_test.go +++ b/tools/toolversion/internal/modulemap/modulemap_test.go @@ -1,18 +1,18 @@ package modulemap -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) func TestModulePath(t *testing.T) { t.Parallel() mod, err := ModulePath("mockery") - if err != nil { - t.Fatal(err) - } - if mod != "github.com/vektra/mockery/v2" { - t.Fatalf("got %q", mod) - } + require.NoError(t, err) + assert.Equal(t, "github.com/vektra/mockery/v2", mod) + _, err = ModulePath("protoc") - if err == nil { - t.Fatal("expected error for protoc") - } + require.Error(t, err) } diff --git a/tools/toolversion/internal/paths/paths_test.go b/tools/toolversion/internal/paths/paths_test.go index 86295ba8bd6..d4f08ab36c9 100644 --- a/tools/toolversion/internal/paths/paths_test.go +++ b/tools/toolversion/internal/paths/paths_test.go @@ -4,27 +4,26 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/require" ) func TestFindRepoRootFromSubdir(t *testing.T) { root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module test\n\ngo 1.26.4\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, ".tool-versions"), []byte("golang 1.26.4\n"), 0o600); err != nil { - t.Fatal(err) - } + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module test\n\ngo 1.26.4\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".tool-versions"), []byte("golang 1.26.4\n"), 0o600)) + sub := filepath.Join(root, "integration-tests") - if err := os.Mkdir(sub, 0o755); err != nil { - t.Fatal(err) - } - t.Chdir(sub) + require.NoError(t, os.Mkdir(sub, 0o755)) + require.NoError(t, os.Chdir(sub)) + t.Cleanup(func() { _ = os.Chdir(root) }) got, err := findRepoRoot() - if err != nil { - t.Fatal(err) - } - if got != root { - t.Fatalf("findRepoRoot() = %q, want %q", got, root) - } + require.NoError(t, err) + + wantRoot, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + gotRoot, err := filepath.EvalSymlinks(got) + require.NoError(t, err) + require.Equal(t, wantRoot, gotRoot) } diff --git a/tools/toolversion/internal/ref/ref_test.go b/tools/toolversion/internal/ref/ref_test.go index 73fa6f8ddb5..a8f26347237 100644 --- a/tools/toolversion/internal/ref/ref_test.go +++ b/tools/toolversion/internal/ref/ref_test.go @@ -1,6 +1,10 @@ package ref -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestForInstall(t *testing.T) { t.Parallel() @@ -17,8 +21,6 @@ func TestForInstall(t *testing.T) { {"v1.2.3", "v1.2.3"}, } for _, tt := range tests { - if got := ForInstall(tt.in); got != tt.want { - t.Errorf("ForInstall(%q) = %q, want %q", tt.in, got, tt.want) - } + assert.Equal(t, tt.want, ForInstall(tt.in), "ForInstall(%q)", tt.in) } } diff --git a/tools/toolversion/main.go b/tools/toolversion/main.go index 00afd3b65a2..43c77eb92fa 100644 --- a/tools/toolversion/main.go +++ b/tools/toolversion/main.go @@ -127,7 +127,7 @@ func cmdGoInstall() *cobra.Command { return err } fmt.Fprintf(os.Stderr, "go install %s\n", target) - c := exec.Command("go", "install", target) + c := exec.CommandContext(cmd.Context(), "go", "install", target) c.Stdout = os.Stdout c.Stderr = os.Stderr return c.Run() diff --git a/tools/toolversion/main_test.go b/tools/toolversion/main_test.go index e62d323ff28..ee1741a685a 100644 --- a/tools/toolversion/main_test.go +++ b/tools/toolversion/main_test.go @@ -5,6 +5,9 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCLIGetAndTarget(t *testing.T) { @@ -14,9 +17,7 @@ protoc 29.3 golangci-lint 2.12.2 golang 1.26.4 `) - if err := os.MkdirAll(filepath.Join(dir, "tools"), 0o755); err != nil { - t.Fatal(err) - } + require.NoError(t, os.MkdirAll(filepath.Join(dir, "tools"), 0o755)) writeManifest(t, filepath.Join(dir, "tools"), "go-tools.txt", `github.com/jmank88/gomods 0.1.7 github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 `) @@ -35,27 +36,20 @@ github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 {[]string{"target", "github.com/smartcontractkit/gencodec"}, "github.com/smartcontractkit/gencodec@42dc7da8c2874db550e91c656f98d05fca3c2f98"}, } for _, tt := range tests { - var buf bytes.Buffer - cmd := newRootCmd() - cmd.SetOut(&buf) - cmd.SetErr(&buf) - cmd.SetArgs(tt.args) - if err := cmd.Execute(); err != nil { - t.Fatalf("args %v: %v", tt.args, err) - } - got := bytes.TrimSpace(buf.Bytes()) - if string(got) != tt.want { - t.Errorf("args %v = %q, want %q", tt.args, got, tt.want) - } + t.Run(tt.want, func(t *testing.T) { + var buf bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(tt.args) + require.NoError(t, cmd.Execute(), "args %v", tt.args) + assert.Equal(t, tt.want, string(bytes.TrimSpace(buf.Bytes())), "args %v", tt.args) + }) } } func writeManifest(t *testing.T, dir, name, content string) { t.Helper() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { - t.Fatal(err) - } + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) } From 54d5687d8d47630c343d6e6ac07b7f82ad8e664f Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 11 Jun 2026 13:05:42 -0400 Subject: [PATCH 5/6] cleanup --- tools/toolversion/internal/drift/check.go | 61 +++++++++--- .../toolversion/internal/drift/check_test.go | 90 +++++++++++++++++- .../toolversion/internal/drift/exceptions.go | 18 ---- .../toolversion/internal/manifest/manifest.go | 71 ++++++-------- .../internal/manifest/manifest_test.go | 38 +++++++- .../internal/modulemap/modulemap.go | 8 +- .../internal/modulemap/modulemap_test.go | 9 ++ tools/toolversion/internal/ref/ref.go | 5 - tools/toolversion/internal/resolve/resolve.go | 7 +- tools/toolversion/main.go | 5 +- tools/toolversion/main_test.go | 94 ++++++++++++++++--- 11 files changed, 304 insertions(+), 102 deletions(-) diff --git a/tools/toolversion/internal/drift/check.go b/tools/toolversion/internal/drift/check.go index eb33e874f1b..d137186b77f 100644 --- a/tools/toolversion/internal/drift/check.go +++ b/tools/toolversion/internal/drift/check.go @@ -26,8 +26,13 @@ func NewChecker(cfg paths.Config, resolver *resolve.Resolver) *Checker { // Check runs all drift rules and returns a combined error if any fail. func (c *Checker) Check() error { + exs, err := loadExceptions(c.cfg.Root) + if err != nil { + return fmt.Errorf("load exceptions: %w", err) + } + var errs []string - if err := c.checkMakefilePins(); err != nil { + if err := c.checkMakefilePins(exs); err != nil { errs = append(errs, err.Error()) } if err := c.checkGolangMirror(); err != nil { @@ -36,7 +41,7 @@ func (c *Checker) Check() error { if err := c.checkStrayToolVersions(); err != nil { errs = append(errs, err.Error()) } - if err := c.checkRepoWidePins(); err != nil { + if err := c.checkRepoWidePins(exs); err != nil { errs = append(errs, err.Error()) } if len(errs) == 0 { @@ -45,18 +50,45 @@ func (c *Checker) Check() error { return fmt.Errorf("%s", strings.Join(errs, "\n")) } -func (c *Checker) checkMakefilePins() error { +// isAllowedException reports whether the given line in relFile is covered by an exception. +// relFile must be a slash-separated path relative to the repo root. +func isAllowedException(exs []exception, relFile, line string) bool { + for _, ex := range exs { + if relFile != filepath.ToSlash(ex.File) { + continue + } + if strings.Contains(line, ex.Contains) { + return true + } + } + return false +} + +func (c *Checker) checkMakefilePins(exs []exception) error { data, err := os.ReadFile(c.cfg.Makefile) if err != nil { return fmt.Errorf("read GNUmakefile: %w", err) } - content := string(data) + + type patMod struct { + pat *regexp.Regexp + mod string + } + mods := c.resolver.ManagedModules() + patterns := make([]patMod, len(mods)) + for i, mod := range mods { + patterns[i] = patMod{ + pat: regexp.MustCompile(regexp.QuoteMeta(mod) + `@(v[0-9]|[0-9a-f]{7,})`), + mod: mod, + } + } + + lines := strings.Split(string(data), "\n") var violations []string - for _, mod := range c.resolver.ManagedModules() { - pat := regexp.MustCompile(regexp.QuoteMeta(mod) + `@(v[0-9]|[0-9a-f]{7,})`) - for i, line := range strings.Split(content, "\n") { - if pat.MatchString(line) && !isAllowedException(c.cfg.Root, "GNUmakefile", line) { - violations = append(violations, fmt.Sprintf("GNUmakefile:%d: hardcoded pin for %s — use $(TOOL_VERSION) go-install instead:\n %s", i+1, mod, strings.TrimSpace(line))) + for _, pm := range patterns { + for i, line := range lines { + if pm.pat.MatchString(line) && !isAllowedException(exs, "GNUmakefile", line) { + violations = append(violations, fmt.Sprintf("GNUmakefile:%d: hardcoded pin for %s — use $(TOOL_VERSION) go-install instead:\n %s", i+1, pm.mod, strings.TrimSpace(line))) } } } @@ -144,7 +176,7 @@ func (c *Checker) checkStrayToolVersions() error { var goInstallPin = regexp.MustCompile(`go install [^[:space:]]+@(v[0-9]+(\.[0-9]+)*|latest|[0-9a-f]{7,})`) -func (c *Checker) checkRepoWidePins() error { +func (c *Checker) checkRepoWidePins(exs []exception) error { root, err := os.OpenRoot(c.cfg.Root) if err != nil { return err @@ -176,7 +208,7 @@ func (c *Checker) checkRepoWidePins() error { if !goInstallPin.MatchString(line) { continue } - if isAllowedException(c.cfg.Root, rel, line) { + if isAllowedException(exs, filepath.ToSlash(rel), line) { continue } violations = append(violations, fmt.Sprintf("%s:%d: hardcoded go install pin — use go run ./tools/toolversion go-install :\n %s", rel, i+1, strings.TrimSpace(line))) @@ -230,6 +262,11 @@ func scannableFile(base, rel string) bool { case "GNUmakefile", "Makefile": return true } + // Check Dockerfile variants before the extension switch so that bare + // "Dockerfile" (ext == "") is not short-circuited by the empty-ext false case. + if strings.HasSuffix(base, "Dockerfile") { + return true + } ext := filepath.Ext(rel) switch ext { case ".go", ".sh", ".yml", ".yaml", ".md", ".nix", ".mk", ".Dockerfile", ".dockerfile": @@ -237,5 +274,5 @@ func scannableFile(base, rel string) bool { case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".gz", ".zip", ".wasm", ".pb", ".bin", "": return false } - return strings.HasSuffix(base, "Dockerfile") + return false } diff --git a/tools/toolversion/internal/drift/check_test.go b/tools/toolversion/internal/drift/check_test.go index 0ac40325c72..5ca72f8c96b 100644 --- a/tools/toolversion/internal/drift/check_test.go +++ b/tools/toolversion/internal/drift/check_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/manifest" @@ -38,6 +39,12 @@ func setupRepo(t *testing.T) (paths.Config, *resolve.Resolver) { return cfg, resolve.New(store) } +func TestCheckAllClean(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + require.NoError(t, NewChecker(cfg, resolver).Check()) +} + func TestCheckMakefilePinViolation(t *testing.T) { t.Parallel() cfg, resolver := setupRepo(t) @@ -72,10 +79,26 @@ func TestCheckStrayToolVersions(t *testing.T) { func TestCheckAllowedProtocException(t *testing.T) { t.Parallel() cfg, resolver := setupRepo(t) - writeFile(t, filepath.Join(cfg.Root, "tools"), "version-exceptions.yaml", "- file: GNUmakefile\n contains: \"protoc-gen-go@\"\n reason: module-coupled\n") + writeFile(t, filepath.Join(cfg.Root, "tools"), "version-exceptions.yaml", + "- file: GNUmakefile\n contains: \"protoc-gen-go@\"\n reason: module-coupled\n") writeFile(t, cfg.Root, "GNUmakefile", "install:\n\tgo install google.golang.org/protobuf/cmd/protoc-gen-go@`go list -m -json google.golang.org/protobuf | jq -r .Version`\n") - err := NewChecker(cfg, resolver).checkMakefilePins() + exs, err := loadExceptions(cfg.Root) + require.NoError(t, err) + err = NewChecker(cfg, resolver).checkMakefilePins(exs) + require.NoError(t, err) +} + +// TestCheckMakefilePinWithException verifies that a managed-module pin in GNUmakefile +// is suppressed when covered by an exception entry. +func TestCheckMakefilePinWithException(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + writeFile(t, filepath.Join(cfg.Root, "tools"), "version-exceptions.yaml", + "- file: GNUmakefile\n contains: \"github.com/vektra/mockery/v2@\"\n reason: bootstrap\n") + writeFile(t, cfg.Root, "GNUmakefile", "install:\n\tgo install github.com/vektra/mockery/v2@v2.99.0\n") + + err := NewChecker(cfg, resolver).Check() require.NoError(t, err) } @@ -87,3 +110,66 @@ func TestCheckRepoWideGoInstall(t *testing.T) { err := NewChecker(cfg, resolver).Check() require.Error(t, err) } + +func TestCheckRepoWideGoInstallClean(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + writeFile(t, cfg.Root, "script.sh", "#!/bin/bash\ngo run ./tools/toolversion go-install somelib\n") + + err := NewChecker(cfg, resolver).Check() + require.NoError(t, err) +} + +func TestCheckRepoWideGoInstallInDockerfile(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + writeFile(t, cfg.Root, "Dockerfile", "FROM golang:1.26\nRUN go install gotest.tools/gotestsum@v9.9.9\n") + + err := NewChecker(cfg, resolver).Check() + require.Error(t, err, "bare Dockerfile with hardcoded go install pin should be flagged") +} + +func TestCheckMalformedExceptions(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + writeFile(t, filepath.Join(cfg.Root, "tools"), "version-exceptions.yaml", "not: valid: yaml: [\n") + + err := NewChecker(cfg, resolver).Check() + require.Error(t, err, "malformed version-exceptions.yaml must return an error, not silently ignore exceptions") +} + +func TestExceptionExactPathNoSubdirLeak(t *testing.T) { + t.Parallel() + cfg, resolver := setupRepo(t) + // Exception covers only "script.sh" at repo root, NOT "sub/script.sh" + writeFile(t, filepath.Join(cfg.Root, "tools"), "version-exceptions.yaml", + "- file: script.sh\n contains: \"go install gotest.tools\"\n reason: test\n") + require.NoError(t, os.Mkdir(filepath.Join(cfg.Root, "sub"), 0o755)) + writeFile(t, filepath.Join(cfg.Root, "sub"), "script.sh", "go install gotest.tools/gotestsum@v9.9.9\n") + + err := NewChecker(cfg, resolver).Check() + require.Error(t, err, "exception for 'script.sh' must not cover 'sub/script.sh'") +} + +func TestScannableFileDockerfile(t *testing.T) { + t.Parallel() + tests := []struct { + base string + rel string + want bool + }{ + {"Dockerfile", "Dockerfile", true}, + {"Dockerfile", "some/path/Dockerfile", true}, + {"chainlink.Dockerfile", "core/chainlink.Dockerfile", true}, + {"server.dockerfile", "server.dockerfile", true}, + {"Makefile", "Makefile", true}, + {"GNUmakefile", "GNUmakefile", true}, + {"main.go", "main.go", true}, + {"config.yaml", "config.yaml", true}, + {"image.png", "image.png", false}, + {"binary", "binary", false}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, scannableFile(tt.base, tt.rel), "scannableFile(%q, %q)", tt.base, tt.rel) + } +} diff --git a/tools/toolversion/internal/drift/exceptions.go b/tools/toolversion/internal/drift/exceptions.go index 95806b9856d..3e3814eaca7 100644 --- a/tools/toolversion/internal/drift/exceptions.go +++ b/tools/toolversion/internal/drift/exceptions.go @@ -3,7 +3,6 @@ package drift import ( "os" "path/filepath" - "strings" "gopkg.in/yaml.v3" ) @@ -29,20 +28,3 @@ func loadExceptions(root string) ([]exception, error) { } return out, nil } - -func isAllowedException(root, relFile, line string) bool { - exceptions, err := loadExceptions(root) - if err != nil { - return false - } - relFile = filepath.ToSlash(relFile) - for _, ex := range exceptions { - if !strings.HasSuffix(relFile, filepath.ToSlash(ex.File)) && relFile != filepath.ToSlash(ex.File) { - continue - } - if strings.Contains(line, ex.Contains) { - return true - } - } - return false -} diff --git a/tools/toolversion/internal/manifest/manifest.go b/tools/toolversion/internal/manifest/manifest.go index 56a049c6a26..6ae2bf879be 100644 --- a/tools/toolversion/internal/manifest/manifest.go +++ b/tools/toolversion/internal/manifest/manifest.go @@ -20,41 +20,45 @@ type Store struct { GoToolsPath string runtimes map[string]string goTools map[string]string + runtimeEntries []Entry + goToolEntries []Entry } // New loads both manifest files from the given paths. func New(toolVersionsPath, goToolsPath string) (*Store, error) { - runtimes, err := parseFile(toolVersionsPath) + runtimeEntries, err := parseFile(toolVersionsPath) if err != nil { return nil, fmt.Errorf("parse %s: %w", toolVersionsPath, err) } - goTools, err := parseFile(goToolsPath) + goToolEntries, err := parseFile(goToolsPath) if err != nil { return nil, fmt.Errorf("parse %s: %w", goToolsPath, err) } return &Store{ ToolVersionsPath: toolVersionsPath, GoToolsPath: goToolsPath, - runtimes: runtimes, - goTools: goTools, + runtimes: entriesToMap(runtimeEntries), + goTools: entriesToMap(goToolEntries), + runtimeEntries: runtimeEntries, + goToolEntries: goToolEntries, }, nil } -func parseFile(path string) (map[string]string, error) { +func parseFile(path string) ([]Entry, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() - entries := make(map[string]string) + var entries []Entry scanner := bufio.NewScanner(f) for scanner.Scan() { name, version, ok := parseLine(scanner.Text()) if !ok { continue } - entries[name] = version + entries = append(entries, Entry{Name: name, Version: version}) } if err := scanner.Err(); err != nil { return nil, err @@ -62,6 +66,14 @@ func parseFile(path string) (map[string]string, error) { return entries, nil } +func entriesToMap(entries []Entry) map[string]string { + m := make(map[string]string, len(entries)) + for _, e := range entries { + m[e.Name] = e.Version + } + return m +} + func parseLine(line string) (name, version string, ok bool) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { @@ -92,45 +104,18 @@ func (s *Store) Lookup(key string) (string, error) { } // List returns all entries from both manifests in file order (.tool-versions first). -func (s *Store) List() ([]Entry, error) { - var out []Entry - for _, path := range []string{s.ToolVersionsPath, s.GoToolsPath} { - entries, err := listFile(path) - if err != nil { - return nil, err - } - out = append(out, entries...) - } - return out, nil -} - -func listFile(path string) ([]Entry, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - var out []Entry - scanner := bufio.NewScanner(f) - for scanner.Scan() { - name, version, ok := parseLine(scanner.Text()) - if !ok { - continue - } - out = append(out, Entry{Name: name, Version: version}) - } - if err := scanner.Err(); err != nil { - return nil, err - } - return out, nil +func (s *Store) List() []Entry { + out := make([]Entry, 0, len(s.runtimeEntries)+len(s.goToolEntries)) + out = append(out, s.runtimeEntries...) + out = append(out, s.goToolEntries...) + return out } -// GoToolModules returns import paths from go-tools.txt. +// GoToolModules returns import paths from go-tools.txt in file order. func (s *Store) GoToolModules() []string { - mods := make([]string, 0, len(s.goTools)) - for name := range s.goTools { - mods = append(mods, name) + mods := make([]string, len(s.goToolEntries)) + for i, e := range s.goToolEntries { + mods[i] = e.Name } return mods } diff --git a/tools/toolversion/internal/manifest/manifest_test.go b/tools/toolversion/internal/manifest/manifest_test.go index 99faae55e77..05c53f5921a 100644 --- a/tools/toolversion/internal/manifest/manifest_test.go +++ b/tools/toolversion/internal/manifest/manifest_test.go @@ -82,13 +82,47 @@ func TestList(t *testing.T) { store, err := New(tv, gt) require.NoError(t, err) - entries, err := store.List() - require.NoError(t, err) + entries := store.List() require.Len(t, entries, 2) assert.Equal(t, "mockery", entries[0].Name) assert.Equal(t, "github.com/jmank88/gomods", entries[1].Name) } +func TestListOrder(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tv := writeManifest(t, dir, ".tool-versions", "golang 1.26.4\nmockery 2.53.0\nprotoc 29.3\n") + gt := writeManifest(t, dir, "go-tools.txt", "github.com/a/tool 1.0.0\ngithub.com/b/tool 2.0.0\n") + + store, err := New(tv, gt) + require.NoError(t, err) + + entries := store.List() + require.Len(t, entries, 5) + // .tool-versions entries come first, in file order + assert.Equal(t, "golang", entries[0].Name) + assert.Equal(t, "mockery", entries[1].Name) + assert.Equal(t, "protoc", entries[2].Name) + // go-tools.txt entries follow, in file order + assert.Equal(t, "github.com/a/tool", entries[3].Name) + assert.Equal(t, "github.com/b/tool", entries[4].Name) +} + +func TestGoToolModulesStable(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tv := writeManifest(t, dir, ".tool-versions", "golang 1.26.4\n") + gt := writeManifest(t, dir, "go-tools.txt", "github.com/a/tool 1.0.0\ngithub.com/b/tool 2.0.0\ngithub.com/c/tool 3.0.0\n") + + store, err := New(tv, gt) + require.NoError(t, err) + + got1 := store.GoToolModules() + got2 := store.GoToolModules() + assert.Equal(t, got1, got2, "GoToolModules must be deterministic") + assert.Equal(t, []string{"github.com/a/tool", "github.com/b/tool", "github.com/c/tool"}, got1) +} + func TestNewFileNotFound(t *testing.T) { t.Parallel() _, err := New("/nonexistent/.tool-versions", "/nonexistent/go-tools.txt") diff --git a/tools/toolversion/internal/modulemap/modulemap.go b/tools/toolversion/internal/modulemap/modulemap.go index 60a99ba2e7b..b92f8d449d9 100644 --- a/tools/toolversion/internal/modulemap/modulemap.go +++ b/tools/toolversion/internal/modulemap/modulemap.go @@ -1,7 +1,10 @@ // Package modulemap maps short runtime names to go module import paths. package modulemap -import "fmt" +import ( + "fmt" + "sort" +) // runtimeToModule maps .tool-versions plugin names to go install module paths. var runtimeToModule = map[string]string{ @@ -17,11 +20,12 @@ func ModulePath(runtime string) (string, error) { return mod, nil } -// Modules returns all mapped module paths. +// Modules returns all mapped module paths in sorted order. func Modules() []string { out := make([]string, 0, len(runtimeToModule)) for _, mod := range runtimeToModule { out = append(out, mod) } + sort.Strings(out) return out } diff --git a/tools/toolversion/internal/modulemap/modulemap_test.go b/tools/toolversion/internal/modulemap/modulemap_test.go index e3d98c673f1..39824bacd71 100644 --- a/tools/toolversion/internal/modulemap/modulemap_test.go +++ b/tools/toolversion/internal/modulemap/modulemap_test.go @@ -1,6 +1,7 @@ package modulemap import ( + "sort" "testing" "github.com/stretchr/testify/assert" @@ -16,3 +17,11 @@ func TestModulePath(t *testing.T) { _, err = ModulePath("protoc") require.Error(t, err) } + +func TestModulesSorted(t *testing.T) { + t.Parallel() + mods := Modules() + assert.True(t, sort.StringsAreSorted(mods), "Modules() output must be sorted; got %v", mods) + // calling twice must return same order + assert.Equal(t, mods, Modules()) +} diff --git a/tools/toolversion/internal/ref/ref.go b/tools/toolversion/internal/ref/ref.go index ed79db184ff..53be306dd06 100644 --- a/tools/toolversion/internal/ref/ref.go +++ b/tools/toolversion/internal/ref/ref.go @@ -13,11 +13,6 @@ func ForInstall(version string) string { return version } -// ForConsumer is an alias for ForInstall (docker tags, golangci-lint-action, etc.). -func ForConsumer(version string) string { - return ForInstall(version) -} - func semverLike(version string) bool { if version == "" { return false diff --git a/tools/toolversion/internal/resolve/resolve.go b/tools/toolversion/internal/resolve/resolve.go index 7b4ef78a547..3933684455b 100644 --- a/tools/toolversion/internal/resolve/resolve.go +++ b/tools/toolversion/internal/resolve/resolve.go @@ -2,6 +2,7 @@ package resolve import ( "fmt" + "sort" "strings" "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/manifest" @@ -27,7 +28,7 @@ func (r *Resolver) Ref(key string) (string, error) { if err != nil { return "", err } - return ref.ForConsumer(v), nil + return ref.ForInstall(v), nil } func (r *Resolver) Target(key string) (string, error) { @@ -48,12 +49,14 @@ func (r *Resolver) Target(key string) (string, error) { return fmt.Sprintf("%s@%s", module, ref.ForInstall(version)), nil } -func (r *Resolver) List() ([]manifest.Entry, error) { +func (r *Resolver) List() []manifest.Entry { return r.store.List() } +// ManagedModules returns all module paths tracked by the manifests, sorted. func (r *Resolver) ManagedModules() []string { mods := modulemap.Modules() mods = append(mods, r.store.GoToolModules()...) + sort.Strings(mods) return mods } diff --git a/tools/toolversion/main.go b/tools/toolversion/main.go index 43c77eb92fa..29834e26254 100644 --- a/tools/toolversion/main.go +++ b/tools/toolversion/main.go @@ -144,10 +144,7 @@ func cmdList() *cobra.Command { if err != nil { return err } - entries, err := r.List() - if err != nil { - return err - } + entries := r.List() for _, e := range entries { fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", e.Name, e.Version) } diff --git a/tools/toolversion/main_test.go b/tools/toolversion/main_test.go index ee1741a685a..2ff372039a0 100644 --- a/tools/toolversion/main_test.go +++ b/tools/toolversion/main_test.go @@ -4,13 +4,22 @@ import ( "bytes" "os" "path/filepath" + "sort" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestCLIGetAndTarget(t *testing.T) { +func writeManifest(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) +} + +func setupCLIEnv(t *testing.T) string { + t.Helper() dir := t.TempDir() writeManifest(t, dir, ".tool-versions", `mockery 2.53.0 protoc 29.3 @@ -24,6 +33,22 @@ github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 t.Setenv("CHAINLINK_ROOT", dir) t.Setenv("TOOL_VERSIONS_FILE", filepath.Join(dir, ".tool-versions")) t.Setenv("GO_TOOLS_FILE", filepath.Join(dir, "tools", "go-tools.txt")) + return dir +} + +func runCLI(t *testing.T, args ...string) (string, error) { + t.Helper() + var buf bytes.Buffer + cmd := newRootCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(args) + err := cmd.Execute() + return string(bytes.TrimSpace(buf.Bytes())), err +} + +func TestCLIGetAndTarget(t *testing.T) { + setupCLIEnv(t) tests := []struct { args []string @@ -37,19 +62,64 @@ github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { - var buf bytes.Buffer - cmd := newRootCmd() - cmd.SetOut(&buf) - cmd.SetErr(&buf) - cmd.SetArgs(tt.args) - require.NoError(t, cmd.Execute(), "args %v", tt.args) - assert.Equal(t, tt.want, string(bytes.TrimSpace(buf.Bytes())), "args %v", tt.args) + got, err := runCLI(t, tt.args...) + require.NoError(t, err, "args %v", tt.args) + assert.Equal(t, tt.want, got, "args %v", tt.args) }) } } -func writeManifest(t *testing.T, dir, name, content string) { - t.Helper() - require.NoError(t, os.MkdirAll(dir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) +func TestCLIUnknownKey(t *testing.T) { + setupCLIEnv(t) + + for _, args := range [][]string{ + {"get", "no-such-tool"}, + {"ref", "no-such-tool"}, + {"target", "no-such-tool"}, + } { + _, err := runCLI(t, args...) + require.Error(t, err, "args %v should fail for unknown key", args) + } +} + +func TestCLIList(t *testing.T) { + setupCLIEnv(t) + + got, err := runCLI(t, "list") + require.NoError(t, err) + + lines := strings.Split(got, "\n") + require.GreaterOrEqual(t, len(lines), 6) + // .tool-versions entries come first + assert.Contains(t, lines[0], "mockery") + // go-tools.txt entries follow + assert.Contains(t, got, "github.com/jmank88/gomods") + assert.Contains(t, got, "github.com/smartcontractkit/gencodec") +} + +func TestCLIModules(t *testing.T) { + setupCLIEnv(t) + + got, err := runCLI(t, "modules") + require.NoError(t, err) + + lines := strings.Split(got, "\n") + // Must include the modulemap entry and go-tools entries + assert.Contains(t, lines, "github.com/vektra/mockery/v2") + assert.Contains(t, lines, "github.com/jmank88/gomods") + assert.Contains(t, lines, "github.com/smartcontractkit/gencodec") + // Must be sorted + assert.True(t, sort.StringsAreSorted(lines), "modules output must be sorted; got %v", lines) +} + +func TestCLIMakeVars(t *testing.T) { + setupCLIEnv(t) + + got, err := runCLI(t, "make-vars") + require.NoError(t, err) + + assert.Contains(t, got, "GOLANGCI_LINT_VERSION=v2.12.2") + // protoc uses raw get (no v-prefix) + assert.Contains(t, got, "PROTOC_VERSION=29.3") + assert.NotContains(t, got, "PROTOC_VERSION=v") } From 6fbac2f2ccf5cd01621cc705d20df2720bbc48f0 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 11 Jun 2026 14:01:07 -0400 Subject: [PATCH 6/6] cleanup --- tools/toolversion/internal/paths/paths.go | 4 ++ .../toolversion/internal/paths/paths_test.go | 5 +- tools/toolversion/main.go | 64 ++++++++++--------- tools/toolversion/main_test.go | 51 ++++++++++----- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/tools/toolversion/internal/paths/paths.go b/tools/toolversion/internal/paths/paths.go index a15818ce5f7..7a347c84317 100644 --- a/tools/toolversion/internal/paths/paths.go +++ b/tools/toolversion/internal/paths/paths.go @@ -52,6 +52,10 @@ func findRepoRoot() (string, error) { if err != nil { return "", err } + return findRepoRootFrom(dir) +} + +func findRepoRootFrom(dir string) (string, error) { for { if fileExists(filepath.Join(dir, "go.mod")) && fileExists(filepath.Join(dir, ".tool-versions")) { return dir, nil diff --git a/tools/toolversion/internal/paths/paths_test.go b/tools/toolversion/internal/paths/paths_test.go index d4f08ab36c9..cfa8710390e 100644 --- a/tools/toolversion/internal/paths/paths_test.go +++ b/tools/toolversion/internal/paths/paths_test.go @@ -9,16 +9,15 @@ import ( ) func TestFindRepoRootFromSubdir(t *testing.T) { + t.Parallel() root := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module test\n\ngo 1.26.4\n"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(root, ".tool-versions"), []byte("golang 1.26.4\n"), 0o600)) sub := filepath.Join(root, "integration-tests") require.NoError(t, os.Mkdir(sub, 0o755)) - require.NoError(t, os.Chdir(sub)) - t.Cleanup(func() { _ = os.Chdir(root) }) - got, err := findRepoRoot() + got, err := findRepoRootFrom(sub) require.NoError(t, err) wantRoot, err := filepath.EvalSymlinks(root) diff --git a/tools/toolversion/main.go b/tools/toolversion/main.go index 29834e26254..14321bc734e 100644 --- a/tools/toolversion/main.go +++ b/tools/toolversion/main.go @@ -21,21 +21,26 @@ func main() { } } +type loaderFn func() (*resolve.Resolver, paths.Config, error) + func newRootCmd() *cobra.Command { + return newRootCmdWithLoader(loadResolver) +} + +func newRootCmdWithLoader(load loaderFn) *cobra.Command { root := &cobra.Command{ Use: "toolversion", Short: "Read dev-tool versions from .tool-versions and tools/go-tools.txt", } - root.AddCommand( - cmdGet(), - cmdRef(), - cmdTarget(), - cmdGoInstall(), - cmdList(), - cmdModules(), - cmdCheck(), - cmdMakeVars(), + cmdGet(load), + cmdRef(load), + cmdTarget(load), + cmdGoInstall(load), + cmdList(load), + cmdModules(load), + cmdCheck(load), + cmdMakeVars(load), ) return root } @@ -52,13 +57,13 @@ func loadResolver() (*resolve.Resolver, paths.Config, error) { return resolve.New(store), cfg, nil } -func cmdGet() *cobra.Command { +func cmdGet(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "get ", Short: "Print the raw version for a tool", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - r, _, err := loadResolver() + r, _, err := load() if err != nil { return err } @@ -72,13 +77,13 @@ func cmdGet() *cobra.Command { } } -func cmdRef() *cobra.Command { +func cmdRef(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "ref ", Short: "Print a consumer-ready version reference (v-prefix for semver)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - r, _, err := loadResolver() + r, _, err := load() if err != nil { return err } @@ -92,13 +97,13 @@ func cmdRef() *cobra.Command { } } -func cmdTarget() *cobra.Command { +func cmdTarget(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "target ", Short: "Print the go install argument: module@version", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - r, _, err := loadResolver() + r, _, err := load() if err != nil { return err } @@ -112,13 +117,13 @@ func cmdTarget() *cobra.Command { } } -func cmdGoInstall() *cobra.Command { +func cmdGoInstall(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "go-install ", Short: "Run go install for the tool at the pinned version", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - r, _, err := loadResolver() + r, _, err := load() if err != nil { return err } @@ -135,12 +140,12 @@ func cmdGoInstall() *cobra.Command { } } -func cmdList() *cobra.Command { +func cmdList(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "list", Short: "Print all name/version pairs from both manifests", RunE: func(cmd *cobra.Command, args []string) error { - r, _, err := loadResolver() + r, _, err := load() if err != nil { return err } @@ -153,12 +158,12 @@ func cmdList() *cobra.Command { } } -func cmdModules() *cobra.Command { +func cmdModules(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "modules", Short: "Print all managed go module import paths", RunE: func(cmd *cobra.Command, args []string) error { - r, _, err := loadResolver() + r, _, err := load() if err != nil { return err } @@ -170,12 +175,12 @@ func cmdModules() *cobra.Command { } } -func cmdCheck() *cobra.Command { +func cmdCheck(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "check", Short: "Fail if version pins drift from the manifests", RunE: func(cmd *cobra.Command, args []string) error { - r, cfg, err := loadResolver() + r, cfg, err := load() if err != nil { return err } @@ -188,12 +193,12 @@ func cmdCheck() *cobra.Command { } } -func cmdMakeVars() *cobra.Command { +func cmdMakeVars(load loaderFn) *cobra.Command { return &cobra.Command{ Use: "make-vars", Short: "Print Makefile variable assignments for common tool versions", RunE: func(cmd *cobra.Command, args []string) error { - r, _, err := loadResolver() + r, _, err := load() if err != nil { return err } @@ -207,13 +212,14 @@ func cmdMakeVars() *cobra.Command { var b strings.Builder for _, v := range vars { var val string + var verr error if v.key == "protoc" { - val, err = r.Get(v.key) + val, verr = r.Get(v.key) } else { - val, err = r.Ref(v.key) + val, verr = r.Ref(v.key) } - if err != nil { - return err + if verr != nil { + return verr } fmt.Fprintf(&b, "%s=%s\n", v.name, val) } diff --git a/tools/toolversion/main_test.go b/tools/toolversion/main_test.go index 2ff372039a0..7487c33b721 100644 --- a/tools/toolversion/main_test.go +++ b/tools/toolversion/main_test.go @@ -10,6 +10,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/manifest" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/paths" + "github.com/smartcontractkit/chainlink/v2/tools/toolversion/internal/resolve" ) func writeManifest(t *testing.T, dir, name, content string) { @@ -18,7 +22,7 @@ func writeManifest(t *testing.T, dir, name, content string) { require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) } -func setupCLIEnv(t *testing.T) string { +func makeTestLoader(t *testing.T) loaderFn { t.Helper() dir := t.TempDir() writeManifest(t, dir, ".tool-versions", `mockery 2.53.0 @@ -30,16 +34,23 @@ golang 1.26.4 writeManifest(t, filepath.Join(dir, "tools"), "go-tools.txt", `github.com/jmank88/gomods 0.1.7 github.com/smartcontractkit/gencodec 42dc7da8c2874db550e91c656f98d05fca3c2f98 `) - t.Setenv("CHAINLINK_ROOT", dir) - t.Setenv("TOOL_VERSIONS_FILE", filepath.Join(dir, ".tool-versions")) - t.Setenv("GO_TOOLS_FILE", filepath.Join(dir, "tools", "go-tools.txt")) - return dir + cfg := paths.Config{ + Root: dir, + ToolVersionsFile: filepath.Join(dir, ".tool-versions"), + GoToolsFile: filepath.Join(dir, "tools", "go-tools.txt"), + } + store, err := manifest.New(cfg.ToolVersionsFile, cfg.GoToolsFile) + require.NoError(t, err) + r := resolve.New(store) + return func() (*resolve.Resolver, paths.Config, error) { + return r, cfg, nil + } } -func runCLI(t *testing.T, args ...string) (string, error) { +func runCLI(t *testing.T, load loaderFn, args ...string) (string, error) { t.Helper() var buf bytes.Buffer - cmd := newRootCmd() + cmd := newRootCmdWithLoader(load) cmd.SetOut(&buf) cmd.SetErr(&buf) cmd.SetArgs(args) @@ -48,7 +59,8 @@ func runCLI(t *testing.T, args ...string) (string, error) { } func TestCLIGetAndTarget(t *testing.T) { - setupCLIEnv(t) + t.Parallel() + load := makeTestLoader(t) tests := []struct { args []string @@ -62,7 +74,8 @@ func TestCLIGetAndTarget(t *testing.T) { } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { - got, err := runCLI(t, tt.args...) + t.Parallel() + got, err := runCLI(t, load, tt.args...) require.NoError(t, err, "args %v", tt.args) assert.Equal(t, tt.want, got, "args %v", tt.args) }) @@ -70,22 +83,24 @@ func TestCLIGetAndTarget(t *testing.T) { } func TestCLIUnknownKey(t *testing.T) { - setupCLIEnv(t) + t.Parallel() + load := makeTestLoader(t) for _, args := range [][]string{ {"get", "no-such-tool"}, {"ref", "no-such-tool"}, {"target", "no-such-tool"}, } { - _, err := runCLI(t, args...) + _, err := runCLI(t, load, args...) require.Error(t, err, "args %v should fail for unknown key", args) } } func TestCLIList(t *testing.T) { - setupCLIEnv(t) + t.Parallel() + load := makeTestLoader(t) - got, err := runCLI(t, "list") + got, err := runCLI(t, load, "list") require.NoError(t, err) lines := strings.Split(got, "\n") @@ -98,9 +113,10 @@ func TestCLIList(t *testing.T) { } func TestCLIModules(t *testing.T) { - setupCLIEnv(t) + t.Parallel() + load := makeTestLoader(t) - got, err := runCLI(t, "modules") + got, err := runCLI(t, load, "modules") require.NoError(t, err) lines := strings.Split(got, "\n") @@ -113,9 +129,10 @@ func TestCLIModules(t *testing.T) { } func TestCLIMakeVars(t *testing.T) { - setupCLIEnv(t) + t.Parallel() + load := makeTestLoader(t) - got, err := runCLI(t, "make-vars") + got, err := runCLI(t, load, "make-vars") require.NoError(t, err) assert.Contains(t, got, "GOLANGCI_LINT_VERSION=v2.12.2")