Rework Dockerfile.backend to the mandated Go multistage lint-stage pattern #17

Closed
opened 2026-08-09 03:38:14 +02:00 by clawbot · 1 comment
Collaborator

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 adds git make gcc musl-dev, go installs 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.
## 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
Author
Collaborator

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 lintFROM 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.

## 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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/netwatch#17