Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions .github/workflows/test-actions.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <version>` | the language version |
| `flake.nix` | `goVersion = "<version>";` | the dev shell and CI |
| `Dockerfile` | `FROM ... golang:<tag>` | 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.
Expand Down
22 changes: 22 additions & 0 deletions common.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 19 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
];
};
Expand Down
152 changes: 152 additions & 0 deletions scripts/check-go-version.sh
Original file line number Diff line number Diff line change
@@ -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 <version>` the language version
# flake.nix `goVersion = "<version>";` the dev shell and CI toolchain
# Dockerfile `FROM ... golang:<tag>` 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:<tag>` 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[*]})"
Loading
Loading