diff --git a/Dockerfile b/Dockerfile index 96d4a5e..8451b56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ # Lint stage +# Same image as Dockerfile.lint: change both pins together. # golangci/golangci-lint:v2.12.2-alpine, 2026-08-07 FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint @@ -13,6 +14,9 @@ RUN go mod download # Copy source code COPY . . +# Tells script/lint it is inside a container, so it runs the linter. +ENV container=docker + # Run formatting check and linter RUN make fmt-check RUN make lint diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..a07d8ca --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,34 @@ +# Dockerfile.lint: the container script/lint builds to run golangci-lint, +# which is never installed on the host. Pinned to the same image as the +# Dockerfile lint stage: change both pins together, or the two run +# different linter versions. +# +# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07 +FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 + +# pixa is CGO/libvips: the type-aware linters compile every package, so +# this image needs the same C libraries the build does. +RUN apk add --no-cache build-base vips-dev libheif-dev pkgconfig + +WORKDIR /src + +# Modules first for layer caching; go.mod/go.sum settle this layer's +# result, so it may safely be reused between runs. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Tells script/lint it is inside a container, so it runs the linter. +ENV container=docker + +# script/lint passes a different CACHEBUST on every run, and BuildKit +# keys every RUN after this ARG on its value, so the lint step always +# runs instead of returning a cached success that linted nothing. +# +# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a +# tmpfs that is discarded after the step. Written into the layer, they +# would pile up as build cache on every run, since no later run, with +# its new CACHEBUST, can reuse that layer. +ARG CACHEBUST +RUN --mount=type=tmpfs,target=/root/.cache script/lint diff --git a/Makefile b/Makefile index 27001a8..708d5e5 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ ifdef HAS_PKGCONFIG NIX_RUN_PREFIX = NIX_RUN_SUFFIX = else - NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run ' + NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif git --run ' NIX_RUN_SUFFIX = ' endif diff --git a/README.md b/README.md index 4c4d859..d3b2b13 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,8 @@ them. We provide: (bootstrap, then install-precommit) - `script/projectname` — output the project name ("pixa") - `script/test` — run the test suite -- `script/lint` — run golangci-lint +- `script/lint` — run golangci-lint, always in a container (builds + `Dockerfile.lint` when run outside one) - `script/fmt` — format all code (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check diff --git a/TODO.md b/TODO.md index 4dc5290..e859d2e 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,18 @@ exhaustion # Completed Steps +- 2026-09-28 run all linting in Docker via `Dockerfile.lint` + + `script/lint` (closes #104): `make lint` calls `script/lint`, the only + way the linter is run; inside a container (both Dockerfiles set + `container=docker`) it runs `golangci-lint`, anywhere else it builds the + hash-pinned `Dockerfile.lint`, whose last step runs `script/lint` again; + the `Dockerfile` lint stage runs `make lint`; no host or nix-shell + `golangci-lint` path remains (`script/bootstrap` installs no linter); + a per-run `CACHEBUST` build-arg keeps the lint step from being served + from cache, and a tmpfs mount on that step keeps Go's and + golangci-lint's caches out of its layer, so a run leaves no large build + cache behind; `golangci-lint config verify` stays out, as it fetches its + schema over an unpinned live HTTPS call - 2026-09-28 every setting as an environment variable (closes #128, also covers #99): each config key can be set by `PIXA_` plus the key in upper case (`.` written as `_`), and the port by `PORT`; a variable present in diff --git a/script/bootstrap b/script/bootstrap index c1abf08..83d2fc5 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,20 +3,14 @@ # 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). CGO image libraries (pkg-config, vips, libheif) are -# installed for the govips bindings. +# make, or go). The linter is never installed on the host: golangci-lint +# runs only inside a container, Dockerfile.lint or the Dockerfile lint +# stage (see script/lint). CGO image libraries (pkg-config, vips, +# libheif) are installed for the govips bindings. 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="" @@ -57,52 +51,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 -} - # CGO dependencies for govips (image processing) ensure_cgo_deps() { if missing pkg-config; then @@ -123,9 +71,8 @@ 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 # CGO image libraries ensure_cgo_deps diff --git a/script/lint b/script/lint index 02c75c1..96aa46d 100755 --- a/script/lint +++ b/script/lint @@ -1,23 +1,37 @@ #!/bin/sh -# script/lint: run the linter. CGO dependencies (pkg-config, vips, -# libheif) come from nix-shell when not already available (e.g. inside -# a Docker build or an existing nix-shell). +# script/lint: run golangci-lint over the whole tree. This is the only +# way the linter is run, everywhere; it is never installed on the host. +# +# Inside a container it runs the linter. Anywhere else it builds +# Dockerfile.lint, whose last step runs this script again inside that +# container. +# +# Dockerfile.lint and the Dockerfile lint stage set container=docker +# (the systemd convention for marking a container) to say where we are. +# /.dockerenv cannot: it is missing inside build steps, and present on +# hosts that are themselves containers. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -run_with_cgo_deps() { - if command -v pkg-config >/dev/null 2>&1; then - sh -c "$1" +main() { + cd "$ROOT" + if [ "${container:-}" = docker ]; then + # `golangci-lint config verify` is not run: it fetches its JSON + # schema over an unpinned live HTTPS call, which REPO_POLICIES.md + # forbids. + echo "Running linter..." + golangci-lint run --config .golangci.yml ./... else - nix-shell -p pkg-config vips libheif golangci-lint git --run "$1" + # A new CACHEBUST on every run means the lint step is never + # served from cache (see Dockerfile.lint). The cacheonly output + # leaves no image behind. + docker build \ + --progress=plain \ + --build-arg CACHEBUST="$(date +%s)-$$" \ + --output=type=cacheonly \ + -f Dockerfile.lint . fi } -main() { - cd "$ROOT" - echo "Running linter..." - run_with_cgo_deps "golangci-lint run" -} - main "$@" diff --git a/script/test b/script/test index e9b4954..c0bcc75 100755 --- a/script/test +++ b/script/test @@ -10,7 +10,7 @@ run_with_cgo_deps() { if command -v pkg-config >/dev/null 2>&1; then sh -c "$1" else - nix-shell -p pkg-config vips libheif golangci-lint git --run "$1" + nix-shell -p pkg-config vips libheif git --run "$1" fi }