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..8aa1f25 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,35 @@ 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. **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 +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..f3c473c 100644 --- a/common.mk +++ b/common.mk @@ -317,6 +317,28 @@ 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 +# 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 + @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..b3b1e7f --- /dev/null +++ b/scripts/check-go-version.sh @@ -0,0 +1,152 @@ +#!/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 — 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 +# 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}" + +# 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=() +files=() + +record() { + labels+=("$1") + versions+=("$2") + files+=("$3") +} + +# 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 + 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 + 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' "$candidate" | sed 's/^/ goVersion = /' >&2 + exit 1 + fi + 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 + +# 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. +# +# 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 = "" + for (i = 2; i <= NF; i++) { + if ($i ~ /^--/) continue + image = $i + break + } + if (image ~ /^golang:/) { + sub(/^golang:/, "", image) + sub(/@.*/, "", image) + print image + } + }' "$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: $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 $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: 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 +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 any 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..c9516d9 --- /dev/null +++ b/scripts/test-check-go-version.sh @@ -0,0 +1,182 @@ +#!/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 -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; } +# 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 "pin 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 +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" + +# --- 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" + +echo "All tests passed"