Coverage is unchanged in total: main's single make check was
test + lint + fmt-check; that is now fmt-check + lint in the lint stage and
test in the builder. Runtime stage, EXPOSE 8080 and the entrypoint are
untouched. No Go source, route or application behaviour was changed.
backend/Makefile:
VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)
— overridable, and the brace-group redirect means a missing .gitor a
missing git binary degrades to dev silently instead of printing fatal: not a git repository and stamping an empty version.
the UNAME_S / ifeq (Darwin) split and -linkmode external -extldflags -static are gone; one recipe, CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=… -X main.Buildarch=…".
-s -w added to GOLDFLAGS (policy pattern; also shrinks the binary).
Two deviations from the reference Dockerfile, both deliberate
1. The build runs through make, not an inline go build. The reference
Dockerfile in REPO_POLICIES.md writes RUN CGO_ENABLED=0 go build -trimpath -ldflags=… directly. The same document
also says "Always use Makefile targets instead of invoking the underlying tools
directly. The Makefile is the single source of truth for how these operations
are run", and an inline copy would (a) create a second, divergable definition
of the build command and (b) silently drop the existing -X main.Buildarch=$(BUILDARCH) ldflag, which is an application-behaviour
change this issue forbids. So the exact mandated flags live in the Makefile and
the build stage runs RUN VERSION="${VERSION}" make build. This is not an
assertion — the build log expands it in full:
#24 [builder 9/9] RUN VERSION="dev" make build
#24 0.161 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=dev -X main.Buildarch=x86_64" \
#24 0.161 -o ./netwatch-server ./cmd/netwatch-server/
VERSION is passed in the environment rather than as make build VERSION=…
on purpose: both work against the Makefile as it stands, but only the
environment form keeps working unchanged if build is later turned into a shim
around a script — which is exactly what #38 does. See the reconciliation notes.
2. build is no longer an incremental file target.main had ./netwatch-server: $(shell find . -name '*.go' -type f) go.mod go.sum. With VERSION now an input, that rule is actively wrong: make build VERSION=b
after make build VERSION=a is a no-op and yields a binary stamped a. build is therefore phony and always compiles; Go's build cache makes the
no-op case ~0.1s.
Verification
Everything below was run in a scratch clone (#33 makes worktrees unusable
for make docker), through make targets and script/ entrypoints only — no
raw go, gofmt, yarn, prettier or golangci-lint. Every container is --rm; nothing is left running on the host. No BuildKit cache was pruned
— uncached builds used --no-cache on the single build.
1. Uncached build, timed
docker build --no-cache -f Dockerfile.backend . at bd2bc9f: exit 0 in
48 s (repeated runs 48–61 s), well inside the 5-minute budget. grep -c CACHED is 3, and all three are the two base-image FROM resolutions plus
one WORKDIR metadata step — zero cached RUN layers. Every RUN
executed for real:
#12 [stage-2 2/3] RUN apk add --no-cache ca-certificates
#13 [lint 4/7] RUN go mod download
#14 [builder 2/9] RUN apk add --no-cache make
#17 [lint 6/7] RUN make fmt-check
#18 [lint 7/7] RUN make lint
#21 [builder 6/9] RUN go mod download
#23 [builder 8/9] RUN make test
#24 [builder 9/9] RUN VERSION="dev" make build
with real output underneath them (0 issues. from the linter, per-package ok/no test files from go test, the expanded go build line above).
Per #37 a green CI tick is not evidence, so none is claimed.
2. The lint stage actually gates the build
A lint-only defect was appended to backend/internal/server/routes.go: a
201-character comment line. It compiles and make test passes locally, so any
build failure is unambiguously the linter and not the compiler.
(a) With COPY --from=lint present — build FAILS, exit 1:
#18 [lint 7/7] RUN make lint
#18 11.53 internal/server/routes.go:33:1: The line is 201 characters long, which exceeds the maximum of 120 characters. (lll)
#18 11.55 make: *** [Makefile:25: lint] Error 1
#18 ERROR: process "/bin/sh -c make lint" did not complete successfully: exit code: 2
ERROR: failed to build: failed to solve: process "/bin/sh -c make lint" did not complete successfully: exit code: 2
and it fails fast: the builder never got past step 3 of 9. Grepping the log
for a [builder …] RUN make test line returns 0 matches — compilation and
tests never started.
(b) Counterfactual, the same tree with only that one line deleted from the
Dockerfile — build SUCCEEDS, exit 0. With COPY --from=lint /src/go.sum /dev/null removed, nothing references the lint stage, so BuildKit does not run
it at all (grep -c '\[lint …\] RUN make lint' → 0) and the image with the
lint error in it exports green. That is the whole point of the line, and it is
now demonstrated in both directions rather than asserted.
routes.go was then restored byte-identical (git status --short shows only
the three intended files).
3. The binary is still static, and still runs
Dropping -linkmode external -extldflags -static did not cost us the static
link — CGO_ENABLED=0 gives it for free:
on the host: ELF 64-bit LSB executable, x86-64 … statically linked … stripped
inside the alpine runtime image: ldd /usr/local/bin/netwatch-server → Not a valid dynamic program
the runtime image actually serves: GET /.well-known/healthcheck → HTTP 200, {"appname":"netwatch-server","status":"ok",…,"version":"dev"}
ARG VERSION is wired end to end: built with --build-arg VERSION=1.2.3-test,
the running container reports "version":"1.2.3-test".
4. .git is genuinely no longer required
Built from a context tarred up without.git:
Dockerfile
result
main's
fails: failed to compute cache key … "/.git": not found
this branch's
exit 0 in 48 s, uncached, stamping -X main.Version=dev
And at the Makefile level, in a tree with no .git:
result
main's backend/Makefile
fatal: not a git repository (or any of the parent directories): .git, then builds with -X main.Version= — an empty version, silently
this branch's
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=dev …", exit 0, no stderr noise
Also exercised with the gitbinary removed from PATH entirely
(env -i PATH=<shim dir with no git>): exit 0, main.Version=dev.
5. Gates
root make check — exit 0
cd backend && make check — exit 0 (0 issues.)
cd backend && make docker — exit 0 (the make-target path to this image)
make fmt run over the touched markdown; git status --short clean after
committing.
Reconciliation with the three open merge-ready PRs
This branch is cut from main and is coherent against main. It
deliberately does not pre-merge or anticipate any of the three. Below is what
whoever merges second has to do, per PR, per file.
Dockerfile.backend — guaranteed conflict, one hunk, mechanical. #31 retargets the golangci-lint pin from 9f61b0f53f80672872fced07b6874397c3ed197b
(v2.7.2) to c0d3ddc9cf3faa61a4e378e879ece580256d76e5 (v2.12.2) on the RUN CGO_ENABLED=0 go install … line. That line does not exist any more —
the linter comes from the image, not from go install. So #31's Dockerfile
hunk does not rebase; it must be replaced by editing the two lines at the top
of the lint stage.
This branch pins the image whose --version reports exactly the commit main
already pins, so nothing regresses #14/#31:
$ docker run --rm golangci/golangci-lint@sha256:5d6d5c70…368ba golangci-lint --version
golangci-lint has version 2.7.2 built with go1.25.4 from 9f61b0f5 on 2025-12-07T16:57:12Z
to the v2.12.2 image. On 2026-08-09 the v2.12.2 tag of docker.io/golangci/golangci-lint resolved to sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, and
that image reports has version 2.12.2 built with go1.26.5 from c0d3ddc9 —
i.e. #31's commit. Re-resolve and re-verify that digest at merge time rather
than trusting this paragraph; a digest quoted in a PR body is not a pin.
backend/Makefile — textual conflict only, no semantic one.#31 rewrites
the lint recipe to add the .golangci.yml sha256 drift guard. This branch
does not touch lint at all; it rewrites the variable header (VERSION, GOLDFLAGS, removal of the ifeq) and the build recipe. Take both sides:
this branch's header and build, #31's guarded lint. Git may well
auto-merge it.
One consequence worth knowing: after this branch, make lint runs in the lint stage, not the builder, so #31's guard now executes on Debian trixie
rather than alpine. I checked that image: /usr/bin/sha256sum is present, so
the guard's primary path works there; its shasum -a 256 fallback is not
needed.
Neither PR touches backend/.golangci.yml except #31. I did not open that
file. What the second merge must re-verify is the pair: this branch proves v2.7.2 + main's config lints clean through the lint stage; #31 proves v2.12.2 + the canonical config lints clean through main's builder. Nobody
has yet proven v2.12.2 + canonical config through the lint stage, so run docker build --no-cache -f Dockerfile.backend . once after reconciling.
TODO.md — both add to Completed Steps; #31 additionally rewrites Status
and Next Step. Mine is a single bullet at the top of Completed Steps. Keep both
bullets; take #31's Status/Next Step rewrite.
No code overlap. It touches .editorconfig, .gitignore, TODO.md; the
only shared file is TODO.md, and both edits are additive lines in Completed
Steps. No conflict expected beyond a trivial one.
One thing to be aware of rather than to fix: #35 moves backend/.editorconfig → .editorconfig at the repo root. Both this branch's lint and builder stages copy only backend/, so after #35 the .editorconfig is outside the backend build context. Harmless — nothing in the
build reads it — but noting it so it is not mistaken for a regression later.
Dockerfile.backend — no conflict. I checked #38's changed-file list: it
does not touch Dockerfile.backend. Its PR body describes the backend image as
gated by "Dockerfile.backend's own RUN make check"; after this branch that
sentence is stale — the same coverage is RUN make fmt-check + RUN make lint
in the lint stage and RUN make test in the builder. That is prose in #38's
description and in backend/README.md, not code, but it should be corrected in
the second merge so the docs do not describe a step that no longer exists.
backend/Makefile — hard conflict, and one silent-failure trap.#38
replaces every recipe with a shim (build: @script/build) and moves the
implementation to backend/script/build. That script, at 1c16d50, still
contains what this issue exists to remove:
Resolution, whichever order: keep #38's shim backend/Makefile, and move this
branch's build semantics into backend/script/build, which must
honour an inherited VERSION — version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}".
This is the trap: Dockerfile.backend passes the version as RUN VERSION="${VERSION}" make build, and if script/build ignores the
environment the ARG VERSION silently stops reaching the binary and every
image is stamped unknown with nothing failing. I chose the environment
form specifically so the Dockerfile line itself needs no edit in that merge;
the script is the only thing that has to change.
drop the -linkmode external -extldflags -static branch and the uname -s
test, and build CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=$version -X main.Buildarch=$buildarch".
Leaving the static branch in would reintroduce the CGO dependency into a
builder that no longer installs gcc/musl-dev, and that build will
fail, so this one fails loudly rather than silently.
align the fallback string on dev, matching ARG VERSION=dev, rather than unknown.
Also: #38's backend/script/lint and backend/script/fmt-check become what
the lint stage runs. They are #!/bin/sh with no bashisms and use sha256sum/gofmt, all of which exist in the golangci-lint image (Debian
trixie), so they run there unmodified.
script/bootstrap — no new drift.#38 pins GOLANGCI_LINT_VERSION=2.7.2
to match Dockerfile.backend, with a comment naming #31. This branch keeps the
same linter version, so that pin still agrees with the Dockerfile; it moves to 2.12.2 at the same time as the image digest, in the #31 reconciliation above.
.gitea/workflows/check.yml — untouched here; #38 removes the second raw docker build -f Dockerfile.backend . step. No conflict.
Yes, for the backend half — that half is now done, not merely easier. #36
wants .git out of the build context entirely. Dockerfile.backend no longer
copies or needs it: proven above by building from a context with no .git at
all, uncached, exit 0. Once .git is added to .dockerignore, the backend
image is unaffected.
#36 stays blocked, though, and this changes nothing about why: the frontend Dockerfile's build stage evaluates vite.config.js, which calls execSync("git rev-parse HEAD") at config-eval time, so .git in .dockerignore breaks that build. Whoever takes #36 needs the same treatment
there — a build arg with a fallback — and after that the .dockerignore line
is a one-liner. I did not touch the frontend, per scope.
Not fixed, and out of scope. Incidentally improved on the docker half only: make docker for the backend previously did COPY .git, which in a worktree
copies a .gitfile pointing at a gitdir that does not exist in the
container. That failure mode is gone because nothing copies .git any more. make hooks in backend/Makefile still writes to $(git rev-parse --show-toplevel)/.git/hooks/pre-commit and still breaks in a
worktree; untouched here. All work on this PR was done in a scratch clone.
Out of scope, noticed, not fixed
Running cd backend && make check on a host with golangci-lint v2.12.2
(the version #31 moves to) prints The linter 'gomodguard' is deprecated (since v2.12.0) … Replaced by gomodguard_v2.
It does not appear in this build, which pins v2.7.2, and it is not a failure.
Filed separately rather than fixed drive-by.
Closes #17. Branched from `main` at `fbfe1df`; head is `bd2bc9f`. Three files:
`Dockerfile.backend`, `backend/Makefile`, `TODO.md`.
## What changed
`Dockerfile.backend` becomes the three-stage shape `REPO_POLICIES.md` mandates:
| | before (`main`) | after |
| --- | --- | --- |
| linting | `RUN make check` inside the builder, against a golangci-lint compiled from source by `go install` on every cache miss | `AS lint` stage on the prebuilt, digest-pinned `golangci/golangci-lint` image; `RUN make fmt-check` then `RUN make lint` |
| stage ordering | n/a | `COPY --from=lint /src/go.sum /dev/null` in the builder |
| tests | `make check` in the builder | `RUN make test` in the builder |
| version source | `COPY .git /repo/.git` so `git describe` resolves | `ARG VERSION=dev`, handed to the build in the environment |
| linking | `gcc` + `musl-dev`, `-linkmode external -extldflags -static` | `CGO_ENABLED=0 go build -trimpath`, no C toolchain |
| builder packages | `git make gcc musl-dev` | `make` |
| workdir | `/repo/backend` | `/src` |
Coverage is unchanged in total: `main`'s single `make check` was
test + lint + fmt-check; that is now fmt-check + lint in the `lint` stage and
test in the builder. Runtime stage, `EXPOSE 8080` and the entrypoint are
untouched. No Go source, route or application behaviour was changed.
`backend/Makefile`:
- `VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)`
— overridable, and the brace-group redirect means a missing `.git` **or** a
missing `git` binary degrades to `dev` silently instead of printing
`fatal: not a git repository` and stamping an empty version.
- the `UNAME_S` / `ifeq (Darwin)` split and
`-linkmode external -extldflags -static` are gone; one recipe,
`CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=… -X main.Buildarch=…"`.
- `-s -w` added to `GOLDFLAGS` (policy pattern; also shrinks the binary).
### Two deviations from the reference Dockerfile, both deliberate
**1. The build runs through `make`, not an inline `go build`.** The reference
Dockerfile in `REPO_POLICIES.md` writes
`RUN CGO_ENABLED=0 go build -trimpath -ldflags=…` directly. The same document
also says "Always use Makefile targets instead of invoking the underlying tools
directly. The Makefile is the single source of truth for how these operations
are run", and an inline copy would (a) create a second, divergable definition
of the build command and (b) silently drop the existing
`-X main.Buildarch=$(BUILDARCH)` ldflag, which is an application-behaviour
change this issue forbids. So the exact mandated flags live in the Makefile and
the build stage runs `RUN VERSION="${VERSION}" make build`. This is not an
assertion — the build log expands it in full:
```
#24 [builder 9/9] RUN VERSION="dev" make build
#24 0.161 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=dev -X main.Buildarch=x86_64" \
#24 0.161 -o ./netwatch-server ./cmd/netwatch-server/
```
`VERSION` is passed in the **environment** rather than as `make build VERSION=…`
on purpose: both work against the Makefile as it stands, but only the
environment form keeps working unchanged if `build` is later turned into a shim
around a script — which is exactly what #38 does. See the reconciliation notes.
**2. `build` is no longer an incremental file target.** `main` had
`./netwatch-server: $(shell find . -name '*.go' -type f) go.mod go.sum`. With
`VERSION` now an input, that rule is actively wrong: `make build VERSION=b`
after `make build VERSION=a` is a no-op and yields a binary stamped `a`.
`build` is therefore phony and always compiles; Go's build cache makes the
no-op case ~0.1s.
## Verification
Everything below was run in a scratch **clone** (#33 makes worktrees unusable
for `make docker`), through `make` targets and `script/` entrypoints only — no
raw `go`, `gofmt`, `yarn`, `prettier` or `golangci-lint`. Every container is
`--rm`; nothing is left running on the host. **No BuildKit cache was pruned**
— uncached builds used `--no-cache` on the single build.
### 1. Uncached build, timed
`docker build --no-cache -f Dockerfile.backend .` at `bd2bc9f`: **exit 0 in
48 s** (repeated runs 48–61 s), well inside the 5-minute budget. `grep -c
CACHED` is **3**, and all three are the two base-image `FROM` resolutions plus
one `WORKDIR` metadata step — **zero cached `RUN` layers**. Every `RUN`
executed for real:
```
#12 [stage-2 2/3] RUN apk add --no-cache ca-certificates
#13 [lint 4/7] RUN go mod download
#14 [builder 2/9] RUN apk add --no-cache make
#17 [lint 6/7] RUN make fmt-check
#18 [lint 7/7] RUN make lint
#21 [builder 6/9] RUN go mod download
#23 [builder 8/9] RUN make test
#24 [builder 9/9] RUN VERSION="dev" make build
```
with real output underneath them (`0 issues.` from the linter, per-package
`ok`/`no test files` from `go test`, the expanded `go build` line above).
Per #37 a green CI tick is not evidence, so none is claimed.
### 2. The lint stage actually gates the build
A lint-only defect was appended to `backend/internal/server/routes.go`: a
201-character comment line. It compiles and `make test` passes locally, so any
build failure is unambiguously the linter and not the compiler.
**(a) With `COPY --from=lint` present — build FAILS, exit 1:**
```
#18 [lint 7/7] RUN make lint
#18 11.53 internal/server/routes.go:33:1: The line is 201 characters long, which exceeds the maximum of 120 characters. (lll)
#18 11.55 make: *** [Makefile:25: lint] Error 1
#18 ERROR: process "/bin/sh -c make lint" did not complete successfully: exit code: 2
ERROR: failed to build: failed to solve: process "/bin/sh -c make lint" did not complete successfully: exit code: 2
```
and it fails **fast**: the builder never got past step 3 of 9. Grepping the log
for a `[builder …] RUN make test` line returns **0** matches — compilation and
tests never started.
**(b) Counterfactual, the same tree with only that one line deleted from the
Dockerfile — build SUCCEEDS, exit 0.** With `COPY --from=lint /src/go.sum
/dev/null` removed, nothing references the lint stage, so BuildKit does not run
it at all (`grep -c '\[lint …\] RUN make lint'` → **0**) and the image with the
lint error in it exports green. That is the whole point of the line, and it is
now demonstrated in both directions rather than asserted.
`routes.go` was then restored byte-identical (`git status --short` shows only
the three intended files).
### 3. The binary is still static, and still runs
Dropping `-linkmode external -extldflags -static` did not cost us the static
link — `CGO_ENABLED=0` gives it for free:
- on the host: `ELF 64-bit LSB executable, x86-64 … statically linked … stripped`
- inside the alpine runtime image:
`ldd /usr/local/bin/netwatch-server` → `Not a valid dynamic program`
- the runtime image actually serves:
`GET /.well-known/healthcheck` → **HTTP 200**,
`{"appname":"netwatch-server","status":"ok",…,"version":"dev"}`
`ARG VERSION` is wired end to end: built with `--build-arg VERSION=1.2.3-test`,
the running container reports `"version":"1.2.3-test"`.
### 4. `.git` is genuinely no longer required
Built from a context tarred up **without** `.git`:
| Dockerfile | result |
| --- | --- |
| `main`'s | **fails**: `failed to compute cache key … "/.git": not found` |
| this branch's | **exit 0 in 48 s**, uncached, stamping `-X main.Version=dev` |
And at the Makefile level, in a tree with no `.git`:
| | result |
| --- | --- |
| `main`'s `backend/Makefile` | `fatal: not a git repository (or any of the parent directories): .git`, then builds with `-X main.Version=` — an **empty** version, silently |
| this branch's | `CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=dev …"`, exit 0, no stderr noise |
Also exercised with the `git` **binary** removed from `PATH` entirely
(`env -i PATH=<shim dir with no git>`): exit 0, `main.Version=dev`.
### 5. Gates
- root `make check` — **exit 0**
- `cd backend && make check` — **exit 0** (`0 issues.`)
- `cd backend && make docker` — **exit 0** (the make-target path to this image)
- `make fmt` run over the touched markdown; `git status --short` clean after
committing.
## Reconciliation with the three open merge-ready PRs
This branch is cut from `main` and is coherent **against `main`**. It
deliberately does not pre-merge or anticipate any of the three. Below is what
whoever merges second has to do, per PR, per file.
### PR #31 — `feat/golangci-standard-config` (`4d70317`)
**`Dockerfile.backend` — guaranteed conflict, one hunk, mechanical.**
#31 retargets the golangci-lint pin from `9f61b0f53f80672872fced07b6874397c3ed197b`
(v2.7.2) to `c0d3ddc9cf3faa61a4e378e879ece580256d76e5` (v2.12.2) on the
`RUN CGO_ENABLED=0 go install …` line. **That line does not exist any more** —
the linter comes from the image, not from `go install`. So #31's Dockerfile
hunk does not rebase; it must be replaced by editing the two lines at the top
of the `lint` stage.
This branch pins the image whose `--version` reports exactly the commit `main`
already pins, so nothing regresses #14/#31:
```
$ docker run --rm golangci/golangci-lint@sha256:5d6d5c70…368ba golangci-lint --version
golangci-lint has version 2.7.2 built with go1.25.4 from 9f61b0f5 on 2025-12-07T16:57:12Z
```
Whichever lands second changes:
```dockerfile
# golangci/golangci-lint:v2.7.2 (2026-08-09)
FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba AS lint
```
to the v2.12.2 image. On 2026-08-09 the `v2.12.2` tag of
`docker.io/golangci/golangci-lint` resolved to
`sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, and
that image reports `has version 2.12.2 built with go1.26.5 from c0d3ddc9` —
i.e. #31's commit. **Re-resolve and re-verify that digest at merge time rather
than trusting this paragraph**; a digest quoted in a PR body is not a pin.
**`backend/Makefile` — textual conflict only, no semantic one.** #31 rewrites
the `lint` recipe to add the `.golangci.yml` sha256 drift guard. This branch
does not touch `lint` at all; it rewrites the variable header (`VERSION`,
`GOLDFLAGS`, removal of the `ifeq`) and the `build` recipe. Take both sides:
this branch's header and `build`, #31's guarded `lint`. Git may well
auto-merge it.
One consequence worth knowing: after this branch, `make lint` runs in the
**lint stage**, not the builder, so #31's guard now executes on Debian trixie
rather than alpine. I checked that image: `/usr/bin/sha256sum` is present, so
the guard's primary path works there; its `shasum -a 256` fallback is not
needed.
**Neither PR touches `backend/.golangci.yml` except #31.** I did not open that
file. What the second merge must re-verify is the **pair**: this branch proves
`v2.7.2` + `main`'s config lints clean through the lint stage; #31 proves
`v2.12.2` + the canonical config lints clean through `main`'s builder. Nobody
has yet proven `v2.12.2` + canonical config **through the lint stage**, so run
`docker build --no-cache -f Dockerfile.backend .` once after reconciling.
**`TODO.md`** — both add to Completed Steps; #31 additionally rewrites Status
and Next Step. Mine is a single bullet at the top of Completed Steps. Keep both
bullets; take #31's Status/Next Step rewrite.
### PR #35 — `chore/dotfile-compliance` (`4a7bdf8`)
**No code overlap.** It touches `.editorconfig`, `.gitignore`, `TODO.md`; the
only shared file is `TODO.md`, and both edits are additive lines in Completed
Steps. No conflict expected beyond a trivial one.
One thing to be aware of rather than to fix: #35 moves
`backend/.editorconfig` → `.editorconfig` at the repo root. Both this branch's
`lint` and `builder` stages copy only `backend/`, so after #35 the
`.editorconfig` is outside the backend build context. Harmless — nothing in the
build reads it — but noting it so it is not mistaken for a regression later.
### PR #38 — `fix/unify-check-gate` (`1c16d50`)
**`Dockerfile.backend` — no conflict.** I checked #38's changed-file list: it
does not touch `Dockerfile.backend`. Its PR body describes the backend image as
gated by "`Dockerfile.backend`'s own `RUN make check`"; after this branch that
sentence is stale — the same coverage is `RUN make fmt-check` + `RUN make lint`
in the `lint` stage and `RUN make test` in the builder. That is prose in #38's
description and in `backend/README.md`, not code, but it should be corrected in
the second merge so the docs do not describe a step that no longer exists.
**`backend/Makefile` — hard conflict, and one silent-failure trap.** #38
replaces every recipe with a shim (`build: @script/build`) and moves the
implementation to `backend/script/build`. That script, at `1c16d50`, still
contains what this issue exists to remove:
```sh
version="$(git describe --always --dirty 2>/dev/null || echo unknown)"
…
if [ "$(uname -s)" != "Darwin" ]; then
ldflags="-linkmode external -extldflags -static $ldflags"
fi
go build -o "$BINARY" -ldflags "$ldflags" ./cmd/netwatch-server/
```
Resolution, whichever order: keep #38's shim `backend/Makefile`, and move this
branch's build semantics into `backend/script/build`, which must
1. **honour an inherited `VERSION`** —
`version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}"`.
This is the trap: `Dockerfile.backend` passes the version as
`RUN VERSION="${VERSION}" make build`, and if `script/build` ignores the
environment the `ARG VERSION` silently stops reaching the binary and every
image is stamped `unknown` with nothing failing. I chose the environment
form specifically so the Dockerfile line itself needs no edit in that merge;
the script is the only thing that has to change.
2. drop the `-linkmode external -extldflags -static` branch and the `uname -s`
test, and build
`CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=$version -X main.Buildarch=$buildarch"`.
Leaving the static branch in would reintroduce the CGO dependency into a
builder that no longer installs `gcc`/`musl-dev`, and that build **will**
fail, so this one fails loudly rather than silently.
3. align the fallback string on `dev`, matching `ARG VERSION=dev`, rather than
`unknown`.
Also: #38's `backend/script/lint` and `backend/script/fmt-check` become what
the `lint` stage runs. They are `#!/bin/sh` with no bashisms and use
`sha256sum`/`gofmt`, all of which exist in the golangci-lint image (Debian
trixie), so they run there unmodified.
**`script/bootstrap` — no new drift.** #38 pins `GOLANGCI_LINT_VERSION=2.7.2`
to match `Dockerfile.backend`, with a comment naming #31. This branch keeps the
same linter version, so that pin still agrees with the Dockerfile; it moves to
`2.12.2` at the same time as the image digest, in the #31 reconciliation above.
**`.gitea/workflows/check.yml`** — untouched here; #38 removes the second raw
`docker build -f Dockerfile.backend .` step. No conflict.
**`TODO.md`** — additive on both sides.
## Does this make #36 easier?
Yes, for the backend half — that half is now **done**, not merely easier. #36
wants `.git` out of the build context entirely. `Dockerfile.backend` no longer
copies or needs it: proven above by building from a context with no `.git` at
all, uncached, exit 0. Once `.git` is added to `.dockerignore`, the backend
image is unaffected.
#36 stays blocked, though, and this changes nothing about why: the **frontend**
`Dockerfile`'s build stage evaluates `vite.config.js`, which calls
`execSync("git rev-parse HEAD")` at config-eval time, so `.git` in
`.dockerignore` breaks that build. Whoever takes #36 needs the same treatment
there — a build arg with a fallback — and after that the `.dockerignore` line
is a one-liner. I did not touch the frontend, per scope.
## #33 (worktree `.git` is a file)
Not fixed, and out of scope. Incidentally improved on the docker half only:
`make docker` for the backend previously did `COPY .git`, which in a worktree
copies a `.git` **file** pointing at a gitdir that does not exist in the
container. That failure mode is gone because nothing copies `.git` any more.
`make hooks` in `backend/Makefile` still writes to
`$(git rev-parse --show-toplevel)/.git/hooks/pre-commit` and still breaks in a
worktree; untouched here. All work on this PR was done in a scratch clone.
## Out of scope, noticed, not fixed
Running `cd backend && make check` on a host with golangci-lint **v2.12.2**
(the version #31 moves to) prints
`The linter 'gomodguard' is deprecated (since v2.12.0) … Replaced by gomodguard_v2`.
It does not appear in this build, which pins v2.7.2, and it is not a failure.
Filed separately rather than fixed drive-by.
Head bd2bc9f, branched from main at fbfe1df. One commit, three files: Dockerfile.backend, backend/Makefile, TODO.md (the TODO edit is in the
same commit, one additive bullet, because #31/#35/#38 all touch that file too).
Dockerfile.backend is now lint → builder → runtime. Linting moved out of
the builder into a lint stage on the digest-pinned golangci/golangci-lint
image, which already ships Go, gofmt, make and the linter, so the go install-from-source of golangci-lint is gone. COPY --from=lint /src/go.sum /dev/null chains the stages. The builder installs only make,
runs make test, and builds from ARG VERSION=dev. COPY .git /repo/.git is
deleted, and with the CGO static-link flags dropped from backend/Makefile, gcc and musl-dev go with them. All three FROMs are @sha256: with a
version + date comment; the two unchanged pins keep their original 2026-02-27
dates, since the digests were not re-pinned (both tags have since moved, which
is the point of pinning).
The linter version is unchanged from main on purpose: the image I pinned
reports version 2.7.2 built with go1.25.4 from 9f61b0f5, the exact commit main pins. #31's v2.12.2 is not pre-merged; the PR body has the
per-file, per-PR reconciliation for #31, #35 and #38, including the v2.12.2
image digest to substitute and an instruction to re-resolve it rather than
trust it.
How it was verified
Scratch clone, not a worktree (#33). make targets and script/ entrypoints
only — no raw go, gofmt, yarn, prettier or golangci-lint. Containers
all --rm, none left running. No BuildKit cache pruned; uncached builds used --no-cache on the single build.
Uncached build: exit 0 in 48 s. 3 CACHED lines, all of them base FROM resolutions plus one WORKDIR — zero cached RUN layers, with real
linter/test/compile output in the log. No CI tick is offered as evidence
(#37).
The lint stage really gates, shown both ways with a 201-char comment
line added to routes.go (compiles fine, tests pass, so only the linter can
object): withCOPY --from=lint the build fails at [lint 7/7] RUN make lint and the builder never reaches make test; without that one line the identical tree builds green and the lint
stage is never executed at all.
Still static, still runs: Not a valid dynamic program from ldd
inside the alpine runtime stage, and the container answers GET /.well-known/healthcheck with 200. --build-arg VERSION=1.2.3-test
comes out as "version":"1.2.3-test" at runtime.
No .git needed: from a context with .git removed, main's
Dockerfile fails on "/.git": not found while this one builds uncached in
48 s. At the Makefile level, main's emits fatal: not a git repository and
stamps an empty version; this one stamps dev silently, including with
the git binary absent from PATH entirely.
Root make check0, cd backend && make check0, cd backend && make docker0, make fmt run over the touched markdown.
Two deviations from the reference Dockerfile are argued in the PR body: the
build goes through make build (so there is one definition of the build
command, and -X main.Buildarch is not silently dropped) with VERSION passed
in the environment so it survives #38 turning that target into a shim; and build is no longer an incremental file target, which is now wrong given VERSION is an input.
Backend half of #36 is effectively done — nothing copies or needs .git — but #36 stays blocked on the frontend Dockerfile, whose vite.config.js calls execSync("git rev-parse HEAD") at config-eval time. Not touched, per scope.
One out-of-scope observation was filed as #41 rather than fixed here: under
golangci-lint v2.12.2 the config's default: all pulls in gomodguard, which
that version deprecates in favour of gomodguard_v2.
## Summary
Head `bd2bc9f`, branched from `main` at `fbfe1df`. One commit, three files:
`Dockerfile.backend`, `backend/Makefile`, `TODO.md` (the TODO edit is in the
same commit, one additive bullet, because #31/#35/#38 all touch that file too).
`Dockerfile.backend` is now `lint` → `builder` → runtime. Linting moved out of
the builder into a `lint` stage on the digest-pinned `golangci/golangci-lint`
image, which already ships Go, `gofmt`, `make` and the linter, so the
`go install`-from-source of golangci-lint is gone. `COPY --from=lint
/src/go.sum /dev/null` chains the stages. The builder installs only `make`,
runs `make test`, and builds from `ARG VERSION=dev`. `COPY .git /repo/.git` is
deleted, and with the CGO static-link flags dropped from `backend/Makefile`,
`gcc` and `musl-dev` go with them. All three `FROM`s are `@sha256:` with a
version + date comment; the two unchanged pins keep their original `2026-02-27`
dates, since the digests were not re-pinned (both tags have since moved, which
is the point of pinning).
The linter version is unchanged from `main` on purpose: the image I pinned
reports `version 2.7.2 built with go1.25.4 from 9f61b0f5`, the exact commit
`main` pins. #31's v2.12.2 is **not** pre-merged; the PR body has the
per-file, per-PR reconciliation for #31, #35 and #38, including the v2.12.2
image digest to substitute and an instruction to re-resolve it rather than
trust it.
## How it was verified
Scratch clone, not a worktree (#33). `make` targets and `script/` entrypoints
only — no raw `go`, `gofmt`, `yarn`, `prettier` or `golangci-lint`. Containers
all `--rm`, none left running. No BuildKit cache pruned; uncached builds used
`--no-cache` on the single build.
- **Uncached build**: exit 0 in **48 s**. 3 `CACHED` lines, all of them base
`FROM` resolutions plus one `WORKDIR` — zero cached `RUN` layers, with real
linter/test/compile output in the log. No CI tick is offered as evidence
(#37).
- **The lint stage really gates**, shown both ways with a 201-char comment
line added to `routes.go` (compiles fine, tests pass, so only the linter can
object): **with** `COPY --from=lint` the build **fails** at
`[lint 7/7] RUN make lint` and the builder never reaches `make test`;
**without** that one line the identical tree builds **green** and the lint
stage is never executed at all.
- **Still static, still runs**: `Not a valid dynamic program` from `ldd`
inside the alpine runtime stage, and the container answers
`GET /.well-known/healthcheck` with **200**. `--build-arg VERSION=1.2.3-test`
comes out as `"version":"1.2.3-test"` at runtime.
- **No `.git` needed**: from a context with `.git` removed, `main`'s
Dockerfile fails on `"/.git": not found` while this one builds uncached in
48 s. At the Makefile level, `main`'s emits `fatal: not a git repository` and
stamps an **empty** version; this one stamps `dev` silently, including with
the `git` binary absent from `PATH` entirely.
- Root `make check` **0**, `cd backend && make check` **0**,
`cd backend && make docker` **0**, `make fmt` run over the touched markdown.
Two deviations from the reference Dockerfile are argued in the PR body: the
build goes through `make build` (so there is one definition of the build
command, and `-X main.Buildarch` is not silently dropped) with `VERSION` passed
in the **environment** so it survives #38 turning that target into a shim; and
`build` is no longer an incremental file target, which is now wrong given
`VERSION` is an input.
Backend half of #36 is effectively done — nothing copies or needs `.git` — but
#36 stays blocked on the frontend `Dockerfile`, whose `vite.config.js` calls
`execSync("git rev-parse HEAD")` at config-eval time. Not touched, per scope.
One out-of-scope observation was filed as #41 rather than fixed here: under
golangci-lint v2.12.2 the config's `default: all` pulls in `gomodguard`, which
that version deprecates in favour of `gomodguard_v2`.
Manager note — review INCOMPLETE, this PR is not cleared
The independent reviewer assigned to this PR terminated early on an API quota limit, partway through. It did not post a review comment and it did not reach a verdict.
Label stays needs-review, assignee stays clawbot. This PR has not passed review and must not be merged on the strength of what follows.
What the reviewer had confirmed before it died
One thing only, but it is the most important claim in the PR:
> Both directions of the lint gate confirmed.
That is the COPY --from=lint /src/go.sum /dev/null behaviour — with the line present a lint error fails the build, and without it the identical broken tree builds green while the lint stage never executes. Independently reproduced. That was the central requirement of #17 and the thing most likely to appear correct for the wrong reason, so having it confirmed by someone other than the author is worth recording.
It was mid-way through "restoring the tree and testing the runtime claims" when it stopped.
What remains UNVERIFIED by anyone but the author
Everything else, specifically:
The lint image digest sha256:5d6d5c70… genuinely being golangci-lint v2.7.2 and matching the commit 9f61b0f53f80672872fced07b6874397c3ed197b that main pins. A mismatch here would silently lint with a different ruleset than CI — it is the highest-value unverified item.
The binary still being static and actually running in the alpine runtime stage after gcc/musl-dev and the CGO static-link flags were dropped.
--build-arg VERSION=... reaching the binary.
Building from a context with no .git, and backend/Makefile stamping dev rather than failing or stamping empty.
The two deliberate deviations from the mandated pattern — RUN VERSION="${VERSION}" make build instead of an inline go build, and build no longer being an incremental file target — being sound rather than merely convenient.
The per-PR reconciliation notes for #31, #35 and #38, including the named silent-failure trap where #38's backend/script/build must honour an inherited VERSION or everything stamps unknown with nothing failing.
Scope, pinning of every FROM, one-commit hygiene, and the claim that #36's backend half is now fully done.
What happens next
A fresh reviewer picks this up when quota allows. The brief is unchanged; whoever takes it should treat the lint-gate result above as corroborating evidence rather than as settled, and re-derive it cheaply if convenient — one confirmation from a run that did not complete is weaker than one from a run that did.
Recording this explicitly because a PR sitting at needs-review with a manager comment on it could easily be mistaken for a reviewed PR. It is not.
## Manager note — review INCOMPLETE, this PR is not cleared
The independent reviewer assigned to this PR **terminated early** on an API quota limit, partway through. It did not post a review comment and it did not reach a verdict.
Label stays `needs-review`, assignee stays `clawbot`. **This PR has not passed review and must not be merged on the strength of what follows.**
### What the reviewer had confirmed before it died
One thing only, but it is the most important claim in the PR:
> Both directions of the lint gate confirmed.
That is the `COPY --from=lint /src/go.sum /dev/null` behaviour — with the line present a lint error fails the build, and without it the identical broken tree builds green while the lint stage never executes. Independently reproduced. That was the central requirement of #17 and the thing most likely to appear correct for the wrong reason, so having it confirmed by someone other than the author is worth recording.
It was mid-way through "restoring the tree and testing the runtime claims" when it stopped.
### What remains UNVERIFIED by anyone but the author
Everything else, specifically:
- The lint image digest `sha256:5d6d5c70…` genuinely being golangci-lint **v2.7.2** and matching the commit `9f61b0f53f80672872fced07b6874397c3ed197b` that `main` pins. A mismatch here would silently lint with a different ruleset than CI — it is the highest-value unverified item.
- The binary still being static and actually running in the alpine runtime stage after `gcc`/`musl-dev` and the CGO static-link flags were dropped.
- `--build-arg VERSION=...` reaching the binary.
- Building from a context with no `.git`, and `backend/Makefile` stamping `dev` rather than failing or stamping empty.
- The two deliberate deviations from the mandated pattern — `RUN VERSION="${VERSION}" make build` instead of an inline `go build`, and `build` no longer being an incremental file target — being sound rather than merely convenient.
- The per-PR reconciliation notes for #31, #35 and #38, including the named silent-failure trap where #38's `backend/script/build` must honour an inherited `VERSION` or everything stamps `unknown` with nothing failing.
- Scope, pinning of every `FROM`, one-commit hygiene, and the claim that #36's backend half is now fully done.
### What happens next
A fresh reviewer picks this up when quota allows. The brief is unchanged; whoever takes it should treat the lint-gate result above as corroborating evidence rather than as settled, and re-derive it cheaply if convenient — one confirmation from a run that did not complete is weaker than one from a run that did.
Recording this explicitly because a PR sitting at `needs-review` with a manager comment on it could easily be mistaken for a reviewed PR. It is not.
Independent review — head bd2bc9f, base main at fbfe1df
Verdict: PASS
Priorities 1 through 5 were all reached. Every functional claim in the PR body
was re-derived independently in a fresh scratch clone (not a worktree, per #33). No BuildKit cache was pruned; the one uncached build used --no-cache
scoped to that single build. All test images I created have been removed.
PRIORITY 1 — lint image digest (the highest-value unverified item)
Confirmed. Pulled and executed the exact digest in the Dockerfile:
$ docker run --rm golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba golangci-lint --version
golangci-lint has version 2.7.2 built with go1.25.4 from 9f61b0f5 on 2025-12-07T16:57:12Z
9f61b0f5 is the prefix of 9f61b0f53f80672872fced07b6874397c3ed197b, which
is exactly what main's Dockerfile.backend pins on its go install github.com/golangci/golangci-lint/v2/... line. The lint stage
therefore enforces the same linter build CI has been enforcing. No silent
ruleset change.
Also confirmed by building --target lint and inspecting the stage: /src/.golangci.yml (739 bytes) is present, so the repo's config — not
golangci-lint defaults — is what runs. go, gofmt (/usr/local/go/bin/gofmt)
and make (/usr/bin/make) all exist in the image, so the stage genuinely
installs nothing.
I also checked that make fmt-check is not vacuous in that image (a missing gofmt would make test -z "$(gofmt -l .)" pass silently). Injected a
misformatted file into the lint stage: make fmt-check printed Files not formatted: internal/server/revbadfmt.go and exited 2. Real gate.
Pinning. All three FROM lines are @sha256: with a version-and-date
comment above them (golangci/golangci-lint:v2.7.2 (2026-08-09), golang:1.25-alpine (2026-02-27), alpine:3.23 (2026-02-27)). The two
carried-over pins keep their original digests and dates, which is correct.
No unpinned or mutable reference anywhere in the diff.
PRIORITY 2 — functional claims
Uncached build.docker build --no-cache -f Dockerfile.backend --build-arg VERSION=1.2.3-rev40 .
→ exit 0 in 43.9 s, comfortably inside the 5-minute budget. CACHED
appears 3 times and all three are non-executing steps: the two base FROM
resolutions (#7, #9) and one WORKDIR metadata step (#8). Zero cached RUN layers. Every RUN produced real output — 0 issues. from the linter
(13.2 s), per-package ok/[no test files] from go test with no (cached) markers, and the fully expanded build line:
#24 [builder 9/9] RUN VERSION="1.2.3-rev40" make build
#24 0.182 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=1.2.3-rev40 -X main.Buildarch=x86_64" \
#24 0.182 -o ./netwatch-server ./cmd/netwatch-server/
CI is green on bd2bc9f (check / check (push), 16 s) but per #37 that is
not offered as evidence and nothing here rests on it.
Static binary, and it runs. Inside the alpine runtime stage: ldd /usr/local/bin/netwatch-server → Not a valid dynamic program
(exit 1). The ELF header is e_type = 2 (ET_EXEC), not ET_DYN. Ran the
image with --rm on a loopback-bound port:
GET /.well-known/healthcheck -> 200
{"appname":"netwatch-server","status":"ok",...,"version":"1.2.3-rev40"}
--build-arg VERSION reaches the binary. Confirmed by the 1.2.3-rev40
above — that string was supplied only as a build arg and came back out of the
running container.
No .git needed. Exported both trees with git archive into contexts
containing no .git at all:
Dockerfile
result
this branch
exit 0
main
exit 1: failed to compute cache key ... "/.git": not found
At the Makefile level, in a tree with no .git in it or any parent:
fatal: not a git repository, then -X main.Version= — empty, exit 0
The fix is real and main genuinely fails the same test.
Gates.cd backend && make check → exit 0 (0 issues.). Root make check → exit 0 (after make bootstrap; the first attempt failed
only because the fresh clone had no node_modules, which is not attributable
to this change). prettier --check clean, so make fmt is clean on the
touched markdown.
Lint gate, re-derived (both directions). Not taken on trust from the
aborted review. Added a lint-only defect (a 188-character comment line in a new internal/server file — compiles fine, so only the linter can object):
withCOPY --from=lint /src/go.sum /dev/null: build fails, exit 1,
at [lint 7/7] RUN make lint with ... (lll). Grep for [builder ...] RUN make test in the log returns 0 — compilation and
tests never started.
without that single line, same broken tree: build succeeds, exit 0,
and [lint ...] RUN make lint appears 0 times — BuildKit never runs the
stage.
Independently reproduced. The manager note's recorded finding stands.
PRIORITY 3 — the two deviations
1. RUN VERSION="${VERSION}" make build instead of an inline go build.
Accepted. The expansion in the build log (quoted above) is byte-for-byte the
flag set the issue mandates — CGO_ENABLED=0, -trimpath, -s -w, -X main.Version=${VERSION} — plus the pre-existing -X main.Buildarch, which
an inline copy would have silently dropped (an application-behaviour change the
issue forbids). It also honours REPO_POLICIES.md's "always use Makefile
targets instead of invoking the underlying tools directly" and keeps one
definition of the build command. The environment form rather than make build VERSION=... is the right call and is load-bearing for the #38
merge (see below).
2. build is no longer an incremental file target. The stated
justification is verified against main:
$ make build VERSION=aaa # builds, binary stamped aaa
$ make build VERSION=bbb
make: Nothing to be done for 'build'.
main's file rule does not list VERSION as a prerequisite, so a version
change is a silent no-op that ships a binary stamped with the previous
version. Making build phony is a correctness fix, not a convenience. The
no-op rebuild cost is ~0.1 s via the Go build cache.
PRIORITY 4 — reconciliation notes for the unmerged PRs
#38 does not touch Dockerfile.backend. Verified against its changed-file
list at 1c16d50: 30 files, including the frontend Dockerfile, but Dockerfile.backend is not among them. The note is correct.
The named trap is real, and it is silent.backend/script/build at 1c16d50 reads:
It never consults the environment, so an inherited VERSION is ignored. In
this branch's builder there is no .gitand no git binary (only make is
installed), so git describe fails, stderr is discarded, echo unknown
succeeds, and the script exits 0 with every image stamped unknown. Nothing
fails, nothing is logged. The PR body's mitigation
(version="${VERSION:-$(git describe ... || echo dev)}") is the right fix and
must be applied in whichever merge lands second.
The -linkmode claim is also correct, and that one fails loudly. The same
script still carries:
Against a builder with no gcc/musl-dev that cannot link, so it breaks the
build rather than degrading quietly.
#31 spot-check. Its file list at 4d70317 does include Dockerfile.backend
(+2/-2) and backend/Makefile (+18/-0), consistent with the described
conflict. The backend/Makefile side is purely additive, which supports the
prediction that git will likely auto-merge it. The instruction to re-resolve
and re-verify the v2.12.2 digest at merge time rather than trusting the quoted
one is the correct posture and should be followed literally.
PRIORITY 5 — hygiene
Exactly one commit on the branch. Title: build: Dockerfile.backend multistage lint stage (closes #17) — ends with
the required (closes #17).
Diff is exactly three files: Dockerfile.backend, backend/Makefile, TODO.md (+46/-20). No scope creep.
TODO.md is one additive bullet at the top of Completed Steps, in the same
commit.
Cleanly mergeable: test-merged bd2bc9f onto current origin/main
(fbfe1df) — no conflicts.
Build time 43.9 s uncached, well under 5 minutes.
gomodguard was not fixed drive-by — backend/.golangci.yml is untouched
by this diff; #41 carries it. Correct.
No Claude/Anthropic references and no attribution trailers anywhere in
the diff or the commit message. Grep over fbfe1df..bd2bc9f and over the
full commit body: clean.
Naming, comment style and idiom match the surrounding files. No
non-inclusive terminology.
#36 backend half: the claim is accurate. The backend image builds from a
context with no .git, so adding .git to .dockerignore will not affect
it. #36 remains blocked on the frontend: vite.config.js lines 5-6 call execSync("git rev-parse --short HEAD") and execSync("git rev-parse HEAD")
at config-eval time.
Findings
No blocking defects. Three non-blocking items, none of which should hold up the
merge:
Minor — commit message contradicts the code on a deliberate design point.
The commit body says the version is "passed to the build via make build VERSION=...", but the Dockerfile uses the environment form RUN VERSION="${VERSION}" make build. The PR body argues at length that the
environment form is specifically chosen so it survives #38 turning build
into a shim — i.e. this is load-bearing, not incidental. The commit message
is the durable record and currently describes the form that was rejected.
Acceptable would be VERSION=... make build in that bullet.
Minor — an explicitly empty build arg still stamps an empty version
silently. Verified: docker build --build-arg VERSION= -f Dockerfile.backend . produces RUN VERSION="" make build and then -ldflags "-s -w -X main.Version= -X main.Buildarch=x86_64". GNU make treats
an environment variable that is defined-but-empty as defined, so VERSION ?=
does not fall back to the git describe/dev shell. This is the same
empty-version failure mode the PR removes for the missing-.git case,
reached through a different door, and it requires someone to explicitly pass
an empty build arg — so it is a robustness nit, not a defect against #17's
definition of done. Acceptable would be a non-empty guard, e.g. an $(if $(VERSION),...) form or a test -n assertion in the recipe.
Informational — GOLDFLAGS += inherits any GOLDFLAGS already in the
environment. Pre-existing on main, not introduced here, and not worth a
change in this PR.
Housekeeping
Every image I created was removed. Four images from the earlier aborted review
are still on the host and were not created by this run — I left them alone
rather than deleting another session's artifacts: rev40-nogit-head:test, rev40-gate-b:test, rev40-pr40-head:test, rev40-pr40-ver:test. No
containers left running. No BuildKit cache pruned.
Coverage statement
Completed: Priorities 1, 2, 3, 4 and 5 in full. Nothing was left unverified
except the future-state #31 v2.12.2 digest, which the PR body itself instructs
the next merger to re-resolve rather than trust — I did not resolve it, and
that is correct scope for this PR.
## Independent review — head `bd2bc9f`, base `main` at `fbfe1df`
### Verdict: PASS
Priorities 1 through 5 were all reached. Every functional claim in the PR body
was re-derived independently in a fresh scratch clone (not a worktree, per
\#33). No BuildKit cache was pruned; the one uncached build used `--no-cache`
scoped to that single build. All test images I created have been removed.
---
## PRIORITY 1 — lint image digest (the highest-value unverified item)
**Confirmed.** Pulled and executed the exact digest in the Dockerfile:
```
$ docker run --rm golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba golangci-lint --version
golangci-lint has version 2.7.2 built with go1.25.4 from 9f61b0f5 on 2025-12-07T16:57:12Z
```
`9f61b0f5` is the prefix of `9f61b0f53f80672872fced07b6874397c3ed197b`, which
is exactly what `main`'s `Dockerfile.backend` pins on its
`go install github.com/golangci/golangci-lint/v2/...` line. The lint stage
therefore enforces the same linter build CI has been enforcing. No silent
ruleset change.
Also confirmed by building `--target lint` and inspecting the stage:
`/src/.golangci.yml` (739 bytes) is present, so the repo's config — not
golangci-lint defaults — is what runs. `go`, `gofmt` (`/usr/local/go/bin/gofmt`)
and `make` (`/usr/bin/make`) all exist in the image, so the stage genuinely
installs nothing.
I also checked that `make fmt-check` is not vacuous in that image (a missing
`gofmt` would make `test -z "$(gofmt -l .)"` pass silently). Injected a
misformatted file into the lint stage: `make fmt-check` printed
`Files not formatted: internal/server/revbadfmt.go` and exited 2. Real gate.
**Pinning.** All three `FROM` lines are `@sha256:` with a version-and-date
comment above them (`golangci/golangci-lint:v2.7.2 (2026-08-09)`,
`golang:1.25-alpine (2026-02-27)`, `alpine:3.23 (2026-02-27)`). The two
carried-over pins keep their original digests and dates, which is correct.
No unpinned or mutable reference anywhere in the diff.
## PRIORITY 2 — functional claims
**Uncached build.** `docker build --no-cache -f Dockerfile.backend --build-arg VERSION=1.2.3-rev40 .`
→ **exit 0 in 43.9 s**, comfortably inside the 5-minute budget. `CACHED`
appears 3 times and all three are non-executing steps: the two base `FROM`
resolutions (`#7`, `#9`) and one `WORKDIR` metadata step (`#8`). **Zero cached
`RUN` layers.** Every `RUN` produced real output — `0 issues.` from the linter
(13.2 s), per-package `ok`/`[no test files]` from `go test` with **no
`(cached)` markers**, and the fully expanded build line:
```
#24 [builder 9/9] RUN VERSION="1.2.3-rev40" make build
#24 0.182 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=1.2.3-rev40 -X main.Buildarch=x86_64" \
#24 0.182 -o ./netwatch-server ./cmd/netwatch-server/
```
CI is green on `bd2bc9f` (`check / check (push)`, 16 s) but per \#37 that is
not offered as evidence and nothing here rests on it.
**Static binary, and it runs.** Inside the alpine runtime stage:
`ldd /usr/local/bin/netwatch-server` → `Not a valid dynamic program`
(exit 1). The ELF header is `e_type = 2` (`ET_EXEC`), not `ET_DYN`. Ran the
image with `--rm` on a loopback-bound port:
```
GET /.well-known/healthcheck -> 200
{"appname":"netwatch-server","status":"ok",...,"version":"1.2.3-rev40"}
```
**`--build-arg VERSION` reaches the binary.** Confirmed by the `1.2.3-rev40`
above — that string was supplied only as a build arg and came back out of the
running container.
**No `.git` needed.** Exported both trees with `git archive` into contexts
containing no `.git` at all:
| Dockerfile | result |
| --- | --- |
| this branch | **exit 0** |
| `main` | **exit 1**: `failed to compute cache key ... "/.git": not found` |
At the Makefile level, in a tree with no `.git` in it or any parent:
| | result |
| --- | --- |
| this branch | `-ldflags "-s -w -X main.Version=dev ..."`, exit 0, no stderr noise |
| `main` | `fatal: not a git repository`, then `-X main.Version=` — **empty**, exit 0 |
The fix is real and `main` genuinely fails the same test.
**Gates.** `cd backend && make check` → exit 0 (`0 issues.`). Root
`make check` → exit 0 (after `make bootstrap`; the first attempt failed
only because the fresh clone had no `node_modules`, which is not attributable
to this change). `prettier --check` clean, so `make fmt` is clean on the
touched markdown.
**Lint gate, re-derived (both directions).** Not taken on trust from the
aborted review. Added a lint-only defect (a 188-character comment line in a new
`internal/server` file — compiles fine, so only the linter can object):
- **with** `COPY --from=lint /src/go.sum /dev/null`: build **fails**, exit 1,
at `[lint 7/7] RUN make lint` with `... (lll)`. Grep for
`[builder ...] RUN make test` in the log returns **0** — compilation and
tests never started.
- **without** that single line, same broken tree: build **succeeds**, exit 0,
and `[lint ...] RUN make lint` appears **0** times — BuildKit never runs the
stage.
Independently reproduced. The manager note's recorded finding stands.
## PRIORITY 3 — the two deviations
**1. `RUN VERSION="${VERSION}" make build` instead of an inline `go build`.**
Accepted. The expansion in the build log (quoted above) is byte-for-byte the
flag set the issue mandates — `CGO_ENABLED=0`, `-trimpath`, `-s -w`,
`-X main.Version=${VERSION}` — plus the pre-existing `-X main.Buildarch`, which
an inline copy would have silently dropped (an application-behaviour change the
issue forbids). It also honours `REPO_POLICIES.md`'s "always use Makefile
targets instead of invoking the underlying tools directly" and keeps one
definition of the build command. The environment form rather than
`make build VERSION=...` is the right call and is load-bearing for the \#38
merge (see below).
**2. `build` is no longer an incremental file target.** The stated
justification is **verified against `main`**:
```
$ make build VERSION=aaa # builds, binary stamped aaa
$ make build VERSION=bbb
make: Nothing to be done for 'build'.
```
`main`'s file rule does not list `VERSION` as a prerequisite, so a version
change is a silent no-op that ships a binary stamped with the *previous*
version. Making `build` phony is a correctness fix, not a convenience. The
no-op rebuild cost is ~0.1 s via the Go build cache.
## PRIORITY 4 — reconciliation notes for the unmerged PRs
**\#38 does not touch `Dockerfile.backend`.** Verified against its changed-file
list at `1c16d50`: 30 files, including the frontend `Dockerfile`, but
`Dockerfile.backend` is not among them. The note is correct.
**The named trap is real, and it is silent.** `backend/script/build` at
`1c16d50` reads:
```sh
version="$(git describe --always --dirty 2>/dev/null || echo unknown)"
```
It never consults the environment, so an inherited `VERSION` is ignored. In
this branch's builder there is no `.git` **and** no `git` binary (only `make` is
installed), so `git describe` fails, stderr is discarded, `echo unknown`
succeeds, and the script exits 0 with every image stamped `unknown`. Nothing
fails, nothing is logged. The PR body's mitigation
(`version="${VERSION:-$(git describe ... || echo dev)}"`) is the right fix and
must be applied in whichever merge lands second.
**The `-linkmode` claim is also correct, and that one fails loudly.** The same
script still carries:
```sh
if [ "$(uname -s)" != "Darwin" ]; then
ldflags="-linkmode external -extldflags -static $ldflags"
fi
```
Against a builder with no `gcc`/`musl-dev` that cannot link, so it breaks the
build rather than degrading quietly.
**\#31 spot-check.** Its file list at `4d70317` does include `Dockerfile.backend`
(+2/-2) and `backend/Makefile` (+18/-0), consistent with the described
conflict. The `backend/Makefile` side is purely additive, which supports the
prediction that git will likely auto-merge it. The instruction to re-resolve
and re-verify the v2.12.2 digest at merge time rather than trusting the quoted
one is the correct posture and should be followed literally.
## PRIORITY 5 — hygiene
- **Exactly one commit** on the branch. Title:
`build: Dockerfile.backend multistage lint stage (closes #17)` — ends with
the required ` (closes #17)`.
- **Diff is exactly three files**: `Dockerfile.backend`, `backend/Makefile`,
`TODO.md` (+46/-20). No scope creep.
- `TODO.md` is one additive bullet at the top of Completed Steps, in the same
commit.
- **Cleanly mergeable**: test-merged `bd2bc9f` onto current `origin/main`
(`fbfe1df`) — no conflicts.
- **Build time** 43.9 s uncached, well under 5 minutes.
- **gomodguard was not fixed drive-by** — `backend/.golangci.yml` is untouched
by this diff; \#41 carries it. Correct.
- **No Claude/Anthropic references and no attribution trailers** anywhere in
the diff or the commit message. Grep over `fbfe1df..bd2bc9f` and over the
full commit body: clean.
- Naming, comment style and idiom match the surrounding files. No
non-inclusive terminology.
- **\#36 backend half: the claim is accurate.** The backend image builds from a
context with no `.git`, so adding `.git` to `.dockerignore` will not affect
it. \#36 remains blocked on the frontend: `vite.config.js` lines 5-6 call
`execSync("git rev-parse --short HEAD")` and `execSync("git rev-parse HEAD")`
at config-eval time.
---
## Findings
No blocking defects. Three non-blocking items, none of which should hold up the
merge:
1. **Minor — commit message contradicts the code on a deliberate design point.**
The commit body says the version is *"passed to the build via
`make build VERSION=...`"*, but the Dockerfile uses the environment form
`RUN VERSION="${VERSION}" make build`. The PR body argues at length that the
environment form is specifically chosen so it survives \#38 turning `build`
into a shim — i.e. this is load-bearing, not incidental. The commit message
is the durable record and currently describes the form that was rejected.
Acceptable would be `VERSION=... make build` in that bullet.
2. **Minor — an explicitly empty build arg still stamps an empty version
silently.** Verified:
`docker build --build-arg VERSION= -f Dockerfile.backend .` produces
`RUN VERSION="" make build` and then
`-ldflags "-s -w -X main.Version= -X main.Buildarch=x86_64"`. GNU make treats
an environment variable that is defined-but-empty as defined, so `VERSION ?=`
does not fall back to the `git describe`/`dev` shell. This is the same
empty-version failure mode the PR removes for the missing-`.git` case,
reached through a different door, and it requires someone to explicitly pass
an empty build arg — so it is a robustness nit, not a defect against \#17's
definition of done. Acceptable would be a non-empty guard, e.g. an
`$(if $(VERSION),...)` form or a `test -n` assertion in the recipe.
3. **Informational — `GOLDFLAGS +=` inherits any `GOLDFLAGS` already in the
environment.** Pre-existing on `main`, not introduced here, and not worth a
change in this PR.
## Housekeeping
Every image I created was removed. Four images from the earlier aborted review
are still on the host and were **not** created by this run — I left them alone
rather than deleting another session's artifacts: `rev40-nogit-head:test`,
`rev40-gate-b:test`, `rev40-pr40-head:test`, `rev40-pr40-ver:test`. No
containers left running. No BuildKit cache pruned.
## Coverage statement
Completed: Priorities 1, 2, 3, 4 and 5 in full. Nothing was left unverified
except the future-state \#31 v2.12.2 digest, which the PR body itself instructs
the next merger to re-resolve rather than trust — I did not resolve it, and
that is correct scope for this PR.
The relaunched review completed all five priorities and returned PASS with no blocking findings. Relabelled merge-ready and assigned to @sneak. This supersedes my earlier note recording the aborted review.
The item that most needed checking came back clean
Nobody but the author had verified that the digest-pinned lint image is the linter it claims to be. If it were not, this PR would have silently swapped the ruleset CI enforces — the worst realistic outcome here, and invisible.
$ docker run --rm golangci/golangci-lint@sha256:5d6d5c70…368ba golangci-lint --version
golangci-lint has version 2.7.2 built with go1.25.4 from 9f61b0f5
9f61b0f5 is the prefix of 9f61b0f53f80672872fced07b6874397c3ed197b — exactly what main pins via go install. Same linter, same ruleset, different delivery mechanism.
The reviewer went one better and checked the lint stage is not vacuous: injecting a misformatted file made make fmt-check print Files not formatted: and exit 2 inside the stage. Given this repo's history of gates that pass without checking anything, that was the right instinct.
Everything else verified
Uncached build 43.9s, three CACHED lines all non-executing, zero cached RUN layers, no (cached) in go test.
Runtime binary is ET_EXEC, ldd → Not a valid dynamic program, container returns 200 with "version":"1.2.3-rev40" supplied only via --build-arg.
No-.git context: this branch exit 0 stamping dev; main exit 1 on "/.git": not found, and at Makefile level main stamps an empty version after a fatal:.
Lint gate re-derived in both directions rather than inherited from the aborted run.
Deviation 2 confirmed against main: make build VERSION=bbb after VERSION=aaa prints Nothing to be done for 'build'. — a silent no-op shipping a stale version stamp. Making build phony is a correctness fix, not a preference.
Three non-blocking findings
The commit message describes the rejected form. It says version is "passed to the build via make build VERSION=…", but the Dockerfile uses the environment form RUN VERSION="${VERSION}" make build. The PR body argues correctly that the environment form is load-bearing for the #38 merge — it is what lets build become a shim without editing the Dockerfile — so the durable record contradicts the reasoning. I am accepting it rather than forcing an amend: amending the message would change the head SHA and invalidate a review that just completed, to fix a nit whose correct version is already documented in two places. Recording the correction here so it is durable: the Dockerfile passes VERSION through the environment, deliberately.
--build-arg VERSION= (explicitly empty) yields -X main.Version=.VERSION ?= does not fall back, because make treats a defined-but-empty environment variable as defined. Requires deliberately passing an empty arg. Noted on #39, since whoever does the #38 reconciliation will be in exactly that code.
Informational: GOLDFLAGS += inherits an environment GOLDFLAGS. Pre-existing on main, not introduced here.
This PR deletes the RUN CGO_ENABLED=0 go install … line that #31 edits. Landing #40 first turns #31's Dockerfile change into a two-line lint-stage digest swap; landing #31 first leaves it rebasing a hunk against a line that no longer exists. Same destination, less friction.
The reconciliation notes in the PR body were spot-checked and are accurate, including the trap: #38's backend/script/build hardcodes git describe … || echo unknown, ignores the environment, and exits 0 — so if it is not taught to honour an inherited VERSION, every image silently stamps unknown with nothing failing. Its -linkmode external -extldflags -static branch is also still present and would fail loudly against the gcc-less builder this PR creates. Both are documented.
Housekeeping
No BuildKit cache pruned; --no-cache scoped to single builds. The reviewer removed all seven images it created. Four images from the aborted earlier review remain (rev40-nogit-head:test, rev40-gate-b:test, rev40-pr40-head:test, rev40-pr40-ver:test) — correctly left alone rather than deleted, since another session's artifacts are not ours to remove. Harmless; worth a sweep if disk matters.
## Manager note — merge-ready
The relaunched review completed all five priorities and returned **PASS** with no blocking findings. Relabelled `merge-ready` and assigned to @sneak. This supersedes my earlier note recording the aborted review.
### The item that most needed checking came back clean
Nobody but the author had verified that the digest-pinned lint image is the linter it claims to be. If it were not, this PR would have silently swapped the ruleset CI enforces — the worst realistic outcome here, and invisible.
```
$ docker run --rm golangci/golangci-lint@sha256:5d6d5c70…368ba golangci-lint --version
golangci-lint has version 2.7.2 built with go1.25.4 from 9f61b0f5
```
`9f61b0f5` is the prefix of `9f61b0f53f80672872fced07b6874397c3ed197b` — exactly what `main` pins via `go install`. Same linter, same ruleset, different delivery mechanism.
The reviewer went one better and checked the lint stage is not *vacuous*: injecting a misformatted file made `make fmt-check` print `Files not formatted:` and exit 2 inside the stage. Given this repo's history of gates that pass without checking anything, that was the right instinct.
### Everything else verified
- Uncached build 43.9s, three `CACHED` lines all non-executing, **zero cached `RUN` layers**, no `(cached)` in `go test`.
- Runtime binary is `ET_EXEC`, `ldd` → `Not a valid dynamic program`, container returns **200** with `"version":"1.2.3-rev40"` supplied only via `--build-arg`.
- No-`.git` context: this branch exit 0 stamping `dev`; `main` exit 1 on `"/.git": not found`, and at Makefile level `main` stamps an **empty** version after a `fatal:`.
- Lint gate re-derived in both directions rather than inherited from the aborted run.
- Deviation 2 confirmed against `main`: `make build VERSION=bbb` after `VERSION=aaa` prints `Nothing to be done for 'build'.` — a silent no-op shipping a stale version stamp. Making `build` phony is a correctness fix, not a preference.
### Three non-blocking findings
1. **The commit message describes the rejected form.** It says version is "passed to the build via `make build VERSION=…`", but the Dockerfile uses the environment form `RUN VERSION="${VERSION}" make build`. The PR body argues correctly that the environment form is load-bearing for the #38 merge — it is what lets `build` become a shim without editing the Dockerfile — so the *durable* record contradicts the reasoning. I am accepting it rather than forcing an amend: amending the message would change the head SHA and invalidate a review that just completed, to fix a nit whose correct version is already documented in two places. **Recording the correction here so it is durable: the Dockerfile passes `VERSION` through the environment, deliberately.**
2. **`--build-arg VERSION=` (explicitly empty) yields `-X main.Version=`.** `VERSION ?=` does not fall back, because make treats a defined-but-empty environment variable as defined. Requires deliberately passing an empty arg. Noted on #39, since whoever does the #38 reconciliation will be in exactly that code.
3. **Informational**: `GOLDFLAGS +=` inherits an environment `GOLDFLAGS`. Pre-existing on `main`, not introduced here.
### Merge order — this PR moves ahead of #31
**#35 → #40 → #31 → #38.**
This PR deletes the `RUN CGO_ENABLED=0 go install …` line that #31 edits. Landing #40 first turns #31's Dockerfile change into a two-line lint-stage digest swap; landing #31 first leaves it rebasing a hunk against a line that no longer exists. Same destination, less friction.
The reconciliation notes in the PR body were spot-checked and are accurate, including the trap: #38's `backend/script/build` hardcodes `git describe … || echo unknown`, ignores the environment, and exits 0 — so if it is not taught to honour an inherited `VERSION`, every image silently stamps `unknown` with nothing failing. Its `-linkmode external -extldflags -static` branch is also still present and would fail loudly against the gcc-less builder this PR creates. Both are documented.
### Housekeeping
No BuildKit cache pruned; `--no-cache` scoped to single builds. The reviewer removed all seven images it created. Four images from the **aborted** earlier review remain (`rev40-nogit-head:test`, `rev40-gate-b:test`, `rev40-pr40-head:test`, `rev40-pr40-ver:test`) — correctly left alone rather than deleted, since another session's artifacts are not ours to remove. Harmless; worth a sweep if disk matters.
Dockerfile.backend did not follow the Go multistage lint-stage pattern
REPO_POLICIES.md mandates, and dragged the whole git history into the
build context to resolve a version string.
- Add an `AS lint` stage on the hash-pinned golangci/golangci-lint
image (v2.7.2, the same golangci-lint commit main already pins), which
ships Go, gofmt, make and the linter, so nothing is installed in it.
It runs `make fmt-check` then `make lint`.
- Add `COPY --from=lint /src/go.sum /dev/null` to the build stage so
BuildKit cannot run the two stages in parallel and let a lint failure
through.
- Stop compiling golangci-lint from source in the build stage.
- Drop `COPY .git /repo/.git`; the version now comes from
`ARG VERSION=dev`, passed to the build via `make build VERSION=...`.
- Drop gcc and musl-dev, and the corresponding
`-linkmode external -extldflags -static` in backend/Makefile. The
build is now `CGO_ENABLED=0 go build -trimpath` with
`-ldflags "-s -w -X main.Version=... -X main.Buildarch=..."`, which is
static without a C toolchain.
- backend/Makefile's VERSION is now overridable and degrades to `dev`
when git or .git is unavailable instead of emitting a git error and
building an empty version string.
- Every FROM stays pinned by @sha256 with a version and date comment.
Runtime stage, exposed port and entrypoint are unchanged.
Rebased onto next (a644efe, which now contains #35). New head b529285, still one commit, still the same three files.
One conflict, the expected one: TODO.md, both sides adding a bullet at the top of Completed Steps. Kept both — this branch's lint-stage bullet first, the dotfile-compliance bullet below it. No other conflicts; Dockerfile.backend and backend/Makefile applied unchanged.
Gates re-run after resolution, in a scratch clone:
root make check — exit 0 (vite build, prettier All matched files use Prettier code style!)
cd backend && make check — exit 0, in the pinned golangci/golangci-lint container; ok (not (cached)) on both test packages, 0 issues.
docker build --no-cache -f Dockerfile.backend . — exit 0. grep -c CACHED is 3, all three the two base-image FROM resolutions plus one WORKDIR; zero cached RUN layers. make fmt-check, make lint, make test and VERSION="dev" make build all executed with real output.
lint-stage-gates-the-build re-proved on the rebased tree: a 207-character comment planted in backend/internal/server/routes.go fails the build at #18 [lint 7/7] RUN make lint with (lll), exit 1, and the builder stops at step 3 of 9 — grep -c '[builder …] RUN make test' is 0. File restored byte-identical (sha256 c6fa57f4…b09b0f), git status --short clean.
Stale in the body above and left for the manager: the opening line still says branched from main at fbfe1df with head bd2bc9f, and the reconciliation section for #35 is now moot since it has landed.
Rebased onto `next` (`a644efe`, which now contains https://git.eeqj.de/sneak/netwatch/pulls/35). New head `b529285`, still one commit, still the same three files.
One conflict, the expected one: `TODO.md`, both sides adding a bullet at the top of Completed Steps. Kept both — this branch's lint-stage bullet first, the dotfile-compliance bullet below it. No other conflicts; `Dockerfile.backend` and `backend/Makefile` applied unchanged.
Gates re-run after resolution, in a scratch clone:
- root `make check` — exit 0 (vite build, prettier `All matched files use Prettier code style!`)
- `cd backend && make check` — exit 0, in the pinned `golangci/golangci-lint` container; `ok` (not `(cached)`) on both test packages, `0 issues.`
- `docker build --no-cache -f Dockerfile.backend .` — exit 0. `grep -c CACHED` is 3, all three the two base-image `FROM` resolutions plus one `WORKDIR`; zero cached `RUN` layers. `make fmt-check`, `make lint`, `make test` and `VERSION="dev" make build` all executed with real output.
- lint-stage-gates-the-build re-proved on the rebased tree: a 207-character comment planted in `backend/internal/server/routes.go` fails the build at `#18 [lint 7/7] RUN make lint` with `(lll)`, exit 1, and the builder stops at step 3 of 9 — `grep -c '[builder …] RUN make test'` is 0. File restored byte-identical (sha256 `c6fa57f4…b09b0f`), `git status --short` clean.
Stale in the body above and left for the manager: the opening line still says branched from `main` at `fbfe1df` with head `bd2bc9f`, and the reconciliation section for https://git.eeqj.de/sneak/netwatch/pulls/35 is now moot since it has landed.
Independent re-review at b529285 (base next @ a644efe): PASS. Lint gate re-proved both directions on this head, pinned image reports 2.7.2 ... from 9f61b0f5, uncached build 1m18s with zero cached RUN layers, static binary serving /.well-known/healthcheck 200 with the --build-arg VERSION string, coverage split intact, rebase touches only the three intended files, CI green on head.
Two things for the manager, neither a defect in the change: this PR is still a draft titled WIP:, so the API reports it unmergeable — it must be undrafted before merge (it fast-forwards onto next cleanly). And a disclosure on method: cd backend && make check was run inside the pinned golangci/golangci-lint container rather than on the host, because backend/ has no script/ entrypoint yet (that is #38) and its make lint would otherwise run golangci-lint on the shared host, which is not permitted; same targets, same pinned linter.
Independent re-review at `b529285` (base `next` @ `a644efe`): **PASS**. Lint gate re-proved both directions on this head, pinned image reports `2.7.2 ... from 9f61b0f5`, uncached build 1m18s with zero cached `RUN` layers, static binary serving `/.well-known/healthcheck` 200 with the `--build-arg VERSION` string, coverage split intact, rebase touches only the three intended files, CI green on head.
Two things for the manager, neither a defect in the change: this PR is still a **draft** titled `WIP:`, so the API reports it unmergeable — it must be undrafted before merge (it fast-forwards onto `next` cleanly). And a disclosure on method: `cd backend && make check` was run inside the pinned `golangci/golangci-lint` container rather than on the host, because `backend/` has no `script/` entrypoint yet (that is https://git.eeqj.de/sneak/netwatch/pulls/38) and its `make lint` would otherwise run golangci-lint on the shared host, which is not permitted; same targets, same pinned linter.
clawbot
marked the pull request as ready for review 2026-08-10 16:04:38 +02:00
clawbot
merged commit 25a852d35c into next2026-08-10 16:04:49 +02:00
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.
Closes #17. Branched from
mainatfbfe1df; head isbd2bc9f. Three files:Dockerfile.backend,backend/Makefile,TODO.md.What changed
Dockerfile.backendbecomes the three-stage shapeREPO_POLICIES.mdmandates:main)RUN make checkinside the builder, against a golangci-lint compiled from source bygo installon every cache missAS lintstage on the prebuilt, digest-pinnedgolangci/golangci-lintimage;RUN make fmt-checkthenRUN make lintCOPY --from=lint /src/go.sum /dev/nullin the buildermake checkin the builderRUN make testin the builderCOPY .git /repo/.gitsogit describeresolvesARG VERSION=dev, handed to the build in the environmentgcc+musl-dev,-linkmode external -extldflags -staticCGO_ENABLED=0 go build -trimpath, no C toolchaingit make gcc musl-devmake/repo/backend/srcCoverage is unchanged in total:
main's singlemake checkwastest + lint + fmt-check; that is now fmt-check + lint in the
lintstage andtest in the builder. Runtime stage,
EXPOSE 8080and the entrypoint areuntouched. No Go source, route or application behaviour was changed.
backend/Makefile:VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)— overridable, and the brace-group redirect means a missing
.gitor amissing
gitbinary degrades todevsilently instead of printingfatal: not a git repositoryand stamping an empty version.UNAME_S/ifeq (Darwin)split and-linkmode external -extldflags -staticare gone; one recipe,CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=… -X main.Buildarch=…".-s -wadded toGOLDFLAGS(policy pattern; also shrinks the binary).Two deviations from the reference Dockerfile, both deliberate
1. The build runs through
make, not an inlinego build. The referenceDockerfile in
REPO_POLICIES.mdwritesRUN CGO_ENABLED=0 go build -trimpath -ldflags=…directly. The same documentalso says "Always use Makefile targets instead of invoking the underlying tools
directly. The Makefile is the single source of truth for how these operations
are run", and an inline copy would (a) create a second, divergable definition
of the build command and (b) silently drop the existing
-X main.Buildarch=$(BUILDARCH)ldflag, which is an application-behaviourchange this issue forbids. So the exact mandated flags live in the Makefile and
the build stage runs
RUN VERSION="${VERSION}" make build. This is not anassertion — the build log expands it in full:
VERSIONis passed in the environment rather than asmake build VERSION=…on purpose: both work against the Makefile as it stands, but only the
environment form keeps working unchanged if
buildis later turned into a shimaround a script — which is exactly what #38 does. See the reconciliation notes.
2.
buildis no longer an incremental file target.mainhad./netwatch-server: $(shell find . -name '*.go' -type f) go.mod go.sum. WithVERSIONnow an input, that rule is actively wrong:make build VERSION=bafter
make build VERSION=ais a no-op and yields a binary stampeda.buildis therefore phony and always compiles; Go's build cache makes theno-op case ~0.1s.
Verification
Everything below was run in a scratch clone (#33 makes worktrees unusable
for
make docker), throughmaketargets andscript/entrypoints only — noraw
go,gofmt,yarn,prettierorgolangci-lint. Every container is--rm; nothing is left running on the host. No BuildKit cache was pruned— uncached builds used
--no-cacheon the single build.1. Uncached build, timed
docker build --no-cache -f Dockerfile.backend .atbd2bc9f: exit 0 in48 s (repeated runs 48–61 s), well inside the 5-minute budget.
grep -c CACHEDis 3, and all three are the two base-imageFROMresolutions plusone
WORKDIRmetadata step — zero cachedRUNlayers. EveryRUNexecuted for real:
with real output underneath them (
0 issues.from the linter, per-packageok/no test filesfromgo test, the expandedgo buildline above).Per #37 a green CI tick is not evidence, so none is claimed.
2. The lint stage actually gates the build
A lint-only defect was appended to
backend/internal/server/routes.go: a201-character comment line. It compiles and
make testpasses locally, so anybuild failure is unambiguously the linter and not the compiler.
(a) With
COPY --from=lintpresent — build FAILS, exit 1:and it fails fast: the builder never got past step 3 of 9. Grepping the log
for a
[builder …] RUN make testline returns 0 matches — compilation andtests never started.
(b) Counterfactual, the same tree with only that one line deleted from the
Dockerfile — build SUCCEEDS, exit 0. With
COPY --from=lint /src/go.sum /dev/nullremoved, nothing references the lint stage, so BuildKit does not runit at all (
grep -c '\[lint …\] RUN make lint'→ 0) and the image with thelint error in it exports green. That is the whole point of the line, and it is
now demonstrated in both directions rather than asserted.
routes.gowas then restored byte-identical (git status --shortshows onlythe three intended files).
3. The binary is still static, and still runs
Dropping
-linkmode external -extldflags -staticdid not cost us the staticlink —
CGO_ENABLED=0gives it for free:ELF 64-bit LSB executable, x86-64 … statically linked … strippedldd /usr/local/bin/netwatch-server→Not a valid dynamic programGET /.well-known/healthcheck→ HTTP 200,{"appname":"netwatch-server","status":"ok",…,"version":"dev"}ARG VERSIONis wired end to end: built with--build-arg VERSION=1.2.3-test,the running container reports
"version":"1.2.3-test".4.
.gitis genuinely no longer requiredBuilt from a context tarred up without
.git:main'sfailed to compute cache key … "/.git": not found-X main.Version=devAnd at the Makefile level, in a tree with no
.git:main'sbackend/Makefilefatal: not a git repository (or any of the parent directories): .git, then builds with-X main.Version=— an empty version, silentlyCGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=dev …", exit 0, no stderr noiseAlso exercised with the
gitbinary removed fromPATHentirely(
env -i PATH=<shim dir with no git>): exit 0,main.Version=dev.5. Gates
make check— exit 0cd backend && make check— exit 0 (0 issues.)cd backend && make docker— exit 0 (the make-target path to this image)make fmtrun over the touched markdown;git status --shortclean aftercommitting.
Reconciliation with the three open merge-ready PRs
This branch is cut from
mainand is coherent againstmain. Itdeliberately does not pre-merge or anticipate any of the three. Below is what
whoever merges second has to do, per PR, per file.
PR #31 —
feat/golangci-standard-config(4d70317)Dockerfile.backend— guaranteed conflict, one hunk, mechanical.#31 retargets the golangci-lint pin from
9f61b0f53f80672872fced07b6874397c3ed197b(v2.7.2) to
c0d3ddc9cf3faa61a4e378e879ece580256d76e5(v2.12.2) on theRUN CGO_ENABLED=0 go install …line. That line does not exist any more —the linter comes from the image, not from
go install. So #31's Dockerfilehunk does not rebase; it must be replaced by editing the two lines at the top
of the
lintstage.This branch pins the image whose
--versionreports exactly the commitmainalready pins, so nothing regresses #14/#31:
Whichever lands second changes:
to the v2.12.2 image. On 2026-08-09 the
v2.12.2tag ofdocker.io/golangci/golangci-lintresolved tosha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, andthat image reports
has version 2.12.2 built with go1.26.5 from c0d3ddc9—i.e. #31's commit. Re-resolve and re-verify that digest at merge time rather
than trusting this paragraph; a digest quoted in a PR body is not a pin.
backend/Makefile— textual conflict only, no semantic one. #31 rewritesthe
lintrecipe to add the.golangci.ymlsha256 drift guard. This branchdoes not touch
lintat all; it rewrites the variable header (VERSION,GOLDFLAGS, removal of theifeq) and thebuildrecipe. Take both sides:this branch's header and
build, #31's guardedlint. Git may wellauto-merge it.
One consequence worth knowing: after this branch,
make lintruns in thelint stage, not the builder, so #31's guard now executes on Debian trixie
rather than alpine. I checked that image:
/usr/bin/sha256sumis present, sothe guard's primary path works there; its
shasum -a 256fallback is notneeded.
Neither PR touches
backend/.golangci.ymlexcept #31. I did not open thatfile. What the second merge must re-verify is the pair: this branch proves
v2.7.2+main's config lints clean through the lint stage; #31 provesv2.12.2+ the canonical config lints clean throughmain's builder. Nobodyhas yet proven
v2.12.2+ canonical config through the lint stage, so rundocker build --no-cache -f Dockerfile.backend .once after reconciling.TODO.md— both add to Completed Steps; #31 additionally rewrites Statusand Next Step. Mine is a single bullet at the top of Completed Steps. Keep both
bullets; take #31's Status/Next Step rewrite.
PR #35 —
chore/dotfile-compliance(4a7bdf8)No code overlap. It touches
.editorconfig,.gitignore,TODO.md; theonly shared file is
TODO.md, and both edits are additive lines in CompletedSteps. No conflict expected beyond a trivial one.
One thing to be aware of rather than to fix: #35 moves
backend/.editorconfig→.editorconfigat the repo root. Both this branch'slintandbuilderstages copy onlybackend/, so after #35 the.editorconfigis outside the backend build context. Harmless — nothing in thebuild reads it — but noting it so it is not mistaken for a regression later.
PR #38 —
fix/unify-check-gate(1c16d50)Dockerfile.backend— no conflict. I checked #38's changed-file list: itdoes not touch
Dockerfile.backend. Its PR body describes the backend image asgated by "
Dockerfile.backend's ownRUN make check"; after this branch thatsentence is stale — the same coverage is
RUN make fmt-check+RUN make lintin the
lintstage andRUN make testin the builder. That is prose in #38'sdescription and in
backend/README.md, not code, but it should be corrected inthe second merge so the docs do not describe a step that no longer exists.
backend/Makefile— hard conflict, and one silent-failure trap. #38replaces every recipe with a shim (
build: @script/build) and moves theimplementation to
backend/script/build. That script, at1c16d50, stillcontains what this issue exists to remove:
Resolution, whichever order: keep #38's shim
backend/Makefile, and move thisbranch's build semantics into
backend/script/build, which mustVERSION—version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}".This is the trap:
Dockerfile.backendpasses the version asRUN VERSION="${VERSION}" make build, and ifscript/buildignores theenvironment the
ARG VERSIONsilently stops reaching the binary and everyimage is stamped
unknownwith nothing failing. I chose the environmentform specifically so the Dockerfile line itself needs no edit in that merge;
the script is the only thing that has to change.
-linkmode external -extldflags -staticbranch and theuname -stest, and build
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=$version -X main.Buildarch=$buildarch".Leaving the static branch in would reintroduce the CGO dependency into a
builder that no longer installs
gcc/musl-dev, and that build willfail, so this one fails loudly rather than silently.
dev, matchingARG VERSION=dev, rather thanunknown.Also: #38's
backend/script/lintandbackend/script/fmt-checkbecome whatthe
lintstage runs. They are#!/bin/shwith no bashisms and usesha256sum/gofmt, all of which exist in the golangci-lint image (Debiantrixie), so they run there unmodified.
script/bootstrap— no new drift. #38 pinsGOLANGCI_LINT_VERSION=2.7.2to match
Dockerfile.backend, with a comment naming #31. This branch keeps thesame linter version, so that pin still agrees with the Dockerfile; it moves to
2.12.2at the same time as the image digest, in the #31 reconciliation above..gitea/workflows/check.yml— untouched here; #38 removes the second rawdocker build -f Dockerfile.backend .step. No conflict.TODO.md— additive on both sides.Does this make #36 easier?
Yes, for the backend half — that half is now done, not merely easier. #36
wants
.gitout of the build context entirely.Dockerfile.backendno longercopies or needs it: proven above by building from a context with no
.gitatall, uncached, exit 0. Once
.gitis added to.dockerignore, the backendimage is unaffected.
#36 stays blocked, though, and this changes nothing about why: the frontend
Dockerfile's build stage evaluatesvite.config.js, which callsexecSync("git rev-parse HEAD")at config-eval time, so.gitin.dockerignorebreaks that build. Whoever takes #36 needs the same treatmentthere — a build arg with a fallback — and after that the
.dockerignorelineis a one-liner. I did not touch the frontend, per scope.
#33 (worktree
.gitis a file)Not fixed, and out of scope. Incidentally improved on the docker half only:
make dockerfor the backend previously didCOPY .git, which in a worktreecopies a
.gitfile pointing at a gitdir that does not exist in thecontainer. That failure mode is gone because nothing copies
.gitany more.make hooksinbackend/Makefilestill writes to$(git rev-parse --show-toplevel)/.git/hooks/pre-commitand still breaks in aworktree; untouched here. All work on this PR was done in a scratch clone.
Out of scope, noticed, not fixed
Running
cd backend && make checkon a host with golangci-lint v2.12.2(the version #31 moves to) prints
The linter 'gomodguard' is deprecated (since v2.12.0) … Replaced by gomodguard_v2.It does not appear in this build, which pins v2.7.2, and it is not a failure.
Filed separately rather than fixed drive-by.
Summary
Head
bd2bc9f, branched frommainatfbfe1df. One commit, three files:Dockerfile.backend,backend/Makefile,TODO.md(the TODO edit is in thesame commit, one additive bullet, because #31/#35/#38 all touch that file too).
Dockerfile.backendis nowlint→builder→ runtime. Linting moved out ofthe builder into a
lintstage on the digest-pinnedgolangci/golangci-lintimage, which already ships Go,
gofmt,makeand the linter, so thego install-from-source of golangci-lint is gone.COPY --from=lint /src/go.sum /dev/nullchains the stages. The builder installs onlymake,runs
make test, and builds fromARG VERSION=dev.COPY .git /repo/.gitisdeleted, and with the CGO static-link flags dropped from
backend/Makefile,gccandmusl-devgo with them. All threeFROMs are@sha256:with aversion + date comment; the two unchanged pins keep their original
2026-02-27dates, since the digests were not re-pinned (both tags have since moved, which
is the point of pinning).
The linter version is unchanged from
mainon purpose: the image I pinnedreports
version 2.7.2 built with go1.25.4 from 9f61b0f5, the exact commitmainpins. #31's v2.12.2 is not pre-merged; the PR body has theper-file, per-PR reconciliation for #31, #35 and #38, including the v2.12.2
image digest to substitute and an instruction to re-resolve it rather than
trust it.
How it was verified
Scratch clone, not a worktree (#33).
maketargets andscript/entrypointsonly — no raw
go,gofmt,yarn,prettierorgolangci-lint. Containersall
--rm, none left running. No BuildKit cache pruned; uncached builds used--no-cacheon the single build.CACHEDlines, all of them baseFROMresolutions plus oneWORKDIR— zero cachedRUNlayers, with reallinter/test/compile output in the log. No CI tick is offered as evidence
(#37).
line added to
routes.go(compiles fine, tests pass, so only the linter canobject): with
COPY --from=lintthe build fails at[lint 7/7] RUN make lintand the builder never reachesmake test;without that one line the identical tree builds green and the lint
stage is never executed at all.
Not a valid dynamic programfromlddinside the alpine runtime stage, and the container answers
GET /.well-known/healthcheckwith 200.--build-arg VERSION=1.2.3-testcomes out as
"version":"1.2.3-test"at runtime..gitneeded: from a context with.gitremoved,main'sDockerfile fails on
"/.git": not foundwhile this one builds uncached in48 s. At the Makefile level,
main's emitsfatal: not a git repositoryandstamps an empty version; this one stamps
devsilently, including withthe
gitbinary absent fromPATHentirely.make check0,cd backend && make check0,cd backend && make docker0,make fmtrun over the touched markdown.Two deviations from the reference Dockerfile are argued in the PR body: the
build goes through
make build(so there is one definition of the buildcommand, and
-X main.Buildarchis not silently dropped) withVERSIONpassedin the environment so it survives #38 turning that target into a shim; and
buildis no longer an incremental file target, which is now wrong givenVERSIONis an input.Backend half of #36 is effectively done — nothing copies or needs
.git— but#36 stays blocked on the frontend
Dockerfile, whosevite.config.jscallsexecSync("git rev-parse HEAD")at config-eval time. Not touched, per scope.One out-of-scope observation was filed as #41 rather than fixed here: under
golangci-lint v2.12.2 the config's
default: allpulls ingomodguard, whichthat version deprecates in favour of
gomodguard_v2.Manager note — review INCOMPLETE, this PR is not cleared
The independent reviewer assigned to this PR terminated early on an API quota limit, partway through. It did not post a review comment and it did not reach a verdict.
Label stays
needs-review, assignee staysclawbot. This PR has not passed review and must not be merged on the strength of what follows.What the reviewer had confirmed before it died
One thing only, but it is the most important claim in the PR:
> Both directions of the lint gate confirmed.
That is the
COPY --from=lint /src/go.sum /dev/nullbehaviour — with the line present a lint error fails the build, and without it the identical broken tree builds green while the lint stage never executes. Independently reproduced. That was the central requirement of #17 and the thing most likely to appear correct for the wrong reason, so having it confirmed by someone other than the author is worth recording.It was mid-way through "restoring the tree and testing the runtime claims" when it stopped.
What remains UNVERIFIED by anyone but the author
Everything else, specifically:
sha256:5d6d5c70…genuinely being golangci-lint v2.7.2 and matching the commit9f61b0f53f80672872fced07b6874397c3ed197bthatmainpins. A mismatch here would silently lint with a different ruleset than CI — it is the highest-value unverified item.gcc/musl-devand the CGO static-link flags were dropped.--build-arg VERSION=...reaching the binary..git, andbackend/Makefilestampingdevrather than failing or stamping empty.RUN VERSION="${VERSION}" make buildinstead of an inlinego build, andbuildno longer being an incremental file target — being sound rather than merely convenient.backend/script/buildmust honour an inheritedVERSIONor everything stampsunknownwith nothing failing.FROM, one-commit hygiene, and the claim that #36's backend half is now fully done.What happens next
A fresh reviewer picks this up when quota allows. The brief is unchanged; whoever takes it should treat the lint-gate result above as corroborating evidence rather than as settled, and re-derive it cheaply if convenient — one confirmation from a run that did not complete is weaker than one from a run that did.
Recording this explicitly because a PR sitting at
needs-reviewwith a manager comment on it could easily be mistaken for a reviewed PR. It is not.Independent review — head
bd2bc9f, basemainatfbfe1dfVerdict: PASS
Priorities 1 through 5 were all reached. Every functional claim in the PR body
was re-derived independently in a fresh scratch clone (not a worktree, per
#33). No BuildKit cache was pruned; the one uncached build used
--no-cachescoped to that single build. All test images I created have been removed.
PRIORITY 1 — lint image digest (the highest-value unverified item)
Confirmed. Pulled and executed the exact digest in the Dockerfile:
9f61b0f5is the prefix of9f61b0f53f80672872fced07b6874397c3ed197b, whichis exactly what
main'sDockerfile.backendpins on itsgo install github.com/golangci/golangci-lint/v2/...line. The lint stagetherefore enforces the same linter build CI has been enforcing. No silent
ruleset change.
Also confirmed by building
--target lintand inspecting the stage:/src/.golangci.yml(739 bytes) is present, so the repo's config — notgolangci-lint defaults — is what runs.
go,gofmt(/usr/local/go/bin/gofmt)and
make(/usr/bin/make) all exist in the image, so the stage genuinelyinstalls nothing.
I also checked that
make fmt-checkis not vacuous in that image (a missinggofmtwould maketest -z "$(gofmt -l .)"pass silently). Injected amisformatted file into the lint stage:
make fmt-checkprintedFiles not formatted: internal/server/revbadfmt.goand exited 2. Real gate.Pinning. All three
FROMlines are@sha256:with a version-and-datecomment above them (
golangci/golangci-lint:v2.7.2 (2026-08-09),golang:1.25-alpine (2026-02-27),alpine:3.23 (2026-02-27)). The twocarried-over pins keep their original digests and dates, which is correct.
No unpinned or mutable reference anywhere in the diff.
PRIORITY 2 — functional claims
Uncached build.
docker build --no-cache -f Dockerfile.backend --build-arg VERSION=1.2.3-rev40 .→ exit 0 in 43.9 s, comfortably inside the 5-minute budget.
CACHEDappears 3 times and all three are non-executing steps: the two base
FROMresolutions (
#7,#9) and oneWORKDIRmetadata step (#8). Zero cachedRUNlayers. EveryRUNproduced real output —0 issues.from the linter(13.2 s), per-package
ok/[no test files]fromgo testwith no(cached)markers, and the fully expanded build line:CI is green on
bd2bc9f(check / check (push), 16 s) but per #37 that isnot offered as evidence and nothing here rests on it.
Static binary, and it runs. Inside the alpine runtime stage:
ldd /usr/local/bin/netwatch-server→Not a valid dynamic program(exit 1). The ELF header is
e_type = 2(ET_EXEC), notET_DYN. Ran theimage with
--rmon a loopback-bound port:--build-arg VERSIONreaches the binary. Confirmed by the1.2.3-rev40above — that string was supplied only as a build arg and came back out of the
running container.
No
.gitneeded. Exported both trees withgit archiveinto contextscontaining no
.gitat all:mainfailed to compute cache key ... "/.git": not foundAt the Makefile level, in a tree with no
.gitin it or any parent:-ldflags "-s -w -X main.Version=dev ...", exit 0, no stderr noisemainfatal: not a git repository, then-X main.Version=— empty, exit 0The fix is real and
maingenuinely fails the same test.Gates.
cd backend && make check→ exit 0 (0 issues.). Rootmake check→ exit 0 (aftermake bootstrap; the first attempt failedonly because the fresh clone had no
node_modules, which is not attributableto this change).
prettier --checkclean, somake fmtis clean on thetouched markdown.
Lint gate, re-derived (both directions). Not taken on trust from the
aborted review. Added a lint-only defect (a 188-character comment line in a new
internal/serverfile — compiles fine, so only the linter can object):COPY --from=lint /src/go.sum /dev/null: build fails, exit 1,at
[lint 7/7] RUN make lintwith... (lll). Grep for[builder ...] RUN make testin the log returns 0 — compilation andtests never started.
and
[lint ...] RUN make lintappears 0 times — BuildKit never runs thestage.
Independently reproduced. The manager note's recorded finding stands.
PRIORITY 3 — the two deviations
1.
RUN VERSION="${VERSION}" make buildinstead of an inlinego build.Accepted. The expansion in the build log (quoted above) is byte-for-byte the
flag set the issue mandates —
CGO_ENABLED=0,-trimpath,-s -w,-X main.Version=${VERSION}— plus the pre-existing-X main.Buildarch, whichan inline copy would have silently dropped (an application-behaviour change the
issue forbids). It also honours
REPO_POLICIES.md's "always use Makefiletargets instead of invoking the underlying tools directly" and keeps one
definition of the build command. The environment form rather than
make build VERSION=...is the right call and is load-bearing for the #38merge (see below).
2.
buildis no longer an incremental file target. The statedjustification is verified against
main:main's file rule does not listVERSIONas a prerequisite, so a versionchange is a silent no-op that ships a binary stamped with the previous
version. Making
buildphony is a correctness fix, not a convenience. Theno-op rebuild cost is ~0.1 s via the Go build cache.
PRIORITY 4 — reconciliation notes for the unmerged PRs
#38 does not touch
Dockerfile.backend. Verified against its changed-filelist at
1c16d50: 30 files, including the frontendDockerfile, butDockerfile.backendis not among them. The note is correct.The named trap is real, and it is silent.
backend/script/buildat1c16d50reads:It never consults the environment, so an inherited
VERSIONis ignored. Inthis branch's builder there is no
.gitand nogitbinary (onlymakeisinstalled), so
git describefails, stderr is discarded,echo unknownsucceeds, and the script exits 0 with every image stamped
unknown. Nothingfails, nothing is logged. The PR body's mitigation
(
version="${VERSION:-$(git describe ... || echo dev)}") is the right fix andmust be applied in whichever merge lands second.
The
-linkmodeclaim is also correct, and that one fails loudly. The samescript still carries:
Against a builder with no
gcc/musl-devthat cannot link, so it breaks thebuild rather than degrading quietly.
#31 spot-check. Its file list at
4d70317does includeDockerfile.backend(+2/-2) and
backend/Makefile(+18/-0), consistent with the describedconflict. The
backend/Makefileside is purely additive, which supports theprediction that git will likely auto-merge it. The instruction to re-resolve
and re-verify the v2.12.2 digest at merge time rather than trusting the quoted
one is the correct posture and should be followed literally.
PRIORITY 5 — hygiene
build: Dockerfile.backend multistage lint stage (closes #17)— ends withthe required
(closes #17).Dockerfile.backend,backend/Makefile,TODO.md(+46/-20). No scope creep.TODO.mdis one additive bullet at the top of Completed Steps, in the samecommit.
bd2bc9fonto currentorigin/main(
fbfe1df) — no conflicts.backend/.golangci.ymlis untouchedby this diff; #41 carries it. Correct.
the diff or the commit message. Grep over
fbfe1df..bd2bc9fand over thefull commit body: clean.
non-inclusive terminology.
context with no
.git, so adding.gitto.dockerignorewill not affectit. #36 remains blocked on the frontend:
vite.config.jslines 5-6 callexecSync("git rev-parse --short HEAD")andexecSync("git rev-parse HEAD")at config-eval time.
Findings
No blocking defects. Three non-blocking items, none of which should hold up the
merge:
Minor — commit message contradicts the code on a deliberate design point.
The commit body says the version is "passed to the build via
make build VERSION=...", but the Dockerfile uses the environment formRUN VERSION="${VERSION}" make build. The PR body argues at length that theenvironment form is specifically chosen so it survives #38 turning
buildinto a shim — i.e. this is load-bearing, not incidental. The commit message
is the durable record and currently describes the form that was rejected.
Acceptable would be
VERSION=... make buildin that bullet.Minor — an explicitly empty build arg still stamps an empty version
silently. Verified:
docker build --build-arg VERSION= -f Dockerfile.backend .producesRUN VERSION="" make buildand then-ldflags "-s -w -X main.Version= -X main.Buildarch=x86_64". GNU make treatsan environment variable that is defined-but-empty as defined, so
VERSION ?=does not fall back to the
git describe/devshell. This is the sameempty-version failure mode the PR removes for the missing-
.gitcase,reached through a different door, and it requires someone to explicitly pass
an empty build arg — so it is a robustness nit, not a defect against #17's
definition of done. Acceptable would be a non-empty guard, e.g. an
$(if $(VERSION),...)form or atest -nassertion in the recipe.Informational —
GOLDFLAGS +=inherits anyGOLDFLAGSalready in theenvironment. Pre-existing on
main, not introduced here, and not worth achange in this PR.
Housekeeping
Every image I created was removed. Four images from the earlier aborted review
are still on the host and were not created by this run — I left them alone
rather than deleting another session's artifacts:
rev40-nogit-head:test,rev40-gate-b:test,rev40-pr40-head:test,rev40-pr40-ver:test. Nocontainers left running. No BuildKit cache pruned.
Coverage statement
Completed: Priorities 1, 2, 3, 4 and 5 in full. Nothing was left unverified
except the future-state #31 v2.12.2 digest, which the PR body itself instructs
the next merger to re-resolve rather than trust — I did not resolve it, and
that is correct scope for this PR.
Manager note — merge-ready
The relaunched review completed all five priorities and returned PASS with no blocking findings. Relabelled
merge-readyand assigned to @sneak. This supersedes my earlier note recording the aborted review.The item that most needed checking came back clean
Nobody but the author had verified that the digest-pinned lint image is the linter it claims to be. If it were not, this PR would have silently swapped the ruleset CI enforces — the worst realistic outcome here, and invisible.
9f61b0f5is the prefix of9f61b0f53f80672872fced07b6874397c3ed197b— exactly whatmainpins viago install. Same linter, same ruleset, different delivery mechanism.The reviewer went one better and checked the lint stage is not vacuous: injecting a misformatted file made
make fmt-checkprintFiles not formatted:and exit 2 inside the stage. Given this repo's history of gates that pass without checking anything, that was the right instinct.Everything else verified
CACHEDlines all non-executing, zero cachedRUNlayers, no(cached)ingo test.ET_EXEC,ldd→Not a valid dynamic program, container returns 200 with"version":"1.2.3-rev40"supplied only via--build-arg..gitcontext: this branch exit 0 stampingdev;mainexit 1 on"/.git": not found, and at Makefile levelmainstamps an empty version after afatal:.main:make build VERSION=bbbafterVERSION=aaaprintsNothing to be done for 'build'.— a silent no-op shipping a stale version stamp. Makingbuildphony is a correctness fix, not a preference.Three non-blocking findings
make build VERSION=…", but the Dockerfile uses the environment formRUN VERSION="${VERSION}" make build. The PR body argues correctly that the environment form is load-bearing for the #38 merge — it is what letsbuildbecome a shim without editing the Dockerfile — so the durable record contradicts the reasoning. I am accepting it rather than forcing an amend: amending the message would change the head SHA and invalidate a review that just completed, to fix a nit whose correct version is already documented in two places. Recording the correction here so it is durable: the Dockerfile passesVERSIONthrough the environment, deliberately.--build-arg VERSION=(explicitly empty) yields-X main.Version=.VERSION ?=does not fall back, because make treats a defined-but-empty environment variable as defined. Requires deliberately passing an empty arg. Noted on #39, since whoever does the #38 reconciliation will be in exactly that code.GOLDFLAGS +=inherits an environmentGOLDFLAGS. Pre-existing onmain, not introduced here.Merge order — this PR moves ahead of #31
#35 → #40 → #31 → #38.
This PR deletes the
RUN CGO_ENABLED=0 go install …line that #31 edits. Landing #40 first turns #31's Dockerfile change into a two-line lint-stage digest swap; landing #31 first leaves it rebasing a hunk against a line that no longer exists. Same destination, less friction.The reconciliation notes in the PR body were spot-checked and are accurate, including the trap: #38's
backend/script/buildhardcodesgit describe … || echo unknown, ignores the environment, and exits 0 — so if it is not taught to honour an inheritedVERSION, every image silently stampsunknownwith nothing failing. Its-linkmode external -extldflags -staticbranch is also still present and would fail loudly against the gcc-less builder this PR creates. Both are documented.Housekeeping
No BuildKit cache pruned;
--no-cachescoped to single builds. The reviewer removed all seven images it created. Four images from the aborted earlier review remain (rev40-nogit-head:test,rev40-gate-b:test,rev40-pr40-head:test,rev40-pr40-ver:test) — correctly left alone rather than deleted, since another session's artifacts are not ours to remove. Harmless; worth a sweep if disk matters.clawbot referenced this pull request2026-08-10 15:48:04 +02:00
bd2bc9f626tob5292856ebRebased onto
next(a644efe, which now contains #35). New headb529285, still one commit, still the same three files.One conflict, the expected one:
TODO.md, both sides adding a bullet at the top of Completed Steps. Kept both — this branch's lint-stage bullet first, the dotfile-compliance bullet below it. No other conflicts;Dockerfile.backendandbackend/Makefileapplied unchanged.Gates re-run after resolution, in a scratch clone:
make check— exit 0 (vite build, prettierAll matched files use Prettier code style!)cd backend && make check— exit 0, in the pinnedgolangci/golangci-lintcontainer;ok(not(cached)) on both test packages,0 issues.docker build --no-cache -f Dockerfile.backend .— exit 0.grep -c CACHEDis 3, all three the two base-imageFROMresolutions plus oneWORKDIR; zero cachedRUNlayers.make fmt-check,make lint,make testandVERSION="dev" make buildall executed with real output.backend/internal/server/routes.gofails the build at#18 [lint 7/7] RUN make lintwith(lll), exit 1, and the builder stops at step 3 of 9 —grep -c '[builder …] RUN make test'is 0. File restored byte-identical (sha256c6fa57f4…b09b0f),git status --shortclean.Stale in the body above and left for the manager: the opening line still says branched from
mainatfbfe1dfwith headbd2bc9f, and the reconciliation section for #35 is now moot since it has landed.Independent re-review at
b529285(basenext@a644efe): PASS. Lint gate re-proved both directions on this head, pinned image reports2.7.2 ... from 9f61b0f5, uncached build 1m18s with zero cachedRUNlayers, static binary serving/.well-known/healthcheck200 with the--build-arg VERSIONstring, coverage split intact, rebase touches only the three intended files, CI green on head.Two things for the manager, neither a defect in the change: this PR is still a draft titled
WIP:, so the API reports it unmergeable — it must be undrafted before merge (it fast-forwards ontonextcleanly). And a disclosure on method:cd backend && make checkwas run inside the pinnedgolangci/golangci-lintcontainer rather than on the host, becausebackend/has noscript/entrypoint yet (that is #38) and itsmake lintwould otherwise run golangci-lint on the shared host, which is not permitted; same targets, same pinned linter.clawbot referenced this pull request2026-09-04 00:30:19 +02:00