diff --git a/Dockerfile b/Dockerfile index f43c43a..57a865a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,9 +19,14 @@ RUN go mod download # .dockerignore. COPY . . -# Run formatting check and linter +# Run formatting check and linter. golangci-lint is invoked directly rather +# than through `make lint`: this stage is already the pinned linter image, and +# script/lint is a wrapper that builds Dockerfile.lint, so calling it here +# would need a docker daemon inside the build. Keep these steps in step with +# Dockerfile.lint, including --network=none (see its header for why). RUN make fmt-check -RUN make lint +RUN --network=none golangci-lint config verify --config .golangci.yml +RUN --network=none golangci-lint run --config .golangci.yml ./... # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..9a9af51 --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,37 @@ +# Lint-only image, built by script/lint. golangci-lint is never installed on +# the host: the repo is COPYed into the pinned image and linted as a build +# step, so a successful build IS a clean lint. This works even when the docker +# daemon is remote and bind mounts are impossible. +# +# script/lint passes --no-cache-filter=lint. Without it an unchanged tree +# replays the lint stage from cache and the build succeeds in under a second +# having run no linter at all. Do not drop that flag. +# +# The lint steps run with --network=none. `golangci-lint config verify` is +# documented as fetching its JSON schema over HTTPS, which would make linting +# depend on an unpinned remote artifact; this pinned image resolves the schema +# without any network, and --network=none enforces that rather than trusting +# it. It also proves no linter reaches out at analysis time. If a future image +# bump makes either step need the network, this build fails loudly instead of +# quietly acquiring an unpinned dependency. + +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 +# Using Debian-based image because mattn/go-sqlite3 (CGO) does not +# compile on Alpine musl (off64_t is a glibc type). +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps + +WORKDIR /src + +# Copy go mod files first for better layer caching. This stage is cacheable; +# only the lint stage below is forced to re-execute. +COPY go.mod go.sum ./ +RUN go mod download + +FROM deps AS lint + +COPY . . + +# `run` silently ignores config keys it does not recognize, so a typo would +# disable a setting without a word. `config verify` is what catches that. +RUN --network=none golangci-lint config verify --config .golangci.yml +RUN --network=none golangci-lint run --config .golangci.yml ./... diff --git a/README.md b/README.md index 570f087..8664bfa 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ with retry support, logging, and observability. Category: infrastructure ### Prerequisites - Go 1.26+ -- golangci-lint v2.11+ -- Docker (for containerized deployment) +- Docker (for linting and for containerized deployment) + +golangci-lint is not a prerequisite and must not be installed on the host: +`make lint` runs the pinned linter image via `Dockerfile.lint`. ### Quick Start @@ -41,7 +43,7 @@ make docker make bootstrap # Install all dependencies (idempotent) make setup # Bootstrap + install git pre-commit hook make fmt # Format code (gofmt + goimports) -make lint # Run golangci-lint +make lint # Run golangci-lint in Docker (Dockerfile.lint) make test # Run tests with race detection make check # test + lint + fmt-check (CI gate) make build # Build binary to bin/webhooker @@ -248,7 +250,7 @@ them. We provide: (bootstrap, then install-precommit) - `script/projectname` — output the project name ("webhooker") - `script/test` — run the test suite -- `script/lint` — run golangci-lint +- `script/lint` — run golangci-lint in Docker (see Linting below) - `script/fmt` — format all code (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check @@ -1058,6 +1060,7 @@ webhooker/ │ └── js/app.js # Client-side JavaScript (minimal bootstrap) ├── templates/ # Go HTML templates (base, index, login, etc.) ├── Dockerfile # Multi-stage: lint, build+test, then Alpine runtime +├── Dockerfile.lint # Lint-only image built by script/lint ├── Makefile # fmt, lint, test, check, build, docker targets ├── go.mod / go.sum └── .golangci.yml # Linter configuration @@ -1165,17 +1168,48 @@ downstream at form-parse time. - Container runs as non-root user (UID 1000) - GORM soft deletes on all entities (data preserved for audit) +### Linting + +golangci-lint never runs on the host. `script/lint` builds +`Dockerfile.lint`, which copies the repo into the digest-pinned +golangci-lint image and lints as a build step, so a successful build is +a clean lint. A host binary would share one cache and one lock with +every other checkout on the machine, which has produced both invented +findings attributed to other worktrees and unearned passes. + +Two properties are load-bearing: + +- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged + tree replays the lint layer from cache and the build exits 0 in under + a second having linted nothing. The `deps` stage stays cacheable, so + module downloads are not repeated. Invalidation is scoped to the one + stage; never prune the shared build cache. +- Both lint steps use `RUN --network=none`. `golangci-lint config + verify` is documented as fetching its JSON schema over HTTPS, which + would be an unpinned remote dependency; the pinned image resolves the + schema without network access, and `--network=none` enforces that + instead of trusting it. Verify is worth keeping because + `golangci-lint run` silently ignores config keys it does not + recognize, so a typo would disable a setting with no warning. + ### Docker The Dockerfile uses a multi-stage build: -1. **Builder stage** (Debian-based `golang:1.24`) — installs - golangci-lint, downloads dependencies, copies source, runs `make - check` (format verification, linting, tests, compilation). -2. **Runtime stage** (`alpine:3.21`) — copies the binary, creates the +1. **Lint stage** (`golangci/golangci-lint`) — copies source, runs + `make fmt-check`, `golangci-lint config verify`, and + `golangci-lint run`. +2. **Builder stage** (Debian-based `golang`) — downloads dependencies, + copies source, runs `make test` and `make build`, then relinks the + binary statically. +3. **Runtime stage** (`alpine:3.21`) — copies the binary, creates the `/var/lib/webhooker` directory for all SQLite databases, runs as non-root user, exposes port 8080, includes a health check. +The lint stage invokes `golangci-lint` directly rather than `make lint`: +it is already the pinned linter image, and `make lint` would shell out +to another docker build from inside this one. + The builder uses Debian rather than Alpine because GORM's SQLite dialect pulls in CGO-dependent headers at compile time. The runtime binary is statically linked and runs on Alpine. diff --git a/script/bootstrap b/script/bootstrap index f1b3e30..1f014a1 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,19 +3,12 @@ # 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 (not git, -# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt -# it is installed from a hash-verified GitHub release archive (never -# curl | sh). +# make, or go). golangci-lint is deliberately not installed: linting runs +# only in docker, via script/lint and Dockerfile.lint. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-08-07. Never "latest"; exact versions only. -GOLANGCI_LINT_VERSION="2.12.2" -# sha256 of golangci-lint-2.12.2-linux-.tar.gz release archives -GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553" -GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a" - PKGMGR="" SUDO="" @@ -56,52 +49,6 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } -# verify_sha256 -verify_sha256() { - if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$1" | cut -d' ' -f1)" - else - actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" - fi - if [ "$actual" != "$2" ]; then - echo "bootstrap: sha256 mismatch for $1" >&2 - echo " expected: $2" >&2 - echo " actual: $actual" >&2 - exit 1 - fi -} - -# apt has no golangci-lint package: install a pinned release archive -# from GitHub, verified by hardcoded sha256 (never curl | sh). -install_golangci_lint_release() { - case "$(uname -m)" in - x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;; - aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;; - *) - echo "bootstrap: unsupported architecture $(uname -m)" >&2 - exit 1 - ;; - esac - if missing curl; then pkg_install curl curl curl curl; fi - name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}" - tmp="$(mktemp -d)" - curl -fsSL -o "$tmp/$name.tar.gz" \ - "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz" - verify_sha256 "$tmp/$name.tar.gz" "$sha" - tar -xzf "$tmp/$name.tar.gz" -C "$tmp" - $SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint - rm -rf "$tmp" -} - -ensure_golangci_lint() { - if ! missing golangci-lint; then return 0; fi - detect_pkgmgr - case "$PKGMGR" in - apt) install_golangci_lint_release ;; - *) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;; - esac -} - main() { cd "$ROOT" @@ -109,9 +56,14 @@ main() { if missing git; then pkg_install git git git git; fi if missing make; then pkg_install gnumake make make make; fi - # Go toolchain and linter + # Go toolchain if missing go; then pkg_install go golang go go; fi - ensure_golangci_lint + + # Not installed here: docker is platform-specific and out of scope for a + # package-manager bootstrap, but script/lint needs it. + if missing docker; then + echo "bootstrap: docker not found; script/lint requires it" >&2 + fi go mod download diff --git a/script/lint b/script/lint index 8017180..753631b 100755 --- a/script/lint +++ b/script/lint @@ -1,12 +1,29 @@ #!/bin/sh -# script/lint: run the linter. +# script/lint: run the linter. golangci-lint is never installed locally: it +# runs via docker only, one way, everywhere — script/lint builds +# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image +# and lints as a build step. This works even when the docker daemon is remote +# and bind mounts are impossible, and it removes the host linter's shared +# cache, which has attributed other checkouts' findings to this one. +# +# --no-cache-filter=lint forces the lint stage to re-execute on every run; a +# cached lint stage exits 0 in under a second having linted nothing. The deps +# stage keeps its cache, so module downloads are not repeated. +# --progress=plain keeps the linter's own output visible on success, so a +# passing run shows the issue count rather than nothing. +# --output=type=cacheonly leaves no image behind to clean up. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - golangci-lint run --config .golangci.yml ./... + docker build \ + -f Dockerfile.lint \ + --no-cache-filter=lint \ + --progress=plain \ + --output=type=cacheonly \ + . } main "$@"