diff --git a/Dockerfile b/Dockerfile index ec33b34..94b5b57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,9 @@ -# Build stage -# golang 1.25-alpine, 2026-02-28 -FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder - -RUN apk add --no-cache git make gcc musl-dev binutils-gold - -# golangci-lint v2.12.2, 2026-08-07 -RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5 -# goimports v0.42.0 -RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 +# Lint stage - fast feedback on lint issues, before the build starts. +# The linter is invoked directly rather than through `make lint`: that +# target shells out to `docker build -f Dockerfile.lint`, and there is +# no docker daemon inside a docker build. +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint WORKDIR /src COPY go.mod go.sum ./ @@ -15,8 +11,26 @@ RUN go mod download COPY . . -# Run all checks - build fails if any check fails -RUN make check +RUN make fmt-check +RUN golangci-lint run --config .golangci.yml ./... + +# Build stage +# golang 1.25-alpine, 2026-02-28 +FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder + +RUN apk add --no-cache git make gcc musl-dev binutils-gold + +# Force BuildKit to run the lint stage before proceeding +COPY --from=lint /src/go.sum /dev/null + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Run the tests - build fails if any test fails +RUN make test # Build the binary RUN make build diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..5c236de --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,27 @@ +# Lint-only image: used by script/lint. golangci-lint is never run on +# the host — the repo is COPYed into the build context and the linter +# runs as a build step, so a successful build IS a clean lint. This +# also works where the docker daemon is remote and bind mounts are +# impossible. +# +# `golangci-lint config verify` is deliberately NOT run here: it +# fetches its JSON schema over a live, unpinned HTTPS call, which would +# make linting network-dependent and defeat hash-pinning. +# +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps + +WORKDIR /src + +# Dependencies first, so this stage stays cached across lint runs. +COPY go.mod go.sum ./ +RUN go mod download + +# Everything below is invalidated on every run by the +# --no-cache-filter=lint that script/lint passes: caching is explicitly +# waived for linting, and a cached build lints nothing. +FROM deps AS lint + +COPY . . + +RUN golangci-lint run --config .golangci.yml ./... diff --git a/README.md b/README.md index 83f9226..96141f9 100644 --- a/README.md +++ b/README.md @@ -380,14 +380,21 @@ standard: normalized scripts in `script/` are the entrypoints for the development workflow, and the Makefile targets are thin shims that call them. We provide: -- `script/bootstrap` — install all dependencies (go, pinned - golangci-lint and goimports, `go mod download`) +- `script/bootstrap` — install all dependencies (go, pinned goimports, + `go mod download`). It does not install golangci-lint: see + `script/lint` below. - `script/setup` — make a fresh clone ready for development: bootstrap plus the git pre-commit hook - `script/projectname` — print the project name (used for the Docker image tag) - `script/test` — run the test suite (race detector, coverage) -- `script/lint` — run golangci-lint +- `script/lint` — run golangci-lint, always inside Docker: it builds + `Dockerfile.lint`, which COPYs the repo into the digest-pinned + `golangci-lint` image and lints as a build step, so a successful + build is a clean lint. The linter is never installed or run on the + host, and Docker is the only prerequisite. Caching is waived for + linting: the lint stage is forced to execute on every run with + `--no-cache-filter`, because a cached build lints nothing. - `script/fmt` — format all code (gofmt -s, goimports) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check @@ -403,7 +410,7 @@ them. We provide: ```sh make build # Build binary to bin/dnswatcher make test # Run tests with race detector -make lint # Run golangci-lint +make lint # Run golangci-lint in Docker (requires docker) make fmt # Format code make check # Run all checks (test, lint, fmt-check) make clean # Remove build artifacts diff --git a/TODO.md b/TODO.md index bc4c519..8e3909b 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ confirm make check still passes. # Completed Steps +- 2026-08-10: all linting moved into Docker: new root `Dockerfile.lint` + on the digest-pinned `golangci/golangci-lint:v2.12.2` image, + `script/lint` reduced to a thin wrapper that builds it with + `--no-cache-filter=lint` so the linter actually executes every run, + golangci-lint install dropped from `script/bootstrap` (goimports + stays, `script/fmt` needs it on the host), and the root `Dockerfile` + given its own lint stage so its build no longer recurses through + `make check` into `script/lint`. `golangci-lint config verify` is + deliberately omitted: it fetches its schema over an unpinned live + HTTPS call - 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the org-standard v2-schema config used across the org's repos diff --git a/script/bootstrap b/script/bootstrap index 129cc77..0901c87 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,15 +3,16 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes nothing is present. -# golangci-lint and goimports are installed via `go install` at the same -# pinned commits the Dockerfile uses (never "latest"). +# goimports is installed via `go install` at a pinned commit (never +# "latest") because script/fmt and script/fmt-check run it on the host. +# The linter is NOT installed here: golangci-lint runs via docker only +# (script/lint), pinned by image digest, so its only prerequisite is a +# working docker. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-08-07 (same pins as the Dockerfile) -# golangci-lint v2.12.2 -GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5" +# Pinned version, 2026-08-07 (same pin as the Dockerfile) # goimports v0.42.0 GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" @@ -69,11 +70,18 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi - # Lint/format tools, pinned via go install (installs into + # Format tools, pinned via go install (installs into # "$(go env GOPATH)/bin"; ensure that is on your PATH). - if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi if missing goimports; then go install "$GOIMPORTS_REF"; fi + # Linting runs via docker only (script/lint). Warn, don't fail: + # everything except `make lint` works without it. + if missing docker; then + echo "bootstrap: WARNING: docker not found; make lint and" >&2 + echo "bootstrap: make docker require it. Install docker to" >&2 + echo "bootstrap: run the linter." >&2 + fi + go mod download echo "bootstrap complete" diff --git a/script/lint b/script/lint index 8017180..84a1972 100755 --- a/script/lint +++ b/script/lint @@ -1,12 +1,28 @@ #!/bin/sh -# script/lint: run the linter. +# script/lint: run the linter. golangci-lint is never installed or run +# on the host: it runs via docker only, one way, everywhere. This +# builds Dockerfile.lint, which COPYs the repo into the digest-pinned +# golangci-lint image and lints as a build step, so a successful build +# means a clean lint. +# +# --no-cache-filter=lint forces the lint stage (source copy + linter +# run) to execute on every invocation. Without it an unchanged tree +# returns success in well under a second having linted nothing. The +# deps stage (base image + go mod download) stays cached, and no global +# cache invalidation is performed. --progress=plain keeps the linter's +# own output visible. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - golangci-lint run --config .golangci.yml ./... + docker build \ + --progress=plain \ + --no-cache-filter=lint \ + --target lint \ + -f Dockerfile.lint \ + . } main "$@"