Rework Dockerfile.backend to the mandated Go multistage lint-stage pattern #17
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Dockerfile.backenddoes not follow the Go Dockerfile pattern thatREPO_POLICIES.mdmandates, and it drags the entire git history into the build context to do it. Verified onmainatfbfe1df.Current shape: a single
builderstage based ongolang:1.25-alpinethatapk addsgit make gcc musl-dev,go installs golangci-lint from source, copies.git, then runsmake checkandmake build.Divergences from policy
No separate lint stage. Policy: "Dockerfiles must use a separate lint stage for fail-fast feedback. Go repos use a multistage build where linting runs in an independent stage based on the
golangci/golangci-lintimage (pinned by hash). This stage runsmake fmt-checkandmake lintbefore the full build begins." There is noAS lintstage at all.No BuildKit stage dependency. Policy requires
COPY --from=lint /src/go.sum /dev/nullin the build stage to force BuildKit to complete linting before compiling. Absent.golangci-lint is compiled from source inside the build.
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@9f61b0f...builds the linter on every cache miss. The policy pattern uses the prebuiltgolangci/golangci-lintimage directly, which "includes both Go and the linter, so there is no need to install the linter separately." This is the single largest contributor to build time.COPY .git /repo/.git. The whole history is copied into the image solely sobackend/Makefile'sVERSION := $(shell git describe --always --dirty)resolves. Policy's pattern usesARG VERSION=devand-ldflags "-X main.Version=${VERSION}"instead. The.gitcopy also blocks adding.gitto.dockerignore(see #15).Static linking via CGO.
backend/Makefilebuilds with-linkmode external -extldflags -static, which is whygccandmusl-devare installed. The policy pattern isCGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}", which needs no C toolchain and produces a static binary anyway.Definition of done
Dockerfile.backendhas alintstageFROM golangci/golangci-lint@sha256:...(pinned by digest, with a# golangci/golangci-lint:v2.12.2, YYYY-MM-DDcomment above it) that runsmake fmt-checkandmake lint.COPY --from=lint /src/go.sum /dev/null(or the equivalent for the chosen paths) so BuildKit cannot run the stages in parallel and skip a lint failure. Verify this actually works: introduce a deliberate lint error, confirmdocker build -f Dockerfile.backend .fails, revert.make testand builds withCGO_ENABLED=0 go build -trimpathand-ldflags="-s -w -X main.Version=${VERSION}", driven byARG VERSION=dev.gccandmusl-devare no longer installed;backend/Makefile's-linkmode external -extldflags -staticis removed. Confirm the resulting binary is still static and runs in thealpineruntime stage.COPY .git /repo/.gitis gone. Version comes fromARG VERSION, andbackend/Makefiletoleratesgit describebeing unavailable (no build failure when.gitis absent).FROMis pinned by@sha256:digest with a version-and-date comment above it, per the hash-pinning policy.docker build -f Dockerfile.backend .succeeds and completes in under 5 minutes.make checkandcd backend && make checkboth pass.TODO.mdupdated in the same commit.(closes #N).Implementation requirements
REPO_POLICIES.mdclosely; deviate only where this repo genuinely differs, and note any deviation in the PR description.ca-certificates), keepsEXPOSE 8080, and keeps the existing entrypoint behaviour.maketargets andscript/entrypoints only for verification.Implementation plan
Branching from
mainatfbfe1dfintofeat/backend-dockerfile-lint-stage,worked in a scratch clone (not a worktree, because of #33).
Dockerfile.backend— three stagesAS lint—FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368bawith a
# golangci/golangci-lint:v2.7.2 (2026-08-09)comment above it.WORKDIR /src, copybackend/go.mod backend/go.sum,go mod download,copy
backend/, thenRUN make fmt-checkandRUN make lint. I verifiedthe image already carries everything those two targets need:
golangci-lint has version 2.7.2 built with go1.25.4 from 9f61b0f5,go1.25.5,/usr/bin/make,/usr/local/go/bin/gofmt— so nothing isinstalled in this stage.
Version choice:
mainpins golangci-lint at commit9f61b0f53f80672872fced07b6874397c3ed197b= v2.7.2, and the digest aboveis the
v2.7.2tag of the image, whose--versionreports that exactcommit. This branch is cut from
mainand must be coherent againstmain,so it matches
mainrather than pre-merging #14/#31's v2.12.2(
c0d3ddc9cf3faa61a4e378e879ece580256d76e5). This is not a regression of#31: whichever of the two lands second bumps the one digest. The PR body
will carry the explicit reconciliation instruction and the v2.12.2 image
digest to substitute.
AS builder— the existinggolang:1.25-alpinedigest pin, withapk add --no-cache makeonly (git,gcc,musl-devall dropped).First line after
WORKDIR /srcisCOPY --from=lint /src/go.sum /dev/nullso BuildKit cannot run the twostages in parallel. Then
go mod download,COPY backend/ .,RUN make test,ARG VERSION=dev, and the build.Runtime — unchanged in substance:
alpine:3.23by digest,ca-certificates,EXPOSE 8080,ENTRYPOINT ["netwatch-server"].COPY .git /repo/.gitis deleted. EveryFROMkeeps a version + date commentabove its
@sha256:pin.backend/MakefileVERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)—
?=soARG VERSIONcan drive it from the Dockerfile, the brace-groupredirect so a missing
.git(or a missinggitbinary) degrades todevinstead of erroring or emitting stderr noise.
UNAME_S/ifeq (Darwin)split and-linkmode external -extldflags -static. The single build recipe becomesCGO_ENABLED=0 go build -trimpath -ldflags "-s -w $(GOLDFLAGS)" ..., whichis exactly the command the policy pattern mandates.
Deviation I intend to make, and will call out in the PR body: the policy
reference Dockerfile inlines
RUN CGO_ENABLED=0 go build -trimpath -ldflags=....I will instead put those exact flags in
backend/Makefileand have the buildstage run
make build VERSION=${VERSION}. Same command, same flags, same-X main.Version=${VERSION}, but it keeps one build definition instead of twodivergent ones, honours "always use Makefile targets instead of invoking the
underlying tools directly", and preserves the existing
-X main.Buildarch=$(BUILDARCH)ldflag that an inline copy would silentlydrop (that would be an application-behaviour change, which this issue
forbids). The docker build log will show the fully expanded
go buildline,so the flags are verifiable rather than asserted.
Verification I will run and paste into the PR
docker build -f Dockerfile.backend .forced uncached (--no-cacheon thatsingle build — no
docker builder prune, shared cache is not mine todestroy), timed, must be under 5 minutes.
fails and that it fails in the
lintstage before the builder'smake testever runs, then revert. Green CI is not accepted as evidencehere (#37).
file/lddon the artifact plus actually running theruntime image and hitting it, to confirm dropping the CGO static flags did
not produce a dynamically linked binary.
.gitproof: build from a tree with.gitremoved entirely and showmake buildand the image build both succeed.make checkandcd backend && make check.make fmtover the touched markdown.Scope
TODO.mdgets one additive line in the same commit (three other PRs touchthat file).
backend/.golangci.ymlis not touched..dockerignoreis nottouched — removing
.gitfrom the context is #36, and it stays blocked on thefrontend
Dockerfile, whosevite.config.jsshells out togit rev-parse HEADat config-eval time; the PR will state whether this makes#36 easier. Nothing else changes.