Run all linting in Docker via Dockerfile.lint (closes #109)
All checks were successful
check / check (push) Successful in 3m9s
All checks were successful
check / check (push) Successful in 3m9s
golangci-lint no longer 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. The host binary shared one cache and one lock with every other checkout on the machine, which produced findings attributed to unrelated worktrees as well as unearned passes. Two properties the wrapper has to get right: - --no-cache-filter=lint forces the lint stage to re-execute. Without it an unchanged tree replays the layer and the build exits 0 in under a second having linted nothing. The deps stage stays cacheable. - Both lint steps use RUN --network=none. golangci-lint config verify is documented as fetching its JSON schema over HTTPS; the pinned image resolves it with no network, and --network=none enforces that rather than trusting it. Verify is kept because golangci-lint run silently ignores config keys it does not recognize. The main Dockerfile's lint stage now invokes golangci-lint directly instead of `make lint`, which would otherwise need a docker daemon inside the build. golangci-lint installation is removed from script/bootstrap.
This commit is contained in:
@@ -19,9 +19,14 @@ RUN go mod download
|
|||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
COPY . .
|
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 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
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
|||||||
37
Dockerfile.lint
Normal file
37
Dockerfile.lint
Normal file
@@ -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 ./...
|
||||||
50
README.md
50
README.md
@@ -12,8 +12,10 @@ with retry support, logging, and observability. Category: infrastructure
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26+
|
- Go 1.26+
|
||||||
- golangci-lint v2.11+
|
- Docker (for linting and for containerized deployment)
|
||||||
- Docker (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
|
### Quick Start
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ make docker
|
|||||||
make bootstrap # Install all dependencies (idempotent)
|
make bootstrap # Install all dependencies (idempotent)
|
||||||
make setup # Bootstrap + install git pre-commit hook
|
make setup # Bootstrap + install git pre-commit hook
|
||||||
make fmt # Format code (gofmt + goimports)
|
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 test # Run tests with race detection
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker
|
make build # Build binary to bin/webhooker
|
||||||
@@ -248,7 +250,7 @@ them. We provide:
|
|||||||
(bootstrap, then install-precommit)
|
(bootstrap, then install-precommit)
|
||||||
- `script/projectname` — output the project name ("webhooker")
|
- `script/projectname` — output the project name ("webhooker")
|
||||||
- `script/test` — run the test suite
|
- `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` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
@@ -1058,6 +1060,7 @@ webhooker/
|
|||||||
│ └── js/app.js # Client-side JavaScript (minimal bootstrap)
|
│ └── js/app.js # Client-side JavaScript (minimal bootstrap)
|
||||||
├── templates/ # Go HTML templates (base, index, login, etc.)
|
├── templates/ # Go HTML templates (base, index, login, etc.)
|
||||||
├── Dockerfile # Multi-stage: lint, build+test, then Alpine runtime
|
├── 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
|
├── Makefile # fmt, lint, test, check, build, docker targets
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
@@ -1165,17 +1168,48 @@ downstream at form-parse time.
|
|||||||
- Container runs as non-root user (UID 1000)
|
- Container runs as non-root user (UID 1000)
|
||||||
- GORM soft deletes on all entities (data preserved for audit)
|
- 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
|
### Docker
|
||||||
|
|
||||||
The Dockerfile uses a multi-stage build:
|
The Dockerfile uses a multi-stage build:
|
||||||
|
|
||||||
1. **Builder stage** (Debian-based `golang:1.24`) — installs
|
1. **Lint stage** (`golangci/golangci-lint`) — copies source, runs
|
||||||
golangci-lint, downloads dependencies, copies source, runs `make
|
`make fmt-check`, `golangci-lint config verify`, and
|
||||||
check` (format verification, linting, tests, compilation).
|
`golangci-lint run`.
|
||||||
2. **Runtime stage** (`alpine:3.21`) — copies the binary, creates the
|
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
|
`/var/lib/webhooker` directory for all SQLite databases, runs as
|
||||||
non-root user, exposes port 8080, includes a health check.
|
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
|
The builder uses Debian rather than Alpine because GORM's SQLite
|
||||||
dialect pulls in CGO-dependent headers at compile time. The runtime
|
dialect pulls in CGO-dependent headers at compile time. The runtime
|
||||||
binary is statically linked and runs on Alpine.
|
binary is statically linked and runs on Alpine.
|
||||||
|
|||||||
@@ -3,19 +3,12 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# 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
|
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||||
# it is installed from a hash-verified GitHub release archive (never
|
# only in docker, via script/lint and Dockerfile.lint.
|
||||||
# curl | sh).
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
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-<arch>.tar.gz release archives
|
|
||||||
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
|
||||||
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|
||||||
@@ -56,52 +49,6 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
# verify_sha256 <file> <expected-hash>
|
|
||||||
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() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -109,9 +56,14 @@ main() {
|
|||||||
if missing git; then pkg_install git git git git; fi
|
if missing git; then pkg_install git git git git; fi
|
||||||
if missing make; then pkg_install gnumake make make make; 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
|
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
|
go mod download
|
||||||
|
|
||||||
|
|||||||
21
script/lint
21
script/lint
@@ -1,12 +1,29 @@
|
|||||||
#!/bin/sh
|
#!/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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
golangci-lint run --config .golangci.yml ./...
|
docker build \
|
||||||
|
-f Dockerfile.lint \
|
||||||
|
--no-cache-filter=lint \
|
||||||
|
--progress=plain \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user