From 15bee3389e69685fe26b40ca39153f4c602d5bf4 Mon Sep 17 00:00:00 2001 From: alexander <259883306+AlexHardAtWork@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:47:16 +0000 Subject: [PATCH 1/5] feat: add check-go-version and report unavailable goVersion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev shell resolves goVersion from the locked go-overlay, so a freshly released Go patch failed with `attribute '1.27.1' missing` at shell-evaluation time, taking down every workflow before any job ran. It now names the available versions and the fix. check-go-version asserts go.mod, flake.nix and the Dockerfile agree, and requires a patch-level golang tag so Renovate can group it with the other pins. test-actions.yml now triggers on what this repo ships — the composite actions, common.mk, the scripts and the flake — not only on *.sh, and exercises setup-nix so a pin bump cannot reach consumers untested. --- .github/workflows/test-actions.yml | 58 ++++++++++++++-- README.md | 25 +++++++ common.mk | 20 ++++++ flake.nix | 20 +++++- scripts/check-go-version.sh | 84 +++++++++++++++++++++++ scripts/test-check-go-version.sh | 106 +++++++++++++++++++++++++++++ 6 files changed, 308 insertions(+), 5 deletions(-) create mode 100755 scripts/check-go-version.sh create mode 100755 scripts/test-check-go-version.sh diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 023a2fe..1153004 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -1,17 +1,30 @@ name: Test Actions +# Consuming repositories pin this repo by SHA, so anything shipped from here +# reaches their CI unchanged. The triggers therefore cover what is shipped — +# the composite actions and their pins, common.mk, the scripts and the flake — +# not just the shell scripts. A pin bump in an action.yml used to change what +# every consumer runs without a single job executing here. on: pull_request: paths: - - '.github/actions/**/*.sh' - - 'scripts/*.sh' + - '.github/actions/**' + - '.github/workflows/test-actions.yml' - '.github/workflows/update-action-pins.yml' + - 'common.mk' + - 'flake.lock' + - 'flake.nix' + - 'scripts/**' push: branches: [main] paths: - - '.github/actions/**/*.sh' - - 'scripts/*.sh' + - '.github/actions/**' + - '.github/workflows/test-actions.yml' - '.github/workflows/update-action-pins.yml' + - 'common.mk' + - 'flake.lock' + - 'flake.nix' + - 'scripts/**' permissions: contents: read @@ -35,3 +48,40 @@ jobs: run: .github/actions/get-go-version/test-extract-go-version.sh - name: Test the update-action-pins check run: scripts/test-update-action-pins.sh + - name: Test check-go-version.sh + run: scripts/test-check-go-version.sh + + test-setup-nix: + needs: shellcheck + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up nix with the shared Cachix cache + uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachix-signing-key: ${{ secrets.CACHIX_SIGNING_KEY }} + - name: Nix is usable + run: nix --version + # Builds the shell mkShell produces, so a broken flake.nix or a pin bump + # that changes what the installer delivers fails here rather than in a + # consuming repository. + - name: The dev shell evaluates and builds + run: nix develop --command true + # The goVersion guard only runs on the error path, so it needs its own + # test: a version the locked overlay does not carry must produce the + # readable message, not `attribute '...' missing`. + - name: A missing goVersion reports what to do + run: | + if err="$(nix eval --impure .#lib.mkShell \ + --apply 'f: (f { system = builtins.currentSystem; goVersion = "0.0.0"; }).drvPath' 2>&1)"; then + echo "::error::expected evaluation to fail for an unavailable goVersion" + exit 1 + fi + if ! grep -q 'is not in the locked go-overlay' <<<"$err"; then + echo "::error::goVersion guard did not produce the expected message" + echo "$err" + exit 1 + fi + echo "guard reported:" + grep -A4 'is not in the locked go-overlay' <<<"$err" || true diff --git a/README.md b/README.md index ad6884c..bb9cacc 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The included `common.mk` provides: | `setup-local-cluster` | Create a Kind cluster for local development | | `repo-settings` | Reconcile GitHub repository settings | | `envtest-binaries-sideload` | Populate the envtest cache from upstream K8s/etcd releases | +| `check-go-version` | Assert every Go version pin in the repository agrees | ### Formatting and linting @@ -174,6 +175,30 @@ it. Linux downloads prebuilt binaries; macOS builds `kube-apiserver` from source when controller-tools has no archive for the requested version, which also needs `go` on `PATH` — supplied by `goVersion`, not by the default dev shell. +### Go version consistency + +A Go project pins its version in up to three places, and a bump that updates only +some of them fails in a workflow that does not mention the file that is wrong: + +| File | Pin | What it drives | +| --- | --- | --- | +| `go.mod` | `go ` | the language version | +| `flake.nix` | `goVersion = "";` | the dev shell and CI | +| `Dockerfile` | `FROM ... golang:` | the build image | + +`check-go-version` asserts they agree, skipping any file the repository does not +have — a library without a `Dockerfile` still passes. Override `GO_MOD`, +`FLAKE_NIX` or `DOCKERFILE` for a non-default layout. + +The Docker tag must be **patch-level** (`1.27.0`, not `1.27`). A minor-level tag +never compares equal to the other two, and Renovate cannot fold it into the same +update group, so the image silently stays behind while everything else moves. + +Related: the dev shell resolves `goVersion` from `go-overlay`, which `flake.lock` +pins. A Go patch released after that lock was written does not exist in the shell +yet; the shell says so and names the fix (`nix flake update go-overlay`) rather +than failing with a missing attribute. + ### Repository settings Run `make repo-settings` to reconcile your GitHub repository with the organization's standard configuration. This target is idempotent and requires the `gh` CLI to be authenticated. diff --git a/common.mk b/common.mk index 35ee683..159ec2e 100644 --- a/common.mk +++ b/common.mk @@ -317,6 +317,26 @@ envtest-binaries-sideload: $(SETUP_ENVTEST) $(ENVTEST_SIDELOAD) ## Populate the @SETUP_ENVTEST=$(SETUP_ENVTEST) BIN_DIR=$(LOCALBIN) YQ=$(YQ) \ bash $(ENVTEST_SIDELOAD) $(ENVTEST_K8S_VERSION) +# Cached rather than piped to bash like repo-settings: this runs on the lint +# path, so it must not reach the network on every invocation. +CHECK_GO_VERSION := $(LOCALBIN)/check-go-version-$(subst /,~,$(DEV_KIT_VERSION)).sh +$(CHECK_GO_VERSION): | $(LOCALBIN) + @curl --fail -sSL \ + "https://raw.githubusercontent.com/opendefensecloud/dev-kit/$(DEV_KIT_VERSION)/scripts/check-go-version.sh" \ + -o $@.download + @mv $@.download $@ + +# Overridable so a repository with a non-default layout can point at its own +# files; an absent file is skipped rather than failing. +GO_MOD ?= $(BUILD_PATH)/go.mod +FLAKE_NIX ?= $(BUILD_PATH)/flake.nix +DOCKERFILE ?= $(BUILD_PATH)/Dockerfile + +.PHONY: check-go-version +check-go-version: $(CHECK_GO_VERSION) ## Assert go.mod, flake.nix and the Dockerfile pin the same Go version + @GO_MOD='$(GO_MOD)' FLAKE_NIX='$(FLAKE_NIX)' DOCKERFILE='$(DOCKERFILE)' \ + bash $(CHECK_GO_VERSION) + # Install local tools TOOL_LOCK := $(BUILD_PATH)/tools.lock diff --git a/flake.nix b/flake.nix index 3d609dd..da2c5d0 100644 --- a/flake.nix +++ b/flake.nix @@ -100,7 +100,25 @@ '' + shellHook; packages = defaultPkgs ++ packages ++ lib.optionals (goVersion != null) [ - pkgs.go-bin.versions.${goVersion} + # go-overlay only carries versions it has a manifest for, and the + # overlay is pinned by flake.lock — so a freshly released Go patch + # does not exist here until the lock is refreshed. Without this the + # failure is `attribute '1.27.1' missing`, which points at the + # overlay's internals rather than at the fix, and it takes down every + # workflow at shell-evaluation time before any job runs. + (pkgs.go-bin.versions.${goVersion} or (throw '' + dev-kit: Go ${goVersion} is not in the locked go-overlay. + Available ${lib.versions.majorMinor goVersion}.x: ${ + let + inMinor = builtins.filter + (v: lib.hasPrefix (lib.versions.majorMinor goVersion + ".") v) + (builtins.attrNames pkgs.go-bin.versions); + in + if inMinor == [] then "none" else lib.concatStringsSep ", " inMinor + } + Either refresh the overlay: nix flake update go-overlay + or set goVersion to a version listed above. + '')) pkgs.gotools ]; }; diff --git a/scripts/check-go-version.sh b/scripts/check-go-version.sh new file mode 100755 index 0000000..4beebce --- /dev/null +++ b/scripts/check-go-version.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Assert that every place this repository pins the Go version agrees. +# +# Sources, each optional — a repository that lacks one is simply not checked: +# +# go.mod `go ` the language version +# flake.nix `goVersion = "";` the dev shell and CI toolchain +# Dockerfile `FROM ... golang:` the build image +# +# Disagreement between them is the failure mode behind every Go bump: Renovate +# updates some of the pins, and CI then fails somewhere else entirely with an +# error that does not mention the file that is actually wrong. +# +# The Docker tag must be patch-level (1.27.0, not 1.27). A minor-level tag never +# compares equal to the other two, and Renovate cannot fold it into the same +# update group, so the image silently stays behind. +set -euo pipefail + +GO_MOD="${GO_MOD:-go.mod}" +FLAKE_NIX="${FLAKE_NIX:-flake.nix}" +DOCKERFILE="${DOCKERFILE:-Dockerfile}" + +labels=() +versions=() +files=() + +record() { + labels+=("$1") + versions+=("$2") + files+=("$3") +} + +# go.mod — `go 1.27.0`, optionally with a toolchain line we ignore. +if [ -f "$GO_MOD" ]; then + v="$(sed -nE 's/^go[[:space:]]+([0-9]+\.[0-9]+(\.[0-9]+)?)[[:space:]]*$/\1/p' "$GO_MOD")" + [ -n "$v" ] && record "go.mod" "$v" "$GO_MOD" +fi + +# flake.nix — same expression the get-go-version action reads. +if [ -f "$FLAKE_NIX" ]; then + v="$(sed -nE 's/^[[:space:]]*goVersion[[:space:]]*=[[:space:]]*"([0-9]+\.[0-9]+\.[0-9]+)";[[:space:]]*$/\1/p' "$FLAKE_NIX")" + if [ "$(printf '%s\n' "$v" | sed '/^$/d' | wc -l)" -gt 1 ]; then + echo "error: expected at most one goVersion assignment in $FLAKE_NIX, found:" >&2 + printf '%s\n' "$v" | sed 's/^/ /' >&2 + exit 1 + fi + [ -n "$v" ] && record "flake.nix" "$v" "$FLAKE_NIX" +fi + +# Dockerfile — every `golang:` builder stage, digest suffix stripped. +if [ -f "$DOCKERFILE" ]; then + mapfile -t tags < <(sed -nE 's/^FROM([[:space:]]+--[^[:space:]]+)*[[:space:]]+golang:([^@[:space:]]+).*/\2/p' "$DOCKERFILE" | sort -u) + if [ "${#tags[@]}" -gt 1 ]; then + echo "error: $DOCKERFILE pins more than one golang tag: ${tags[*]}" >&2 + exit 1 + fi + if [ "${#tags[@]}" -eq 1 ]; then + record "Dockerfile" "${tags[0]}" "$DOCKERFILE" + if ! [[ "${tags[0]}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: $DOCKERFILE pins golang:${tags[0]} — a patch-level tag is required." >&2 + echo " A minor-level tag never matches go.mod or flake.nix, and Renovate" >&2 + echo " cannot group it with the other Go pins, so the image stays behind." >&2 + exit 1 + fi + fi +fi + +if [ "${#versions[@]}" -eq 0 ]; then + echo "note: no Go version pins found in $GO_MOD, $FLAKE_NIX or $DOCKERFILE — nothing to check" >&2 + exit 0 +fi + +for i in "${!versions[@]}"; do + if [ "${versions[$i]}" != "${versions[0]}" ]; then + echo "error: Go version pins disagree." >&2 + for j in "${!versions[@]}"; do + printf ' %-10s %s (%s)\n' "${labels[$j]}" "${versions[$j]}" "${files[$j]}" >&2 + done + echo " All of them must name the same version." >&2 + exit 1 + fi +done + +echo "Go version pins agree: ${versions[0]} (${labels[*]})" diff --git a/scripts/test-check-go-version.sh b/scripts/test-check-go-version.sh new file mode 100755 index 0000000..48c7252 --- /dev/null +++ b/scripts/test-check-go-version.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +TESTDIR="$(mktemp -d)" +trap 'rm -rf "$TESTDIR"' EXIT + +cd "$TESTDIR" + +run_test() { + local name="$1" + local expected_rc="$2" + local expected_output="$3" + + echo "=== $name ===" + local output rc + set +e + output="$("$ROOT/check-go-version.sh" 2>&1)" + rc=$? + set -e + if [ -n "$output" ]; then + echo "$output" + fi + if [ "$rc" -ne "$expected_rc" ]; then + echo "FAIL: expected exit code $expected_rc, got $rc" + exit 1 + fi + if echo "$output" | grep -q "$expected_output"; then + echo "PASS" + else + echo "FAIL: expected output to contain '$expected_output'" + exit 1 + fi + echo +} + +reset() { + rm -f go.mod flake.nix Dockerfile +} + +write_go_mod() { printf 'module example.com/x\n\ngo %s\n' "$1" > go.mod; } +write_flake() { printf '{\n outputs = {\n devShells.default = mkShell {\n goVersion = "%s";\n };\n };\n}\n' "$1" > flake.nix; } +# The literal `--platform=$BUILDPLATFORM` matters: it is what Solar and ARC +# write, and the extraction has to skip the flag to reach the image tag. +write_dockerfile() { + cat > Dockerfile <<'EOF' +# Build the manager binary +FROM --platform=$BUILDPLATFORM golang:__TAG__ AS builder +RUN make build +EOF + sed -i "s|__TAG__|$1${2:-}|" Dockerfile +} + +# --- all three agree ------------------------------------------------------- +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +write_dockerfile 1.27.0 "@sha256:0d1d3a794be25f809dd2cb3160d8c73276c4056a9f8242a138e908ddeee7b6b6" +run_test "all three pins agree" 0 "Go version pins agree: 1.27.0" + +# --- go.mod disagrees with flake.nix --------------------------------------- +reset +write_go_mod 1.27.1 +write_flake 1.27.0 +run_test "go.mod and flake.nix disagree" 1 "Go version pins disagree" + +# --- the Dockerfile is the one left behind --------------------------------- +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +write_dockerfile 1.26.6 "@sha256:0d1d3a794be25f809dd2cb3160d8c73276c4056a9f8242a138e908ddeee7b6b6" +run_test "Dockerfile left behind" 1 "Dockerfile 1.26.6" + +# --- minor-level docker tag: the ARC #483 case ----------------------------- +reset +write_go_mod 1.26.6 +write_flake 1.26.6 +write_dockerfile 1.26 "@sha256:0d1d3a794be25f809dd2cb3160d8c73276c4056a9f8242a138e908ddeee7b6b6" +run_test "minor-level golang tag is rejected" 1 "a patch-level tag is required" + +# --- library repository: no Dockerfile ------------------------------------- +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +run_test "library repo without a Dockerfile" 0 "Go version pins agree: 1.27.0" + +# --- go.mod only ----------------------------------------------------------- +reset +write_go_mod 1.27.0 +run_test "go.mod alone" 0 "Go version pins agree: 1.27.0" + +# --- two golang tags in one Dockerfile ------------------------------------- +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +cat > Dockerfile <<'EOF' +FROM --platform=$BUILDPLATFORM golang:1.27.0 AS builder +FROM golang:1.26.6 AS tools +EOF +run_test "conflicting golang tags in one Dockerfile" 1 "pins more than one golang tag" + +# --- nothing to check ------------------------------------------------------ +reset +run_test "no Go pins at all" 0 "nothing to check" + +echo "All tests passed" From ce1bd3d8a6309344596303c22b10655bd4c69b22 Mon Sep 17 00:00:00 2001 From: alexander <259883306+AlexHardAtWork@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:21:34 +0000 Subject: [PATCH 2/5] fix: reject unparseable Go pins instead of skipping them A file that is absent is skipped on purpose; one that exists but whose pin cannot be read was skipped too, which hid the drift the check exists to find. Dockerfile instructions are case-insensitive and may be indented, so `from golang:...` and an indented FROM were never seen. --- scripts/check-go-version.sh | 54 +++++++++++++++++++++++++++----- scripts/test-check-go-version.sh | 30 ++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/scripts/check-go-version.sh b/scripts/check-go-version.sh index 4beebce..664800b 100755 --- a/scripts/check-go-version.sh +++ b/scripts/check-go-version.sh @@ -30,26 +30,64 @@ record() { files+=("$3") } -# go.mod — `go 1.27.0`, optionally with a toolchain line we ignore. +# A file that does not exist is skipped on purpose — a library has no +# Dockerfile. A file that *does* exist but whose pin cannot be parsed is an +# error, never a skip: silently ignoring it is the same silent drift this script +# exists to catch. + +# go.mod — `go 1.27.0`. The `toolchain` directive is a different line and is +# deliberately not read. if [ -f "$GO_MOD" ]; then - v="$(sed -nE 's/^go[[:space:]]+([0-9]+\.[0-9]+(\.[0-9]+)?)[[:space:]]*$/\1/p' "$GO_MOD")" - [ -n "$v" ] && record "go.mod" "$v" "$GO_MOD" + candidate="$(sed -nE 's/^[[:space:]]*go[[:space:]]+([^[:space:]].*)$/\1/p' "$GO_MOD")" + if [ -n "$candidate" ]; then + v="$(printf '%s\n' "$candidate" | sed -nE 's/^([0-9]+\.[0-9]+(\.[0-9]+)?)[[:space:]]*$/\1/p')" + if [ -z "$v" ]; then + echo "error: cannot read a Go version from the go directive in $GO_MOD:" >&2 + printf '%s\n' "$candidate" | sed 's/^/ go /' >&2 + exit 1 + fi + record "go.mod" "$v" "$GO_MOD" + fi fi # flake.nix — same expression the get-go-version action reads. if [ -f "$FLAKE_NIX" ]; then - v="$(sed -nE 's/^[[:space:]]*goVersion[[:space:]]*=[[:space:]]*"([0-9]+\.[0-9]+\.[0-9]+)";[[:space:]]*$/\1/p' "$FLAKE_NIX")" - if [ "$(printf '%s\n' "$v" | sed '/^$/d' | wc -l)" -gt 1 ]; then + candidate="$(sed -nE 's/^[[:space:]]*goVersion[[:space:]]*=[[:space:]]*(.*)$/\1/p' "$FLAKE_NIX")" + if [ "$(printf '%s\n' "$candidate" | sed '/^$/d' | wc -l)" -gt 1 ]; then echo "error: expected at most one goVersion assignment in $FLAKE_NIX, found:" >&2 - printf '%s\n' "$v" | sed 's/^/ /' >&2 + printf '%s\n' "$candidate" | sed 's/^/ goVersion = /' >&2 exit 1 fi - [ -n "$v" ] && record "flake.nix" "$v" "$FLAKE_NIX" + if [ -n "$candidate" ]; then + v="$(printf '%s\n' "$candidate" | sed -nE 's/^"([0-9]+\.[0-9]+\.[0-9]+)";[[:space:]]*$/\1/p')" + if [ -z "$v" ]; then + echo "error: cannot read a Go version from goVersion in $FLAKE_NIX:" >&2 + printf '%s\n' "$candidate" | sed 's/^/ goVersion = /' >&2 + echo " Expected a patch-level version, as in: goVersion = \"1.27.0\";" >&2 + exit 1 + fi + record "flake.nix" "$v" "$FLAKE_NIX" + fi fi # Dockerfile — every `golang:` builder stage, digest suffix stripped. +# Instruction keywords are case-insensitive and leading whitespace is ignored, +# so `from golang:…` and an indented `FROM` are valid and must be seen. if [ -f "$DOCKERFILE" ]; then - mapfile -t tags < <(sed -nE 's/^FROM([[:space:]]+--[^[:space:]]+)*[[:space:]]+golang:([^@[:space:]]+).*/\2/p' "$DOCKERFILE" | sort -u) + mapfile -t tags < <(awk ' + toupper($1) == "FROM" { + image = "" + for (i = 2; i <= NF; i++) { + if ($i ~ /^--/) continue + image = $i + break + } + if (image ~ /^golang:/) { + sub(/^golang:/, "", image) + sub(/@.*/, "", image) + print image + } + }' "$DOCKERFILE" | sort -u) if [ "${#tags[@]}" -gt 1 ]; then echo "error: $DOCKERFILE pins more than one golang tag: ${tags[*]}" >&2 exit 1 diff --git a/scripts/test-check-go-version.sh b/scripts/test-check-go-version.sh index 48c7252..264b47c 100755 --- a/scripts/test-check-go-version.sh +++ b/scripts/test-check-go-version.sh @@ -99,6 +99,36 @@ FROM golang:1.26.6 AS tools EOF run_test "conflicting golang tags in one Dockerfile" 1 "pins more than one golang tag" +# --- lowercase FROM: valid Dockerfile syntax ------------------------------- +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +cat > Dockerfile <<'EOF' +from golang:1.26.6 AS builder +EOF +run_test "lowercase from is not skipped" 1 "Dockerfile 1.26.6" + +# --- indented FROM: leading whitespace is ignored by Docker ---------------- +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +cat > Dockerfile <<'EOF' + FROM golang:1.26.6 AS builder +EOF +run_test "indented FROM is not skipped" 1 "Dockerfile 1.26.6" + +# --- a go directive that cannot be parsed is an error, not a skip ---------- +reset +write_flake 1.27.0 +printf 'module example.com/x\n\ngo 1.27.0 // pinned\n' > go.mod +run_test "unparseable go directive" 1 "cannot read a Go version from the go directive" + +# --- a goVersion that is not patch-level is an error, not a skip ---------- +reset +write_go_mod 1.27.0 +printf '{\n goVersion = "1.27";\n}\n' > flake.nix +run_test "non patch-level goVersion" 1 "cannot read a Go version from goVersion" + # --- nothing to check ------------------------------------------------------ reset run_test "no Go pins at all" 0 "nothing to check" From 0b84e7f2b8f6450ee1d2ad15f3a8456afbfcfeab Mon Sep 17 00:00:00 2001 From: alexander <259883306+AlexHardAtWork@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:36:36 +0000 Subject: [PATCH 3/5] fix: compare golang tag variants on the version part An official tag may carry an OS suffix, so golang:1.25.7-alpine3.22 was rejected as "not patch-level" and, had it passed, would never have compared equal to go.mod. Parse the version out of the tag and compare on that; a non-version tag now says so instead of blaming precision. --- scripts/check-go-version.sh | 30 ++++++++++++++++++++++-------- scripts/test-check-go-version.sh | 31 ++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/scripts/check-go-version.sh b/scripts/check-go-version.sh index 664800b..ebc1b41 100755 --- a/scripts/check-go-version.sh +++ b/scripts/check-go-version.sh @@ -88,18 +88,32 @@ if [ -f "$DOCKERFILE" ]; then print image } }' "$DOCKERFILE" | sort -u) - if [ "${#tags[@]}" -gt 1 ]; then - echo "error: $DOCKERFILE pins more than one golang tag: ${tags[*]}" >&2 - exit 1 - fi - if [ "${#tags[@]}" -eq 1 ]; then - record "Dockerfile" "${tags[0]}" "$DOCKERFILE" - if ! [[ "${tags[0]}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "error: $DOCKERFILE pins golang:${tags[0]} — a patch-level tag is required." >&2 + # Official golang tags carry an optional OS/variant suffix — 1.27.1, + # 1.27.1-alpine, 1.27.1-alpine3.22, 1.27-bookworm. Compare on the version + # part only; which base image a stage wants is not this check's business. + docker_version="" + for tag in "${tags[@]}"; do + if [[ "$tag" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-.+)?$ ]]; then + v="${BASH_REMATCH[1]}" + elif [[ "$tag" =~ ^[0-9]+\.[0-9]+(-.+)?$ ]]; then + echo "error: $DOCKERFILE pins golang:$tag — a patch-level tag is required." >&2 echo " A minor-level tag never matches go.mod or flake.nix, and Renovate" >&2 echo " cannot group it with the other Go pins, so the image stays behind." >&2 exit 1 + else + echo "error: cannot read a Go version from the golang tag in $DOCKERFILE: golang:$tag" >&2 + echo " Expected a patch-level version with an optional variant suffix," >&2 + echo " as in: golang:1.27.1 or golang:1.27.1-alpine3.22" >&2 + exit 1 + fi + if [ -n "$docker_version" ] && [ "$v" != "$docker_version" ]; then + echo "error: $DOCKERFILE pins more than one Go version: ${tags[*]}" >&2 + exit 1 fi + docker_version="$v" + done + if [ -n "$docker_version" ]; then + record "Dockerfile" "$docker_version" "$DOCKERFILE" fi fi diff --git a/scripts/test-check-go-version.sh b/scripts/test-check-go-version.sh index 264b47c..10365ea 100755 --- a/scripts/test-check-go-version.sh +++ b/scripts/test-check-go-version.sh @@ -97,7 +97,36 @@ cat > Dockerfile <<'EOF' FROM --platform=$BUILDPLATFORM golang:1.27.0 AS builder FROM golang:1.26.6 AS tools EOF -run_test "conflicting golang tags in one Dockerfile" 1 "pins more than one golang tag" +run_test "conflicting golang tags in one Dockerfile" 1 "pins more than one Go version" + +# --- variant tags: the version is what has to agree, not the base image ----- +reset +write_go_mod 1.25.7 +write_flake 1.25.7 +write_dockerfile 1.25.7-alpine3.22 +run_test "variant tag agrees on the version" 0 "Go version pins agree: 1.25.7" + +reset +write_go_mod 1.26.6 +write_flake 1.26.6 +write_dockerfile 1.26-alpine +run_test "minor-level variant tag is rejected" 1 "a patch-level tag is required" + +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +write_dockerfile latest +run_test "non-version tag is rejected" 1 "cannot read a Go version from the golang tag" + +# --- two stages, same version, different base ------------------------------ +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +cat > Dockerfile <<'EOF' +FROM --platform=$BUILDPLATFORM golang:1.27.0 AS builder +FROM golang:1.27.0-alpine3.22 AS tools +EOF +run_test "same version, different variants" 0 "Go version pins agree: 1.27.0" # --- lowercase FROM: valid Dockerfile syntax ------------------------------- reset From 85f37d7f98933975c4d3e44ba1f0c34e492bcc2b Mon Sep 17 00:00:00 2001 From: alexander <259883306+AlexHardAtWork@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:45:17 +0000 Subject: [PATCH 4/5] feat: check every tracked Dockerfile, not just the root one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repository can pin Go in a second Dockerfile — ARC already has examples/ocm/Dockerfile — and a check that reads one path would miss it and would need a list somebody has to remember to update. Discover them instead. --- README.md | 9 ++++-- common.mk | 4 ++- scripts/check-go-version.sh | 48 +++++++++++++++++++++----------- scripts/test-check-go-version.sh | 21 ++++++++++++-- 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bb9cacc..3b99fe5 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,13 @@ some of them fails in a workflow that does not mention the file that is wrong: | `Dockerfile` | `FROM ... golang:` | the build image | `check-go-version` asserts they agree, skipping any file the repository does not -have — a library without a `Dockerfile` still passes. Override `GO_MOD`, -`FLAKE_NIX` or `DOCKERFILE` for a non-default layout. +have — a library without a `Dockerfile` still passes. **Every tracked Dockerfile +is read**, not just the one at the root, so adding one does not mean remembering +to register it. Override `GO_MOD`, `FLAKE_NIX` or `DOCKERFILE` (a space-separated +list) for a non-default layout. + +A file that exists but whose pin cannot be parsed is an error, never a skip — +silently ignoring it is the same drift the check exists to catch. The Docker tag must be **patch-level** (`1.27.0`, not `1.27`). A minor-level tag never compares equal to the other two, and Renovate cannot fold it into the same diff --git a/common.mk b/common.mk index 159ec2e..f3c473c 100644 --- a/common.mk +++ b/common.mk @@ -330,7 +330,9 @@ $(CHECK_GO_VERSION): | $(LOCALBIN) # files; an absent file is skipped rather than failing. GO_MOD ?= $(BUILD_PATH)/go.mod FLAKE_NIX ?= $(BUILD_PATH)/flake.nix -DOCKERFILE ?= $(BUILD_PATH)/Dockerfile +# Empty by default: the script then reads every tracked Dockerfile. Set this to +# a space-separated list to override. +DOCKERFILE ?= .PHONY: check-go-version check-go-version: $(CHECK_GO_VERSION) ## Assert go.mod, flake.nix and the Dockerfile pin the same Go version diff --git a/scripts/check-go-version.sh b/scripts/check-go-version.sh index ebc1b41..7df3e22 100755 --- a/scripts/check-go-version.sh +++ b/scripts/check-go-version.sh @@ -5,7 +5,7 @@ # # go.mod `go ` the language version # flake.nix `goVersion = "";` the dev shell and CI toolchain -# Dockerfile `FROM ... golang:` the build image +# Dockerfile `FROM ... golang:` the build image — every tracked one # # Disagreement between them is the failure mode behind every Go bump: Renovate # updates some of the pins, and CI then fails somewhere else entirely with an @@ -18,7 +18,17 @@ set -euo pipefail GO_MOD="${GO_MOD:-go.mod}" FLAKE_NIX="${FLAKE_NIX:-flake.nix}" -DOCKERFILE="${DOCKERFILE:-Dockerfile}" + +# Every tracked Dockerfile is read, so adding one does not mean remembering to +# add it here too. DOCKERFILE overrides that with a space-separated list. +if [ -n "${DOCKERFILE:-}" ]; then + read -r -a dockerfiles <<< "$DOCKERFILE" +else + mapfile -t dockerfiles < <( + git ls-files -- '*Dockerfile' '*Dockerfile.*' '*.Dockerfile' 2>/dev/null || + find . -name '*Dockerfile*' -not -path './bin/*' -print 2>/dev/null + ) +fi labels=() versions=() @@ -70,10 +80,17 @@ if [ -f "$FLAKE_NIX" ]; then fi fi -# Dockerfile — every `golang:` builder stage, digest suffix stripped. +# Dockerfiles — every `golang:` builder stage, digest suffix stripped. # Instruction keywords are case-insensitive and leading whitespace is ignored, # so `from golang:…` and an indented `FROM` are valid and must be seen. -if [ -f "$DOCKERFILE" ]; then +# +# Official golang tags carry an optional OS/variant suffix — 1.27.1, +# 1.27.1-alpine, 1.27.1-alpine3.22, 1.27-bookworm. Compare on the version part +# only; which base image a stage wants is not this check's business. +docker_version="" +docker_file="" +for df in "${dockerfiles[@]}"; do + [ -f "$df" ] || continue mapfile -t tags < <(awk ' toupper($1) == "FROM" { image = "" @@ -87,38 +104,37 @@ if [ -f "$DOCKERFILE" ]; then sub(/@.*/, "", image) print image } - }' "$DOCKERFILE" | sort -u) - # Official golang tags carry an optional OS/variant suffix — 1.27.1, - # 1.27.1-alpine, 1.27.1-alpine3.22, 1.27-bookworm. Compare on the version - # part only; which base image a stage wants is not this check's business. - docker_version="" + }' "$df" | sort -u) for tag in "${tags[@]}"; do if [[ "$tag" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-.+)?$ ]]; then v="${BASH_REMATCH[1]}" elif [[ "$tag" =~ ^[0-9]+\.[0-9]+(-.+)?$ ]]; then - echo "error: $DOCKERFILE pins golang:$tag — a patch-level tag is required." >&2 + echo "error: $df pins golang:$tag — a patch-level tag is required." >&2 echo " A minor-level tag never matches go.mod or flake.nix, and Renovate" >&2 echo " cannot group it with the other Go pins, so the image stays behind." >&2 exit 1 else - echo "error: cannot read a Go version from the golang tag in $DOCKERFILE: golang:$tag" >&2 + echo "error: cannot read a Go version from the golang tag in $df: golang:$tag" >&2 echo " Expected a patch-level version with an optional variant suffix," >&2 echo " as in: golang:1.27.1 or golang:1.27.1-alpine3.22" >&2 exit 1 fi if [ -n "$docker_version" ] && [ "$v" != "$docker_version" ]; then - echo "error: $DOCKERFILE pins more than one Go version: ${tags[*]}" >&2 + echo "error: Dockerfiles pin more than one Go version:" >&2 + printf ' %-40s %s\n' "$docker_file" "$docker_version" >&2 + printf ' %-40s %s\n' "$df" "$v" >&2 exit 1 fi docker_version="$v" + docker_file="$df" done - if [ -n "$docker_version" ]; then - record "Dockerfile" "$docker_version" "$DOCKERFILE" - fi +done +if [ -n "$docker_version" ]; then + record "Dockerfile" "$docker_version" "$docker_file" fi if [ "${#versions[@]}" -eq 0 ]; then - echo "note: no Go version pins found in $GO_MOD, $FLAKE_NIX or $DOCKERFILE — nothing to check" >&2 + echo "note: no Go version pins found in $GO_MOD, $FLAKE_NIX or any Dockerfile — nothing to check" >&2 exit 0 fi diff --git a/scripts/test-check-go-version.sh b/scripts/test-check-go-version.sh index 10365ea..d554595 100755 --- a/scripts/test-check-go-version.sh +++ b/scripts/test-check-go-version.sh @@ -35,7 +35,7 @@ run_test() { } reset() { - rm -f go.mod flake.nix Dockerfile + rm -rf go.mod flake.nix Dockerfile examples } write_go_mod() { printf 'module example.com/x\n\ngo %s\n' "$1" > go.mod; } @@ -97,7 +97,7 @@ cat > Dockerfile <<'EOF' FROM --platform=$BUILDPLATFORM golang:1.27.0 AS builder FROM golang:1.26.6 AS tools EOF -run_test "conflicting golang tags in one Dockerfile" 1 "pins more than one Go version" +run_test "conflicting golang tags in one Dockerfile" 1 "pin more than one Go version" # --- variant tags: the version is what has to agree, not the base image ----- reset @@ -158,6 +158,23 @@ write_go_mod 1.27.0 printf '{\n goVersion = "1.27";\n}\n' > flake.nix run_test "non patch-level goVersion" 1 "cannot read a Go version from goVersion" +# --- a second Dockerfile is found without being listed --------------------- +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +write_dockerfile 1.27.0 +mkdir -p examples/ocm +printf 'FROM golang:1.26.6 AS tools\n' > examples/ocm/Dockerfile +run_test "second Dockerfile is discovered" 1 "pin more than one Go version" + +reset +write_go_mod 1.27.0 +write_flake 1.27.0 +write_dockerfile 1.27.0 +mkdir -p examples/ocm +printf 'FROM alpine:3\n' > examples/ocm/Dockerfile +run_test "second Dockerfile without a golang base is ignored" 0 "Go version pins agree: 1.27.0" + # --- nothing to check ------------------------------------------------------ reset run_test "no Go pins at all" 0 "nothing to check" From 8a20887d5b30af5ce2eed5119faedd25c7eb6aad Mon Sep 17 00:00:00 2001 From: alexander <259883306+AlexHardAtWork@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:46:12 +0000 Subject: [PATCH 5/5] chore: run the formatter over the Go version check treefmt landed on main while this branch sat unmerged, so its files had never been through shfmt or mdformat. `make fmt` rewrote them in CI and diff-check then failed. Whitespace and table alignment only. --- README.md | 10 +++++----- scripts/check-go-version.sh | 4 ++-- scripts/test-check-go-version.sh | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3b99fe5..8aa1f25 100644 --- a/README.md +++ b/README.md @@ -180,11 +180,11 @@ when controller-tools has no archive for the requested version, which also needs A Go project pins its version in up to three places, and a bump that updates only some of them fails in a workflow that does not mention the file that is wrong: -| File | Pin | What it drives | -| --- | --- | --- | -| `go.mod` | `go ` | the language version | -| `flake.nix` | `goVersion = "";` | the dev shell and CI | -| `Dockerfile` | `FROM ... golang:` | the build image | +| File | Pin | What it drives | +| ------------ | -------------------------- | -------------------- | +| `go.mod` | `go ` | the language version | +| `flake.nix` | `goVersion = "";` | the dev shell and CI | +| `Dockerfile` | `FROM ... golang:` | the build image | `check-go-version` asserts they agree, skipping any file the repository does not have — a library without a `Dockerfile` still passes. **Every tracked Dockerfile diff --git a/scripts/check-go-version.sh b/scripts/check-go-version.sh index 7df3e22..b3b1e7f 100755 --- a/scripts/check-go-version.sh +++ b/scripts/check-go-version.sh @@ -25,8 +25,8 @@ if [ -n "${DOCKERFILE:-}" ]; then read -r -a dockerfiles <<< "$DOCKERFILE" else mapfile -t dockerfiles < <( - git ls-files -- '*Dockerfile' '*Dockerfile.*' '*.Dockerfile' 2>/dev/null || - find . -name '*Dockerfile*' -not -path './bin/*' -print 2>/dev/null + git ls-files -- '*Dockerfile' '*Dockerfile.*' '*.Dockerfile' 2> /dev/null || + find . -name '*Dockerfile*' -not -path './bin/*' -print 2> /dev/null ) fi diff --git a/scripts/test-check-go-version.sh b/scripts/test-check-go-version.sh index d554595..c9516d9 100755 --- a/scripts/test-check-go-version.sh +++ b/scripts/test-check-go-version.sh @@ -38,12 +38,12 @@ reset() { rm -rf go.mod flake.nix Dockerfile examples } -write_go_mod() { printf 'module example.com/x\n\ngo %s\n' "$1" > go.mod; } -write_flake() { printf '{\n outputs = {\n devShells.default = mkShell {\n goVersion = "%s";\n };\n };\n}\n' "$1" > flake.nix; } +write_go_mod() { printf 'module example.com/x\n\ngo %s\n' "$1" > go.mod; } +write_flake() { printf '{\n outputs = {\n devShells.default = mkShell {\n goVersion = "%s";\n };\n };\n}\n' "$1" > flake.nix; } # The literal `--platform=$BUILDPLATFORM` matters: it is what Solar and ARC # write, and the extraction has to skip the flag to reach the image tag. write_dockerfile() { - cat > Dockerfile <<'EOF' + cat > Dockerfile << 'EOF' # Build the manager binary FROM --platform=$BUILDPLATFORM golang:__TAG__ AS builder RUN make build @@ -93,7 +93,7 @@ run_test "go.mod alone" 0 "Go version pins agree: 1.27.0" reset write_go_mod 1.27.0 write_flake 1.27.0 -cat > Dockerfile <<'EOF' +cat > Dockerfile << 'EOF' FROM --platform=$BUILDPLATFORM golang:1.27.0 AS builder FROM golang:1.26.6 AS tools EOF @@ -122,7 +122,7 @@ run_test "non-version tag is rejected" 1 "cannot read a Go version from the gola reset write_go_mod 1.27.0 write_flake 1.27.0 -cat > Dockerfile <<'EOF' +cat > Dockerfile << 'EOF' FROM --platform=$BUILDPLATFORM golang:1.27.0 AS builder FROM golang:1.27.0-alpine3.22 AS tools EOF @@ -132,7 +132,7 @@ run_test "same version, different variants" 0 "Go version pins agree: 1.27.0" reset write_go_mod 1.27.0 write_flake 1.27.0 -cat > Dockerfile <<'EOF' +cat > Dockerfile << 'EOF' from golang:1.26.6 AS builder EOF run_test "lowercase from is not skipped" 1 "Dockerfile 1.26.6" @@ -141,7 +141,7 @@ run_test "lowercase from is not skipped" 1 "Dockerfile 1.26.6" reset write_go_mod 1.27.0 write_flake 1.27.0 -cat > Dockerfile <<'EOF' +cat > Dockerfile << 'EOF' FROM golang:1.26.6 AS builder EOF run_test "indented FROM is not skipped" 1 "Dockerfile 1.26.6"