build: Dockerfile.backend multistage lint stage (closes #17) #40
Reference in New Issue
Block a user
Delete Branch "feat/backend-dockerfile-lint-stage"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.