diff --git a/Dockerfile b/Dockerfile index 27ab84b..5decfa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,12 @@ RUN go mod download # Copy source code COPY . . -# Run formatting check and linter +# Run formatting check and linter. The linter is invoked directly, not +# via `make lint`: `make lint` now builds Dockerfile.lint, and there is +# no Docker inside a Docker build. This is the same linter, image, and +# config that Dockerfile.lint and script/lint run. RUN make fmt-check -RUN make lint +RUN golangci-lint run --config .golangci.yml ./... # Build stage # golang:1.25.4-alpine, 2026-02-25 diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..dace2dd --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,41 @@ +# Dockerfile.lint: the one and only path that runs golangci-lint. +# +# golangci-lint is never installed on the host; it runs only inside this +# build. A clean build of this file therefore IS a clean lint over the +# whole tree. It runs the same linter and config as Dockerfile's lint +# stage, pinned to the same image so the two cannot drift to 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 . . + +# Caching is deliberately waived for the lint step: an unchanged tree +# must still run the linter, not return a cached success in well under a +# second having linted nothing. CACHEBUST carries a value that differs +# on every run (script/lint supplies it and refuses to build without +# one). The lint RUN below references it, so BuildKit cannot serve that +# step from cache. Keep the ${CACHEBUST} reference on that step: dropping +# it lets the linter cache again and report a green that linted nothing. +ARG CACHEBUST +RUN test -n "${CACHEBUST}" || { \ + echo "Dockerfile.lint requires the CACHEBUST build-arg; build it via script/lint." >&2; \ + exit 1; } + +# `golangci-lint config verify` is deliberately not run: it fetches its +# JSON schema over an unpinned live HTTPS call, which REPO_POLICIES.md +# forbids for external references. +RUN echo "pixa-lint: running golangci-lint (${CACHEBUST})" && \ + golangci-lint run --config .golangci.yml ./... diff --git a/TODO.md b/TODO.md index dab3310..13671eb 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,15 @@ P1: implement blocked networks configuration to extend SSRF protection # Completed Steps +- 2026-09-21 run all linting in Docker via `Dockerfile.lint` + + `script/lint` (closes #104): `script/lint` builds a hash-pinned root + `Dockerfile.lint`, and no host or nix-shell `golangci-lint` path + remains; a per-run `CACHEBUST` build-arg forces the lint step to + execute every run, so an unchanged tree cannot return a cached green + that linted nothing; `Dockerfile`'s lint stage runs `golangci-lint` + directly, since `make lint` now builds a container and there is no + Docker inside a build; `golangci-lint config verify` stays out, as it + fetches its schema over an unpinned live HTTPS call - 2026-09-21 http.Server hardening (closes #92): added `HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and `HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the diff --git a/script/lint b/script/lint index 02c75c1..18ed85b 100755 --- a/script/lint +++ b/script/lint @@ -1,23 +1,55 @@ #!/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. +# +# The linter is never installed on the host: it runs only inside the +# Dockerfile.lint build, one way, everywhere. A clean build is a clean +# lint. See Dockerfile.lint for why the lint step cannot be cached. 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" - else - nix-shell -p pkg-config vips libheif golangci-lint git --run "$1" +main() { + cd "$ROOT" + + # A value no other run repeats. Dockerfile.lint folds it into the + # lint step's cache key, so the linter re-executes every run instead + # of an unchanged tree returning a cached success having linted + # nothing. + cachebust="$(date +%s)-$$" + + tmp="$(mktemp -d "${TMPDIR:-/tmp}/pixa-lint.XXXXXX")" + trap 'rm -rf "$tmp"' EXIT INT TERM + + # --progress=plain so the lint step's own output reaches the log we + # check below; --output=type=cacheonly because we want the linter's + # verdict, not an image left in the local store. The build status + # travels through a file: a pipeline's exit status is tee's, not the + # build's. + ( + set +e + docker build \ + --progress=plain \ + --build-arg CACHEBUST="$cachebust" \ + --output=type=cacheonly \ + -f Dockerfile.lint . 2>&1 + echo "$?" >"$tmp/status" + ) | tee "$tmp/build.log" + + status="$(cat "$tmp/status" 2>/dev/null || echo 1)" + [ "${status:-1}" -eq 0 ] || exit "${status:-1}" + + # The linter's start line must appear as build output, not only in + # the build's echo of the RUN instruction. A step served from cache + # prints the instruction and none of its output; a step that runs + # prints a "# ..." output line. Requiring that output + # line means a future edit dropping the CACHEBUST reference from + # Dockerfile.lint fails here rather than passing having linted + # nothing. + if ! grep -Eq '^#[0-9]+ +[0-9]+\.[0-9]+ +pixa-lint: running golangci-lint' \ + "$tmp/build.log"; then + echo "script/lint: golangci-lint did not execute (cached step?)." >&2 + exit 1 fi } -main() { - cd "$ROOT" - echo "Running linter..." - run_with_cgo_deps "golangci-lint run" -} - main "$@"