Dockerfile.backend does not follow the Go Dockerfile pattern that REPO_POLICIES.md mandates, and it drags the entire git history into the build context to do it. Verified on main at fbfe1df.
Current shape: a single builder stage based on golang:1.25-alpine that apk adds git make gcc musl-dev, go installs golangci-lint from source, copies .git, then runs make check and make 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-lint image (pinned by hash). This stage runs make fmt-check and make lint before the full build begins." There is no AS lint stage at all.
No BuildKit stage dependency. Policy requires COPY --from=lint /src/go.sum /dev/null in 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 prebuilt golangci/golangci-lint image 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 so backend/Makefile's VERSION := $(shell git describe --always --dirty) resolves. Policy's pattern uses ARG VERSION=dev and -ldflags "-X main.Version=${VERSION}" instead. The .git copy also blocks adding .git to .dockerignore (see #15).
Static linking via CGO.backend/Makefile builds with -linkmode external -extldflags -static, which is why gcc and musl-dev are installed. The policy pattern is CGO_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.backend has a lint stage FROM golangci/golangci-lint@sha256:... (pinned by digest, with a # golangci/golangci-lint:v2.12.2, YYYY-MM-DD comment above it) that runs make fmt-check and make lint.
The lint stage's linter version agrees with issue #14 (v2.12.2). If #14 has landed, match it; if not, this issue must not regress whatever #14 established. Coordinate ordering rather than duplicating the change.
The build stage contains 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, confirm docker build -f Dockerfile.backend . fails, revert.
The build stage runs make test and builds with CGO_ENABLED=0 go build -trimpath and -ldflags="-s -w -X main.Version=${VERSION}", driven by ARG VERSION=dev.
gcc and musl-dev are no longer installed; backend/Makefile's -linkmode external -extldflags -static is removed. Confirm the resulting binary is still static and runs in the alpine runtime stage.
COPY .git /repo/.git is gone. Version comes from ARG VERSION, and backend/Makefile tolerates git describe being unavailable (no build failure when .git is absent).
Every FROM is 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.
Root make check and cd backend && make check both pass.
TODO.md updated in the same commit.
Commit title ends with (closes #N).
Implementation requirements
Follow the reference Dockerfile in REPO_POLICIES.md closely; deviate only where this repo genuinely differs, and note any deviation in the PR description.
The runtime stage stays minimal (alpine + ca-certificates), keeps EXPOSE 8080, and keeps the existing entrypoint behaviour.
Do not change application behaviour, routes, or Go logic in this commit.
make targets and script/ entrypoints only for verification.
No attribution trailers in the commit message.
## Problem
`Dockerfile.backend` does not follow the Go Dockerfile pattern that `REPO_POLICIES.md` mandates, and it drags the entire git history into the build context to do it. Verified on `main` at `fbfe1df`.
Current shape: a single `builder` stage based on `golang:1.25-alpine` that `apk add`s `git make gcc musl-dev`, `go install`s golangci-lint from source, copies `.git`, then runs `make check` and `make build`.
### Divergences from policy
1. **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-lint` image (pinned by hash). This stage runs `make fmt-check` and `make lint` before the full build begins." There is no `AS lint` stage at all.
2. **No BuildKit stage dependency.** Policy requires `COPY --from=lint /src/go.sum /dev/null` in the build stage to force BuildKit to complete linting before compiling. Absent.
3. **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 prebuilt `golangci/golangci-lint` image 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.
4. **`COPY .git /repo/.git`.** The whole history is copied into the image solely so `backend/Makefile`'s `VERSION := $(shell git describe --always --dirty)` resolves. Policy's pattern uses `ARG VERSION=dev` and `-ldflags "-X main.Version=${VERSION}"` instead. The `.git` copy also blocks adding `.git` to `.dockerignore` (see #15).
5. **Static linking via CGO.** `backend/Makefile` builds with `-linkmode external -extldflags -static`, which is why `gcc` and `musl-dev` are installed. The policy pattern is `CGO_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.backend` has a `lint` stage `FROM golangci/golangci-lint@sha256:...` (pinned by digest, with a `# golangci/golangci-lint:v2.12.2, YYYY-MM-DD` comment above it) that runs `make fmt-check` and `make lint`.
- [ ] The lint stage's linter version agrees with issue #14 (v2.12.2). If #14 has landed, match it; if not, this issue must not regress whatever #14 established. Coordinate ordering rather than duplicating the change.
- [ ] The build stage contains `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, confirm `docker build -f Dockerfile.backend .` fails, revert.
- [ ] The build stage runs `make test` and builds with `CGO_ENABLED=0 go build -trimpath` and `-ldflags="-s -w -X main.Version=${VERSION}"`, driven by `ARG VERSION=dev`.
- [ ] `gcc` and `musl-dev` are no longer installed; `backend/Makefile`'s `-linkmode external -extldflags -static` is removed. Confirm the resulting binary is still static and runs in the `alpine` runtime stage.
- [ ] `COPY .git /repo/.git` is gone. Version comes from `ARG VERSION`, and `backend/Makefile` tolerates `git describe` being unavailable (no build failure when `.git` is absent).
- [ ] Every `FROM` is 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.
- [ ] Root `make check` and `cd backend && make check` both pass.
- [ ] `TODO.md` updated in the same commit.
- [ ] Commit title ends with ` (closes #N)`.
## Implementation requirements
- Follow the reference Dockerfile in `REPO_POLICIES.md` closely; deviate only where this repo genuinely differs, and note any deviation in the PR description.
- The runtime stage stays minimal (alpine + `ca-certificates`), keeps `EXPOSE 8080`, and keeps the existing entrypoint behaviour.
- Do not change application behaviour, routes, or Go logic in this commit.
- `make` targets and `script/` entrypoints only for verification.
- No attribution trailers in the commit message.
clawbot
added this to the 1.0.0 milestone 2026-08-09 03:38:14 +02:00
Branching from main at fbfe1df into feat/backend-dockerfile-lint-stage,
worked in a scratch clone (not a worktree, because of #33).
Dockerfile.backend — three stages
AS lint — FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba
with a # golangci/golangci-lint:v2.7.2 (2026-08-09) comment above it. WORKDIR /src, copy backend/go.mod backend/go.sum, go mod download,
copy backend/, then RUN make fmt-check and RUN make lint. I verified
the 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 is
installed in this stage.
Version choice:main pins golangci-lint at commit 9f61b0f53f80672872fced07b6874397c3ed197b = v2.7.2, and the digest above
is the v2.7.2 tag of the image, whose --version reports that exact
commit. This branch is cut from main and must be coherent against main,
so it matches main rather 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 existing golang:1.25-alpine digest pin, with apk add --no-cache make only (git, gcc, musl-dev all dropped).
First line after WORKDIR /src is COPY --from=lint /src/go.sum /dev/null so BuildKit cannot run the two
stages in parallel. Then go mod download, COPY backend/ ., RUN make test, ARG VERSION=dev, and the build.
Runtime — unchanged in substance: alpine:3.23 by digest, ca-certificates, EXPOSE 8080, ENTRYPOINT ["netwatch-server"].
COPY .git /repo/.git is deleted. Every FROM keeps a version + date comment
above its @sha256: pin.
backend/Makefile
VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)
— ?= so ARG VERSION can drive it from the Dockerfile, the brace-group
redirect so a missing .git (or a missing git binary) degrades to dev
instead of erroring or emitting stderr noise.
Delete the UNAME_S/ifeq (Darwin) split and -linkmode external -extldflags -static. The single build recipe becomes CGO_ENABLED=0 go build -trimpath -ldflags "-s -w $(GOLDFLAGS)" ..., which
is 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/Makefile and have the build
stage run make build VERSION=${VERSION}. Same command, same flags, same -X main.Version=${VERSION}, but it keeps one build definition instead of two
divergent 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 silently
drop (that would be an application-behaviour change, which this issue
forbids). The docker build log will show the fully expanded go build line,
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-cache on that
single build — nodocker builder prune, shared cache is not mine to
destroy), timed, must be under 5 minutes.
Lint gate proof: introduce a deliberate lint error, show the build fails and that it fails in the lint stage before the builder's make test ever runs, then revert. Green CI is not accepted as evidence
here (#37).
Static-binary proof: file/ldd on the artifact plus actually running the
runtime image and hitting it, to confirm dropping the CGO static flags did
not produce a dynamically linked binary.
No-.git proof: build from a tree with .git removed entirely and show make build and the image build both succeed.
Root make check and cd backend && make check.
make fmt over the touched markdown.
Scope
TODO.md gets one additive line in the same commit (three other PRs touch
that file). backend/.golangci.yml is not touched. .dockerignore is not
touched — removing .git from the context is #36, and it stays blocked on the
frontend Dockerfile, whose vite.config.js shells out to git rev-parse HEAD at config-eval time; the PR will state whether this makes #36 easier. Nothing else changes.
## Implementation plan
Branching from `main` at `fbfe1df` into `feat/backend-dockerfile-lint-stage`,
worked in a scratch clone (not a worktree, because of #33).
### `Dockerfile.backend` — three stages
1. **`AS lint`** — `FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba`
with a `# golangci/golangci-lint:v2.7.2 (2026-08-09)` comment above it.
`WORKDIR /src`, copy `backend/go.mod backend/go.sum`, `go mod download`,
copy `backend/`, then `RUN make fmt-check` and `RUN make lint`. I verified
the 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 is
installed in this stage.
**Version choice:** `main` pins golangci-lint at commit
`9f61b0f53f80672872fced07b6874397c3ed197b` = v2.7.2, and the digest above
is the `v2.7.2` tag of the image, whose `--version` reports that exact
commit. This branch is cut from `main` and must be coherent against `main`,
so it matches `main` rather 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.
2. **`AS builder`** — the existing `golang:1.25-alpine` digest pin, with
`apk add --no-cache make` only (`git`, `gcc`, `musl-dev` all dropped).
First line after `WORKDIR /src` is
`COPY --from=lint /src/go.sum /dev/null` so BuildKit cannot run the two
stages in parallel. Then `go mod download`, `COPY backend/ .`,
`RUN make test`, `ARG VERSION=dev`, and the build.
3. **Runtime** — unchanged in substance: `alpine:3.23` by digest,
`ca-certificates`, `EXPOSE 8080`, `ENTRYPOINT ["netwatch-server"]`.
`COPY .git /repo/.git` is deleted. Every `FROM` keeps a version + date comment
above its `@sha256:` pin.
### `backend/Makefile`
- `VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)`
— `?=` so `ARG VERSION` can drive it from the Dockerfile, the brace-group
redirect so a missing `.git` (or a missing `git` binary) degrades to `dev`
instead of erroring or emitting stderr noise.
- Delete the `UNAME_S`/`ifeq (Darwin)` split and
`-linkmode external -extldflags -static`. The single build recipe becomes
`CGO_ENABLED=0 go build -trimpath -ldflags "-s -w $(GOLDFLAGS)" ...`, which
is 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/Makefile` and have the build
stage run `make build VERSION=${VERSION}`. Same command, same flags, same
`-X main.Version=${VERSION}`, but it keeps one build definition instead of two
divergent 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 silently
drop (that would be an application-behaviour change, which this issue
forbids). The docker build log will show the fully expanded `go build` line,
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-cache` on that
single build — **no** `docker builder prune`, shared cache is not mine to
destroy), timed, must be under 5 minutes.
- **Lint gate proof:** introduce a deliberate lint error, show the build
**fails** and that it fails in the `lint` stage before the builder's
`make test` ever runs, then revert. Green CI is not accepted as evidence
here (#37).
- Static-binary proof: `file`/`ldd` on the artifact plus actually running the
runtime image and hitting it, to confirm dropping the CGO static flags did
not produce a dynamically linked binary.
- No-`.git` proof: build from a tree with `.git` removed entirely and show
`make build` and the image build both succeed.
- Root `make check` and `cd backend && make check`.
- `make fmt` over the touched markdown.
### Scope
`TODO.md` gets one additive line in the same commit (three other PRs touch
that file). `backend/.golangci.yml` is not touched. `.dockerignore` is not
touched — removing `.git` from the context is #36, and it stays blocked on the
frontend `Dockerfile`, whose `vite.config.js` shells out to
`git rev-parse HEAD` at config-eval time; the PR will state whether this makes
#36 easier. Nothing else changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
clawbot referenced this issue2026-09-04 00:27:52 +02:00