build: unify the gate so root make check covers the backend (closes #16) #38

Open
clawbot wants to merge 1 commits from fix/unify-check-gate into main
Collaborator

Closes #16.

> Updated at b100814 (amended from a6a744b) to address the review.
> Changes since the original description, detailed in the rework comment:
>
> - script/bootstrap now provisions the backend toolchain — Go 1.25.7
> (reused if the installed one is at least 1.25.5) and golangci-lint
> 2.7.2, matching Dockerfile.backend — from hash-verified official
> release archives, and symlinks everything it installs onto PATH so
> make setup && make check is green on a machine that had nothing.
> - GOLANGCI_CONFIG_SHA256 in backend/script/lint is marked
> PROVISIONAL in-file, naming #31 and the canonical
> 021cc83f…346bcb.
> - The half-repo targets are renamed to make frontend-check and
> make backend-check, so every target is named after the script it
> shims. Read make check-frontend / make check-backend below as
> those names.
> - SCRIPT_DIR is gone; every script uses the mandated ROOT idiom.
> - backend/README.md's Getting Started is split into a backend/ block
> and a repo-root block.

Root make check only ever ran the frontend, so "main must always pass
make check" was being satisfied vacuously. The headline evidence, using one
identical broken Go file in both trees:

tree root make check exit
main at fbfe1df 0 — green with a backend that does not compile
this branch 2FAIL ... [build failed]

Design choice: the backend's implementations live in backend/script/*

The issue leaves this open. I picked a second script layer under backend/
rather than extending the root script/* files to reach into backend/.

Dockerfile.backend decides it. Its builder does WORKDIR /repo/backend,
COPY backend/go.mod backend/go.sum ./, COPY backend/ ., then RUN make check. The root script/ directory is never copied into that image. Had the
backend's check implementation lived in root script/*, the backend image
could not run it without copying the root script layer in and rearranging the
COPY order that keeps the go mod download layer cached. The backend is
already its own project by every other measure too — own module, README.md,
LICENSE, .golangci.yml, .dockerignore, .editorconfig — so it gets its
own entrypoints, and backend/Makefile becomes thin shims:

backend/script/{build,test,lint,fmt,fmt-check,check,run,clean}

Each one is #!/bin/sh + set -eu, no bashisms, and locates its root with
$(cd "$(dirname "$0")/.." && pwd -P) before acting; for these, that root is
the backend project root. sh -n clean.

The root scripts then compose over both halves. The frontend-only steps moved
into script/frontend-{test,lint,fmt,fmt-check}, and root script/test,
script/lint, script/fmt and script/fmt-check each run the frontend step
followed by the matching backend/script/* step. Nothing is duplicated: there
is exactly one place each tool is invoked. script/check keeps its shape
(test, lint, fmt-check) and is now the repo-wide gate, which also makes
script/precommit and the installed hook cover the backend.

script/bootstrap provisions the backend toolchain

Widening the gate without widening bootstrap left the documented fresh-clone
path (make setup) installing a pre-commit hook that rejected every commit
with golangci-lint: not found. script/bootstrap therefore also installs:

  • Go 1.25.7 — the toolchain inside the golang:1.25-alpine builder that
    Dockerfile.backend pins by digest. An already-installed Go at or above
    1.25.5 (the floor in backend/go.mod) is used as is, mirroring how node
    is handled.
  • golangci-lint 2.7.2 — exactly the version Dockerfile.backend pins
    (commit 9f61b0f53f80672872fced07b6874397c3ed197b), so local findings match
    CI. Exact match required, not a floor.

Both come from a specific official release archive whose sha256 is hardcoded
in the script and verified before anything is unpacked — never curl | sh.
Installs are version-scoped under
$HOME/.local/share/$(script/projectname)/toolchain/ and idempotent.

Because nvm-style activation never reaches make or the git hook, bootstrap
also symlinks everything it installs outside the system package manager into a
directory on PATH. That was already broken for node before this PR: on
main's bootstrap, make setup succeeded and make check then failed with
yarn: not found.

GOLANGCI_LINT_VERSION carries a reconciliation comment naming #31, which
moves the Dockerfile pin to v2.12.2 / c0d3ddc9cf3faa61a4e378e879ece580256d76e5.

The one thing that could not stay as it was: the frontend Dockerfile

Dockerfile's build stage is a node image with no Go toolchain, so it cannot
run the whole make check any more. It now runs make frontend-check
(script/frontend-check). That is identical coverage to what that image
gates today
— it is the same three frontend steps — and the backend half is
gated by Dockerfile.backend's own RUN make check. script/cibuild builds
both images, so CI still gates the whole repo. make backend-check is added as
the mirror of frontend-check; both exist for the Dockerfiles, and make check remains what a human should run.

The alternative — installing a hash-pinned Go toolchain plus golangci-lint into
the node build stage — would roughly double that image's build time to gate
something already gated, so I did not do it.

backend/Makefile's docker target is gone as well

Not just hooks. Dockerfile.backend lives at the repo root and builds with
the repo root as its context; a backend/script/docker would have had to cd
out of backend/, breaking the root-discovery convention. The backend image is
now built by the root script/docker (tagged netwatch-server) and by
script/cibuild. backend/README.md says so explicitly so nobody goes looking
for the target.

Changes

  • script/bootstrap — provisions Go and golangci-lint from hash-verified
    release archives and puts every provisioned tool on PATH.
  • backend/script/* (new, 8 scripts) + backend/Makefile rewritten as
    shims, hooks and docker removed.
  • script/frontend-{test,lint,fmt,fmt-check,check} (new).
  • script/{test,lint,fmt,fmt-check} now cover both halves;
    script/check unchanged in shape.
  • script/cibuild builds both images; script/docker builds and tags
    both.
  • .gitea/workflows/check.yml — exactly one build step, - run: script/cibuild. The raw docker build -f Dockerfile.backend . is gone.
  • DockerfileRUN make check becomes RUN make frontend-check, with
    the reason in a comment.
  • Makefile — adds frontend-check and backend-check.
  • README.md and backend/README.md — Entrypoints sections describe
    every script, including which ones cover which half.
  • TODO.md — one additive line in Completed Steps, in the same commit.
    Deliberately minimal: PR #31 and PR #35 both rewrite other parts of this
    file, and #31 already corrects the stale Status and Next Step.

PR #31's drift guard is preserved, with one constant to reconcile

#31 (open, merge-ready, unmerged) puts a sha256 drift guard for
.golangci.yml into backend/Makefile's lint target. I restructured that
target out of existence, so the guard moved with the implementation into
backend/script/lint, unchanged in behaviour:

  • same offline sha256sum comparison against a constant, no network, no
    golangci-lint config verify, nothing unpinned;
  • the Darwin fallback that #31 expressed as a SHA256SUM make variable is now
    a sha256() shell function that prefers sha256sum (coreutils on Linux,
    busybox in the alpine builder) and falls back to shasum -a 256;
  • same failure output: expected hash, actual hash, and "restore it verbatim
    from sneak/prompts; do not edit it".

The one difference, and it needs a decision at merge time. This branch is
cut from main, where .golangci.yml is still the pre-#31 file. Pinning
#31's 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb here
would make make lint fail on this branch and on main until #31 lands, so
GOLANGCI_CONFIG_SHA256 in backend/script/lint is pinned to the config that
is actually on main right now,
33ba2bf7fe4a44779d09b0fb31d6daf03685f8dc9d2bc417f963d7aabb0d17dc. The
constant is now marked PROVISIONAL in the file, naming #31 and the
canonical hash, so nobody reading it on main can mistake the pinned file for
the standard.

Whichever of the two PRs lands second must reconcile exactly one line:

  • #31 first — I rebase, backend/Makefile conflicts (its lint recipe no
    longer exists), I keep backend/script/lint and set the constant to
    021cc83f…346bcb.
  • this first#31 rebases, drops its Makefile hunk, and sets the same
    constant in backend/script/lint alongside its .golangci.yml replacement.

The reviewer performed both merge orders and confirmed they fail closed:
make lint exits 2 printing both hashes, in either direction. I did not touch
.golangci.yml (that is #14/#31's file), and the golangci-lint pin I added to
script/bootstrap matches Dockerfile.backend's current pin, with the same
reconciliation note.

Note on #37 (script/cibuild cache-serves an unchanged tree)

Not fixed here, per scope. The restructuring makes it easier: every docker
build CI performs now goes through one function in script/cibuild,

build_image() {
    timeout 300 docker build -f "$1" .
}

so #37's cache-busting lands in exactly one place and applies to both images at
once. It is deliberately not delegated to script/docker, so that a CI-only
cache policy cannot leak into local make docker.

Note on #33 (worktree .git is a file)

Neither fixed nor worsened. Building from a git worktree fails in
vite.config.js, which calls execSync("git rev-parse HEAD"): inside the
container .git is a worktree pointer file whose gitdir does not exist, so
git fails and the config throws. Dockerfile.backend tolerates it — my
backend/script/build uses git describe --always --dirty 2>/dev/null || echo unknown, which it must, because set -eu would otherwise abort the build
where the old $(shell ...) in the Makefile silently produced an empty
version. All docker verification was therefore run from a normal clone.

One behaviour change worth naming: backend/Makefile's old
./netwatch-server: $(shell find . -name '*.go') go.mod go.sum prerequisite
list is gone, so make build no longer short-circuits on an up-to-date binary
and always calls go build. Go's own build cache makes the no-op case ~0.1s.

Verification

All of it with make targets and script/ entrypoints only; no raw go,
gofmt, yarn, prettier or golangci-lint. Full evidence, including the
fresh-container transcript, is in the rework comment.

The definitive gate. debian:bookworm-slim with only make/git/curl
(plus ca-certificates), a fresh clone made inside the container, nothing
else: make setup exits 0, leaves go, gofmt, golangci-lint, node and
yarn on PATH, and make check then exits 0. A second make setup
re-downloads nothing and a second make check is still green with
git status --short empty.

The core fix — same broken Go file in both trees. A bogus argument to
s.respondJSON(...) in backend/internal/handlers/healthcheck.go:

  • worktree at main (fbfe1df), root make checkexit 0;
  • this branch, root make checkexit 2,
    internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile /
    FAIL ... [build failed];
  • reverted, root make checkexit 0, git status --short empty.

Gate results:

  • Root make check — passes, 6.7s, tree clean afterwards.
  • make frontend-check and make backend-check — both exit 0.
  • script/cibuildexit 0, 1m18s, with zero CACHED layers in the
    whole BUILDKIT_PROGRESS=plain log: RUN make frontend-check ran a real
    vite build and prettier --check, and RUN make check ran a real
    go test and reported 0 issues. in 12.3s.
  • sh -n on all 25 scripts — clean; all mode 100755.

One hook installer, gating both halves. In a fresh clone,
make hooks writes .git/hooks/pre-commit containing exactly:

#!/bin/sh
set -e
script/precommit
  • commit with a broken Go file → rejected, exit 1, FAIL ... [build failed];
  • commit with a prettier-violating src/main.jsrejected, exit 1,
    "Code style issues found in the above file";
  • clean-tree commit → accepted, exit 0.

backend/Makefile has no hooks target left, so nothing can clobber it.

Closes #16. > **Updated at `b100814`** (amended from `a6a744b`) to address the review. > Changes since the original description, detailed in the rework comment: > > - `script/bootstrap` now provisions the backend toolchain — Go `1.25.7` > (reused if the installed one is at least `1.25.5`) and golangci-lint > `2.7.2`, matching `Dockerfile.backend` — from hash-verified official > release archives, and symlinks everything it installs onto `PATH` so > `make setup && make check` is green on a machine that had nothing. > - `GOLANGCI_CONFIG_SHA256` in `backend/script/lint` is marked > **PROVISIONAL** in-file, naming #31 and the canonical > `021cc83f…346bcb`. > - The half-repo targets are renamed to `make frontend-check` and > `make backend-check`, so every target is named after the script it > shims. Read `make check-frontend` / `make check-backend` below as > those names. > - `SCRIPT_DIR` is gone; every script uses the mandated `ROOT` idiom. > - `backend/README.md`'s Getting Started is split into a `backend/` block > and a repo-root block. Root `make check` only ever ran the frontend, so "`main` must always pass `make check`" was being satisfied vacuously. The headline evidence, using one identical broken Go file in both trees: | tree | root `make check` exit | | --- | --- | | `main` at `fbfe1df` | **0** — green with a backend that does not compile | | this branch | **2** — `FAIL ... [build failed]` | ## Design choice: the backend's implementations live in `backend/script/*` The issue leaves this open. I picked a second script layer under `backend/` rather than extending the root `script/*` files to reach into `backend/`. `Dockerfile.backend` decides it. Its builder does `WORKDIR /repo/backend`, `COPY backend/go.mod backend/go.sum ./`, `COPY backend/ .`, then `RUN make check`. The root `script/` directory is never copied into that image. Had the backend's check implementation lived in root `script/*`, the backend image could not run it without copying the root script layer in and rearranging the COPY order that keeps the `go mod download` layer cached. The backend is already its own project by every other measure too — own module, `README.md`, `LICENSE`, `.golangci.yml`, `.dockerignore`, `.editorconfig` — so it gets its own entrypoints, and `backend/Makefile` becomes thin shims: `backend/script/{build,test,lint,fmt,fmt-check,check,run,clean}` Each one is `#!/bin/sh` + `set -eu`, no bashisms, and locates its root with `$(cd "$(dirname "$0")/.." && pwd -P)` before acting; for these, that root is the backend project root. `sh -n` clean. The root scripts then compose over both halves. The frontend-only steps moved into `script/frontend-{test,lint,fmt,fmt-check}`, and root `script/test`, `script/lint`, `script/fmt` and `script/fmt-check` each run the frontend step followed by the matching `backend/script/*` step. Nothing is duplicated: there is exactly one place each tool is invoked. `script/check` keeps its shape (test, lint, fmt-check) and is now the repo-wide gate, which also makes `script/precommit` and the installed hook cover the backend. ## `script/bootstrap` provisions the backend toolchain Widening the gate without widening bootstrap left the documented fresh-clone path (`make setup`) installing a pre-commit hook that rejected every commit with `golangci-lint: not found`. `script/bootstrap` therefore also installs: - **Go 1.25.7** — the toolchain inside the `golang:1.25-alpine` builder that `Dockerfile.backend` pins by digest. An already-installed Go at or above `1.25.5` (the floor in `backend/go.mod`) is used as is, mirroring how node is handled. - **golangci-lint 2.7.2** — exactly the version `Dockerfile.backend` pins (commit `9f61b0f53f80672872fced07b6874397c3ed197b`), so local findings match CI. Exact match required, not a floor. Both come from a specific official release archive whose sha256 is hardcoded in the script and verified before anything is unpacked — never `curl | sh`. Installs are version-scoped under `$HOME/.local/share/$(script/projectname)/toolchain/` and idempotent. Because nvm-style activation never reaches `make` or the git hook, bootstrap also symlinks everything it installs outside the system package manager into a directory on `PATH`. That was already broken for node before this PR: on `main`'s bootstrap, `make setup` succeeded and `make check` then failed with `yarn: not found`. `GOLANGCI_LINT_VERSION` carries a reconciliation comment naming #31, which moves the Dockerfile pin to `v2.12.2` / `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. ### The one thing that could not stay as it was: the frontend Dockerfile `Dockerfile`'s build stage is a node image with no Go toolchain, so it cannot run the whole `make check` any more. It now runs `make frontend-check` (`script/frontend-check`). That is **identical coverage to what that image gates today** — it is the same three frontend steps — and the backend half is gated by `Dockerfile.backend`'s own `RUN make check`. `script/cibuild` builds both images, so CI still gates the whole repo. `make backend-check` is added as the mirror of `frontend-check`; both exist for the Dockerfiles, and `make check` remains what a human should run. The alternative — installing a hash-pinned Go toolchain plus golangci-lint into the node build stage — would roughly double that image's build time to gate something already gated, so I did not do it. ### `backend/Makefile`'s `docker` target is gone as well Not just `hooks`. `Dockerfile.backend` lives at the repo root and builds with the repo root as its context; a `backend/script/docker` would have had to `cd` out of `backend/`, breaking the root-discovery convention. The backend image is now built by the root `script/docker` (tagged `netwatch-server`) and by `script/cibuild`. `backend/README.md` says so explicitly so nobody goes looking for the target. ## Changes - **`script/bootstrap`** — provisions Go and golangci-lint from hash-verified release archives and puts every provisioned tool on `PATH`. - **`backend/script/*`** (new, 8 scripts) + **`backend/Makefile`** rewritten as shims, `hooks` and `docker` removed. - **`script/frontend-{test,lint,fmt,fmt-check,check}`** (new). - **`script/{test,lint,fmt,fmt-check}`** now cover both halves; **`script/check`** unchanged in shape. - **`script/cibuild`** builds both images; **`script/docker`** builds and tags both. - **`.gitea/workflows/check.yml`** — exactly one build step, `- run: script/cibuild`. The raw `docker build -f Dockerfile.backend .` is gone. - **`Dockerfile`** — `RUN make check` becomes `RUN make frontend-check`, with the reason in a comment. - **`Makefile`** — adds `frontend-check` and `backend-check`. - **`README.md`** and **`backend/README.md`** — Entrypoints sections describe every script, including which ones cover which half. - **`TODO.md`** — one additive line in Completed Steps, in the same commit. Deliberately minimal: PR #31 and PR #35 both rewrite other parts of this file, and #31 already corrects the stale Status and Next Step. ## PR #31's drift guard is preserved, with one constant to reconcile #31 (open, merge-ready, unmerged) puts a sha256 drift guard for `.golangci.yml` into `backend/Makefile`'s `lint` target. I restructured that target out of existence, so the guard moved with the implementation into `backend/script/lint`, unchanged in behaviour: - same offline `sha256sum` comparison against a constant, no network, no `golangci-lint config verify`, nothing unpinned; - the Darwin fallback that #31 expressed as a `SHA256SUM` make variable is now a `sha256()` shell function that prefers `sha256sum` (coreutils on Linux, busybox in the alpine builder) and falls back to `shasum -a 256`; - same failure output: expected hash, actual hash, and "restore it verbatim from sneak/prompts; do not edit it". **The one difference, and it needs a decision at merge time.** This branch is cut from `main`, where `.golangci.yml` is still the pre-#31 file. Pinning #31's `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb` here would make `make lint` fail on this branch and on `main` until #31 lands, so `GOLANGCI_CONFIG_SHA256` in `backend/script/lint` is pinned to the config that is actually on `main` right now, `33ba2bf7fe4a44779d09b0fb31d6daf03685f8dc9d2bc417f963d7aabb0d17dc`. The constant is now marked **PROVISIONAL** in the file, naming #31 and the canonical hash, so nobody reading it on `main` can mistake the pinned file for the standard. Whichever of the two PRs lands second must reconcile exactly one line: - **#31 first** — I rebase, `backend/Makefile` conflicts (its `lint` recipe no longer exists), I keep `backend/script/lint` and set the constant to `021cc83f…346bcb`. - **this first** — #31 rebases, drops its Makefile hunk, and sets the same constant in `backend/script/lint` alongside its `.golangci.yml` replacement. The reviewer performed both merge orders and confirmed they fail **closed**: `make lint` exits 2 printing both hashes, in either direction. I did not touch `.golangci.yml` (that is #14/#31's file), and the golangci-lint pin I added to `script/bootstrap` matches `Dockerfile.backend`'s current pin, with the same reconciliation note. ## Note on #37 (`script/cibuild` cache-serves an unchanged tree) Not fixed here, per scope. The restructuring makes it **easier**: every docker build CI performs now goes through one function in `script/cibuild`, ```sh build_image() { timeout 300 docker build -f "$1" . } ``` so #37's cache-busting lands in exactly one place and applies to both images at once. It is deliberately not delegated to `script/docker`, so that a CI-only cache policy cannot leak into local `make docker`. ## Note on #33 (worktree `.git` is a file) Neither fixed nor worsened. Building from a git worktree fails in `vite.config.js`, which calls `execSync("git rev-parse HEAD")`: inside the container `.git` is a worktree pointer file whose gitdir does not exist, so `git` fails and the config throws. `Dockerfile.backend` tolerates it — my `backend/script/build` uses `git describe --always --dirty 2>/dev/null || echo unknown`, which it must, because `set -eu` would otherwise abort the build where the old `$(shell ...)` in the Makefile silently produced an empty version. All docker verification was therefore run from a normal clone. One behaviour change worth naming: `backend/Makefile`'s old `./netwatch-server: $(shell find . -name '*.go') go.mod go.sum` prerequisite list is gone, so `make build` no longer short-circuits on an up-to-date binary and always calls `go build`. Go's own build cache makes the no-op case ~0.1s. ## Verification All of it with `make` targets and `script/` entrypoints only; no raw `go`, `gofmt`, `yarn`, `prettier` or `golangci-lint`. Full evidence, including the fresh-container transcript, is in the rework comment. **The definitive gate.** `debian:bookworm-slim` with only make/git/curl (plus `ca-certificates`), a fresh clone made inside the container, nothing else: `make setup` exits 0, leaves `go`, `gofmt`, `golangci-lint`, `node` and `yarn` on `PATH`, and `make check` then exits **0**. A second `make setup` re-downloads nothing and a second `make check` is still green with `git status --short` empty. **The core fix — same broken Go file in both trees.** A bogus argument to `s.respondJSON(...)` in `backend/internal/handlers/healthcheck.go`: - worktree at `main` (`fbfe1df`), root `make check` → **exit 0**; - this branch, root `make check` → **exit 2**, `internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile` / `FAIL ... [build failed]`; - reverted, root `make check` → **exit 0**, `git status --short` empty. **Gate results:** - Root `make check` — passes, 6.7s, tree clean afterwards. - `make frontend-check` and `make backend-check` — both exit 0. - `script/cibuild` — **exit 0**, 1m18s, with **zero** `CACHED` layers in the whole `BUILDKIT_PROGRESS=plain` log: `RUN make frontend-check` ran a real `vite build` and `prettier --check`, and `RUN make check` ran a real `go test` and reported `0 issues.` in 12.3s. - `sh -n` on all 25 scripts — clean; all mode `100755`. **One hook installer, gating both halves.** In a fresh clone, `make hooks` writes `.git/hooks/pre-commit` containing exactly: ```sh #!/bin/sh set -e script/precommit ``` - commit with a broken Go file → **rejected**, exit 1, `FAIL ... [build failed]`; - commit with a prettier-violating `src/main.js` → **rejected**, exit 1, "Code style issues found in the above file"; - clean-tree commit → **accepted**, exit 0. `backend/Makefile` has no `hooks` target left, so nothing can clobber it.
clawbot added the needs-review label 2026-08-09 07:59:07 +02:00
clawbot added 1 commit 2026-08-09 07:59:08 +02:00
build: unify the gate so root make check covers the backend (closes #16)
All checks were successful
check / check (push) Successful in 42s
a6a744b45f
Root `make check` only ever ran the frontend, so the "main is always
green" policy was satisfied vacuously: the Go backend could be entirely
broken and the root gate stayed green.

- The backend moves onto scripts-to-rule-them-all. Its test, lint, fmt,
  fmt-check, build, run and clean implementations now live in
  `backend/script/`, and `backend/Makefile` is thin shims. The backend
  is its own project (own module, README, LICENSE, linter config,
  Dockerfile stage), and `Dockerfile.backend` only copies `backend/`
  into its builder, so its scripts have to live under `backend/`.
- The root `script/test`, `script/lint`, `script/fmt` and
  `script/fmt-check` now run the frontend step and then the matching
  `backend/script/*` step, so `script/check` — and therefore the
  pre-commit hook — gates both halves. The frontend-only steps moved
  into `script/frontend-*` so nothing is duplicated.
- `script/frontend-check` is the frontend half of the gate, exposed as
  the `check-frontend` target, for the frontend Dockerfile: its build
  stage is a node image with no Go toolchain. The backend half is gated
  by `Dockerfile.backend`, and `script/cibuild` builds both images, so
  the two Dockerfiles together still gate the whole repo. The
  `check-backend` target is the mirror of it.
- `script/cibuild` builds both images through one `build_image` helper,
  and the Gitea workflow's only build step is `script/cibuild`; the raw
  `docker build -f Dockerfile.backend .` is gone from the workflow.
  `script/docker` likewise builds and tags both images.
- `backend/Makefile`'s `hooks` target is removed. It wrote the same
  `.git/hooks/pre-commit` as `script/install-precommit`, so the two
  clobbered each other and the developer silently ended up gating on
  only one half of the repo. `script/install-precommit` is now the only
  installer, and the hook it writes runs the repo-wide `script/check`.
- `backend/Makefile`'s `docker` target is removed too: the backend image
  builds from the repo root with a root-level Dockerfile, so it belongs
  to the root `script/docker` and `script/cibuild` rather than to a
  backend script that would have to reach outside `backend/`.
- `backend/script/lint` verifies that `.golangci.yml` still matches its
  pinned sha256 before running the linter. Offline hash comparison, no
  network.

READMEs at the root and in `backend/` document every script, and
`TODO.md` records the change.
clawbot self-assigned this 2026-08-09 07:59:14 +02:00
Author
Collaborator

Summary

One commit, a6a744b, 27 files, +387/-76.

What was built. The backend moved onto scripts-to-rule-them-all with its own
script layer, backend/script/{build,test,lint,fmt,fmt-check,check,run,clean},
because Dockerfile.backend only copies backend/ into its builder and so
could never reach a root-level implementation. backend/Makefile is now nothing
but shims. The frontend-only steps moved to
script/frontend-{test,lint,fmt,fmt-check}, and the root script/test,
script/lint, script/fmt and script/fmt-check run the frontend step and
then the backend one, so script/check — and therefore script/precommit and
the installed hook — covers the whole repo. script/cibuild and script/docker
build both images; the workflow's only build step is now - run: script/cibuild.
backend/Makefile's hooks target is deleted (it was clobbering
script/install-precommit's hook), and its docker target with it, since the
backend image builds from the repo root context. Both READMEs document every
script; TODO.md got one additive line in the same commit.

The frontend Dockerfile now runs make check-frontend instead of make check: that build stage is a node image with no Go toolchain. Coverage of that
image is unchanged — it is the same three frontend steps — and the backend half
is gated by Dockerfile.backend, with script/cibuild building both.

How it was verified. The point of the issue is the gate, so the same broken
Go file was dropped into two trees: on main at fbfe1df the root make check
exits 0, on this branch it exits 2 with FAIL ... [build failed].
Reverted, it is green again with an empty git diff. The fmt-check and lint
stages were proven wired in the same way — a mis-indented Go import fails root
make fmt-check (and root make fmt fixes it), and a byte appended to
.golangci.yml fails root make lint on the drift guard before the linter
runs.

Root make check passes in 7.7s and leaves git status --short empty on a
clean tree. cd backend && make check passes, 0 issues. make test is 0.9s
warm / 4.9s cold, with the backend's 30s timeout retained. script/cibuild
exits 0 in 1m35s and really builds both images, each under its own timeout 300; the backend's in-container make check reported 0 issues. in 21.4s, so
the drift guard works with busybox sha256sum in the alpine builder. In a fresh
clone, make hooks installs the single hook and it rejects a broken-Go commit,
rejects a prettier-violating src/main.js commit, and accepts a clean one.

Two things a reviewer should look at deliberately: the
GOLANGCI_CONFIG_SHA256 constant in backend/script/lint, which carries PR
#31's drift guard but is pinned to the config currently on main so this branch
stays green, and the build_image helper in script/cibuild, which is where
#37's cache fix should land. Both are explained in full in the PR description.

## Summary One commit, `a6a744b`, 27 files, +387/-76. **What was built.** The backend moved onto scripts-to-rule-them-all with its own script layer, `backend/script/{build,test,lint,fmt,fmt-check,check,run,clean}`, because `Dockerfile.backend` only copies `backend/` into its builder and so could never reach a root-level implementation. `backend/Makefile` is now nothing but shims. The frontend-only steps moved to `script/frontend-{test,lint,fmt,fmt-check}`, and the root `script/test`, `script/lint`, `script/fmt` and `script/fmt-check` run the frontend step and then the backend one, so `script/check` — and therefore `script/precommit` and the installed hook — covers the whole repo. `script/cibuild` and `script/docker` build both images; the workflow's only build step is now `- run: script/cibuild`. `backend/Makefile`'s `hooks` target is deleted (it was clobbering `script/install-precommit`'s hook), and its `docker` target with it, since the backend image builds from the repo root context. Both READMEs document every script; `TODO.md` got one additive line in the same commit. The frontend `Dockerfile` now runs `make check-frontend` instead of `make check`: that build stage is a node image with no Go toolchain. Coverage of that image is unchanged — it is the same three frontend steps — and the backend half is gated by `Dockerfile.backend`, with `script/cibuild` building both. **How it was verified.** The point of the issue is the gate, so the same broken Go file was dropped into two trees: on `main` at `fbfe1df` the root `make check` exits **0**, on this branch it exits **2** with `FAIL ... [build failed]`. Reverted, it is green again with an empty `git diff`. The `fmt-check` and `lint` stages were proven wired in the same way — a mis-indented Go import fails root `make fmt-check` (and root `make fmt` fixes it), and a byte appended to `.golangci.yml` fails root `make lint` on the drift guard before the linter runs. Root `make check` passes in 7.7s and leaves `git status --short` empty on a clean tree. `cd backend && make check` passes, `0 issues.` `make test` is 0.9s warm / 4.9s cold, with the backend's 30s `timeout` retained. `script/cibuild` exits 0 in 1m35s and really builds both images, each under its own `timeout 300`; the backend's in-container `make check` reported `0 issues.` in 21.4s, so the drift guard works with busybox `sha256sum` in the alpine builder. In a fresh clone, `make hooks` installs the single hook and it rejects a broken-Go commit, rejects a prettier-violating `src/main.js` commit, and accepts a clean one. Two things a reviewer should look at deliberately: the `GOLANGCI_CONFIG_SHA256` constant in `backend/script/lint`, which carries PR #31's drift guard but is pinned to the config currently on `main` so this branch stays green, and the `build_image` helper in `script/cibuild`, which is where #37's cache fix should land. Both are explained in full in the PR description.
Author
Collaborator

Review of PR #38 — independent adversarial review

Verdict: FAIL — needs-rework.

Mergeable against current main (fbfe1df), CI green, one commit, no scope
creep, no attribution trailers, and every box in #16's definition of done is
independently satisfied. The central claim of the issue is real and the fix is
real — I reproduced both halves. What blocks it is one defect this change
introduces outside the DoD: the repo's documented onboarding path now produces a
checkout in which no commit can be made.


1. The central claim — VERIFIED, both halves

Same break in both trees: added a bogus fifth argument to s.respondJSON(...)
in backend/internal/handlers/healthcheck.go.

tree root make check
clone at main fbfe1df exit 0 — "All matched files use Prettier code style!", zero Go executed
clone at a6a744b exit 2internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile / FAIL ... [build failed]
a6a744b, reverted exit 0, git status --short empty

The "before" half reproduces. The vacuous green was real; this is not a
non-problem.

The other two stages are genuinely wired, not just test:

  • fmt-check — mis-indented the import line: root make fmt-check exit
    2
    , "Files not formatted: internal/handlers/healthcheck.go". Root make fmt
    then fixed it (it reaches Go now) and left git status --short empty.
  • lint, drift guard — appended a byte to backend/.golangci.yml: root make lint exit 2, expected 33ba2bf7...d17dc / actual 3fb875d5...fc614,
    before the linter ran.
  • lint, real finding — I also planted an actual Go lint violation
    (unchecked w.Write return) with the config hash intact, to prove the guard
    is not the only thing wired: root make lint exit 2,
    internal/handlers/lintprobe.go:7:9: Error return value of 'w.Write' is not checked (errcheck). golangci-lint really runs and its failure really
    propagates to the root.

2. BLOCKING — script/bootstrap no longer installs what the gate requires

script/bootstrap (unchanged by this PR) installs make, git, node, yarn and the
JS deps. It installs no Go toolchain and no golangci-lint. Before this PR
that was correct: root script/check needed only node and yarn. After it, root
script/check calls backend/script/lint, which calls golangci-lint, and
backend/script/test, which calls go.

Demonstrated, root make check with golangci-lint absent from PATH:

/tmp/.../backend/script/lint: 43: golangci-lint: not found
make: *** [Makefile:31: check] Error 127

Why this matters, and why it is not merely cosmetic: script/setup is
bootstrap + install-precommit. The documented fresh-clone path is make setup. After this PR, on a machine that script/bootstrap has just fully
provisioned, make setup installs a pre-commit hook that runs the repo-wide
script/check — so every commit, including a frontend-only one-line change,
is rejected with golangci-lint: not found. REPO_POLICIES.md states that
script/bootstrap "installs all dependencies idempotently and assumes nothing
is present"; that sentence is false for this repo's own gate once this lands.

This is a consequence created by this change. Widening the gate to the backend
without widening script/bootstrap to provision the backend's toolchain leaves
the two halves of scripts-to-rule-them-all inconsistent.

Acceptable looks like: script/bootstrap also provisions Go and
golangci-lint, at pinned versions, hash-verified per the hash-pinning rule (the
script already has verify_sha256 and a pkg_install matrix to build on) — the
same treatment nvm already gets. Note the pinned golangci-lint should agree with
Dockerfile.backend's pin, which is the version CI actually gates on.


3. MAJOR — backend/script/lint pins the known-broken config and says nothing about it in-repo

backend/script/lint:16

GOLANGCI_CONFIG_SHA256="33ba2bf7fe4a44779d09b0fb31d6daf03685f8dc9d2bc417f963d7aabb0d17dc"

Pinning main's current file rather than #31's canonical
021cc83f...346bcb is the right call for a branch cut from main — pinning the
canonical hash would red-line this branch and main immediately. I am not
faulting the choice. I am faulting what the file says about it.

The comment block directly above that constant reads:

> Its last silent drift replaced the v2 schema with v1 keys, which left every
> threshold in the file inert while the build stayed green. This script
> therefore asserts the file still matches the pinned copy byte for byte.

The file it pins is that broken v1-schema file. As landed on main, this
script asserts that a schema-invalid config is the pinned standard, in a comment
that explains why schema-invalid configs are dangerous. There is no in-file
marker that the pin is provisional. Anyone reading backend/script/lint on
main would reasonably conclude the current .golangci.yml is canonical. If
#31 slips, this converts a known-bad state into an actively asserted one — the
exact "green you did not earn" shape #37 and #14 exist to eliminate. The PR body
explains all of this, but the PR body is not in the repo.

Acceptable looks like: a comment on that constant naming #31, naming
021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb, and stating
that this pin is main's current file pending that PR.

Sequencing hazard — I tested both merge orders concretely; it fails CLOSED

The claim that git forces the reconciliation in both directions is literally
true but points at the wrong file
, and I verified the consequences rather than
reasoning about them. Both orders were performed in scratch clones, conflicts
resolved the obvious way, then make lint run.

Both directions conflict in backend/Makefile and TODO.md only.
backend/script/lint is new on #38, so it merges clean and silently, carrying
33ba2bf7.... backend/.golangci.yml is touched only by #31, so it merges
clean and becomes 021cc83f.... So the file a merger is forced to open is not
the file carrying the stale hash.

Resolved naively (keep #38's @script/lint shim, drop #31's inline recipe), in
both orders:

.golangci.yml has drifted from the pinned config.
  expected 33ba2bf7fe4a44779d09b0fb31d6daf03685f8dc9d2bc417f963d7aabb0d17dc
  actual   021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
make: *** [Makefile:22: lint] Error 1

make lint exit 2 in both orders. That is fail-closed: loud, immediate, and
it names both hashes. Neither merge order can silently enforce the invalid
config, and neither can silently skip the guard. Two further mitigations: #31's
canonical constant is physically inside the backend/Makefile conflict hunk, so
a merger sees it while resolving; and the PR body names backend/script/lint
explicitly for both directions, not just "reconcile the hash".

So this is not a blocking finding — it is the documentation gap in the
previous paragraph. Recording the test result here because the claim as written
deserved verification.


4. Minor findings

  1. Dockerfile:15 — literal policy deviation. REPO_POLICIES.md: "All
    Dockerfiles must run make check as a build step." This one now runs make check-frontend. The coverage argument is sound and I confirmed it: main's
    script/check is frontend-only, so make check-frontend is byte-equivalent
    in effect, and Dockerfile.backend's RUN make check covers the other half.
    But the guarantee has changed in kind — the frontend image used to inherit
    whatever make check grew into, and now it is pinned to one half. Flagging
    for the owner's judgement, not asking for a change.
  2. script/frontend-lint and script/frontend-fmt-check are byte-identical
    (yarn prettier --check .). script/check therefore runs prettier twice —
    visible in the Docker build log as two consecutive identical
    prettier --check . runs. The duplication existed on main between
    script/lint and script/fmt-check; this PR carries it forward into two
    new files rather than resolving it.
  3. Naming: target and script names are transposed. make check-frontend
    shims to script/frontend-check; make check-backend to
    backend/script/check. Every other target in both Makefiles maps 1:1 onto
    an identically named script. Consistent naming would be either
    make frontend-check or script/check-frontend.
  4. Idiom drift between the composing scripts. script/test, script/lint,
    script/fmt and script/fmt-check invoke siblings as
    "$ROOT/script/frontend-...", while script/check,
    script/frontend-check, script/precommit, backend/script/check and
    script/setup use "$SCRIPT_DIR/...". Both work; pick one.
  5. Two independent 30-second timeouts. Root make test is
    timeout 30 yarn build then timeout 30 go test ./... — worst case 60s
    against the policy's single 30s bound. Measured 1.1s warm, so no operational
    problem; noting the bound, not the runtime. Changing the backend's test
    invocation is explicitly out of scope for #16.
  6. backend/README.md:7-17 presents one copy-pasteable block mixing
    commands run from backend/ (make run, make check) with make docker,
    which only exists at the repo root. The inline comment says so, but the block
    reads as a single sequence.
  7. backend/script/lint failure text leads with the wrong remedy. "Restore
    it verbatim from sneak/prompts; do not edit it." is the first line a reader
    sees, and in the post-#31 case the correct action is the opposite — update
    the constant. The following sentence does say that; consider reordering.
  8. TODO.md merge trap (cosmetic). In either order, resolving the TODO.md
    conflict by taking one side wholesale discards the other PR's edits — I
    confirmed that taking #38's side after #31 reverts #31's Status/Next Step
    corrections back to the stale text. Both PR bodies flag it; the correct
    resolution is to keep both additions.

5. What I independently verified as good

  • DoD, all boxes. Root gate covers both halves (demonstrated above);
    backend implementations in backend/script/* with backend/Makefile reduced
    to shims and the choice documented; script/cibuild builds both images;
    workflow has exactly one build step, - run: script/cibuild, with no raw
    docker build; exactly one hook installer; both READMEs updated; make check
    passes and does not modify tracked files; script/cibuild succeeds locally;
    TODO.md in the same commit; title ends with (closes #16).
  • script/cibuild really executes, not cached. With plain BuildKit
    progress: exit 0, 32s wall, both [internal] load build definition from Dockerfile and ... from Dockerfile.backend. The two check layers were
    not CACHED — #13 [build 7/7] RUN make check-frontend DONE 6.0s with
    real vite build and prettier --check output, and #15 [builder 9/10] RUN make check DONE 14.3s with real go test output and 0 issues. The drift
    guard passes under busybox sha256sum in the alpine builder. Both builds are
    wrapped in timeout 300 and finished far inside 5 minutes. Per #37, CI's own
    42s green is weak evidence; this local run is the evidence.
  • make docker builds and tags bothnetwatch:latest and
    netwatch-server:latest both present afterwards.
  • Exactly one hook installer, gating both halves. grep finds only
    script/install-precommit writing .git/hooks/pre-commit;
    backend/Makefile has no hooks target. Installed it in a scratch clone and
    exercised all three cases: broken-Go commit rejected (exit 1,
    FAIL ... [build failed]); prettier-violating src/main.js commit
    rejected (exit 1, "Code style issues found in the above file"); clean
    commit accepted (exit 0).
  • Removed backend targets leave nothing dangling. No reference to
    backend's docker or hooks targets survives anywhere outside
    REPO_POLICIES.md's generic prose; both READMEs explain the removal.
  • All 25 scripts (17 root, 8 backend): #!/bin/sh, set -eu, sh -n
    clean, no bashisms, mode 100755 in the git index for every one of the eight
    new backend scripts and five new root scripts. Root discovery uses the
    mandated $(cd "$(dirname "$0")/.." && pwd -P) idiom.
  • script/projectname byte-identical to main (git diff empty).
  • No coverage lost in the frontend split. script/frontend-test,
    -lint, -fmt, -fmt-check reproduce main's script/test, lint, fmt,
    fmt-check exactly, including the timeout 30 on yarn build;
    check-frontend is the same three steps main's Dockerfile ran.
  • Both half-gates work standalone: make check-frontend exit 0,
    make check-backend exit 0, make -n parses the multi-line .PHONY.
  • backend/script/build does not silently version binaries as unknown.
    In a normal clone git describe --always --dirty returns a6a744b and the
    string is present in the built binary (grepped). The || echo unknown arm is
    reached only when git genuinely fails, which is what set -eu requires. In
    Dockerfile.backend the COPY .git /repo/.git layer is untouched, so the
    in-image version still resolves. The lost $(shell find ...) prerequisite
    list is a real behaviour change (always rebuilds) and is disclosed in the PR
    body.
  • make fmt is safe with the drift guard. .prettierignore contains
    backend/, so script/frontend-fmt cannot rewrite backend/.golangci.yml
    and invalidate its own hash pin. I checked this specifically.
  • #37 not implemented here. build_image() is
    timeout 300 docker build -f "$1" . with no cache control. The claim that it
    makes #37 easier holds for the CI path — both images go through one function —
    though script/docker deliberately does not share it, so #37 will need to
    decide whether local builds are in scope.
  • No .dockerignore / .prettierignore / .editorconfig / .gitignore
    changes
    #28's and #35's files are untouched. 27 files, all attributable
    to #16.
  • No tooling-vendor references or attribution trailers anywhere in the
    diff, the commit message, or the PR body. Clean merge against current main
    (git merge-tree rc 0). make fmt leaves the tree clean. Inclusive
    terminology scan clean. No trailing-whitespace errors; every new file ends
    with a newline.
  • #33 not worsened. All my verification ran in scratch clones, per the known
    worktree limitation; nothing in this PR touches script/install-precommit's
    .git/hooks path assumption or Dockerfile.backend's COPY .git.

Summary

This is careful, well-argued work and the hard part — proving the gate was
vacuous and making it not be — is done correctly and verifiably. Two things to
fix before merge: extend script/bootstrap so a freshly bootstrapped machine
can actually pass the gate it now installs a hook for, and add an in-file note
on GOLANGCI_CONFIG_SHA256 naming #31 and the canonical hash. Neither is large.
The minor items are optional.

## Review of PR #38 — independent adversarial review **Verdict: FAIL — `needs-rework`.** Mergeable against current `main` (`fbfe1df`), CI green, one commit, no scope creep, no attribution trailers, and every box in #16's definition of done is independently satisfied. The central claim of the issue is real and the fix is real — I reproduced both halves. What blocks it is one defect this change introduces outside the DoD: the repo's documented onboarding path now produces a checkout in which no commit can be made. --- ## 1. The central claim — VERIFIED, both halves Same break in both trees: added a bogus fifth argument to `s.respondJSON(...)` in `backend/internal/handlers/healthcheck.go`. | tree | root `make check` | | --- | --- | | clone at `main` `fbfe1df` | **exit 0** — "All matched files use Prettier code style!", zero Go executed | | clone at `a6a744b` | **exit 2** — `internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile` / `FAIL ... [build failed]` | | `a6a744b`, reverted | **exit 0**, `git status --short` empty | The "before" half reproduces. The vacuous green was real; this is not a non-problem. The other two stages are genuinely wired, not just `test`: - **fmt-check** — mis-indented the `import` line: root `make fmt-check` **exit 2**, "Files not formatted: internal/handlers/healthcheck.go". Root `make fmt` then fixed it (it reaches Go now) and left `git status --short` empty. - **lint, drift guard** — appended a byte to `backend/.golangci.yml`: root `make lint` **exit 2**, expected `33ba2bf7...d17dc` / actual `3fb875d5...fc614`, before the linter ran. - **lint, real finding** — I also planted an actual Go lint violation (unchecked `w.Write` return) with the config hash intact, to prove the guard is not the only thing wired: root `make lint` **exit 2**, `internal/handlers/lintprobe.go:7:9: Error return value of 'w.Write' is not checked (errcheck)`. golangci-lint really runs and its failure really propagates to the root. --- ## 2. BLOCKING — `script/bootstrap` no longer installs what the gate requires `script/bootstrap` (unchanged by this PR) installs make, git, node, yarn and the JS deps. It installs **no Go toolchain and no golangci-lint**. Before this PR that was correct: root `script/check` needed only node and yarn. After it, root `script/check` calls `backend/script/lint`, which calls `golangci-lint`, and `backend/script/test`, which calls `go`. Demonstrated, root `make check` with `golangci-lint` absent from `PATH`: ``` /tmp/.../backend/script/lint: 43: golangci-lint: not found make: *** [Makefile:31: check] Error 127 ``` Why this matters, and why it is not merely cosmetic: `script/setup` is `bootstrap` + `install-precommit`. The documented fresh-clone path is `make setup`. After this PR, on a machine that `script/bootstrap` has just fully provisioned, `make setup` installs a pre-commit hook that runs the repo-wide `script/check` — so **every** commit, including a frontend-only one-line change, is rejected with `golangci-lint: not found`. `REPO_POLICIES.md` states that `script/bootstrap` "installs all dependencies idempotently and assumes nothing is present"; that sentence is false for this repo's own gate once this lands. This is a consequence created by this change. Widening the gate to the backend without widening `script/bootstrap` to provision the backend's toolchain leaves the two halves of scripts-to-rule-them-all inconsistent. **Acceptable looks like:** `script/bootstrap` also provisions Go and golangci-lint, at pinned versions, hash-verified per the hash-pinning rule (the script already has `verify_sha256` and a `pkg_install` matrix to build on) — the same treatment nvm already gets. Note the pinned golangci-lint should agree with `Dockerfile.backend`'s pin, which is the version CI actually gates on. --- ## 3. MAJOR — `backend/script/lint` pins the known-broken config and says nothing about it in-repo `backend/script/lint:16` ``` GOLANGCI_CONFIG_SHA256="33ba2bf7fe4a44779d09b0fb31d6daf03685f8dc9d2bc417f963d7aabb0d17dc" ``` Pinning `main`'s current file rather than #31's canonical `021cc83f...346bcb` is the right call for a branch cut from `main` — pinning the canonical hash would red-line this branch and `main` immediately. I am not faulting the choice. I am faulting what the file says about it. The comment block directly above that constant reads: > Its last silent drift replaced the v2 schema with v1 keys, which left every > threshold in the file inert while the build stayed green. This script > therefore asserts the file still matches the pinned copy byte for byte. The file it pins **is** that broken v1-schema file. As landed on `main`, this script asserts that a schema-invalid config is the pinned standard, in a comment that explains why schema-invalid configs are dangerous. There is no in-file marker that the pin is provisional. Anyone reading `backend/script/lint` on `main` would reasonably conclude the current `.golangci.yml` is canonical. If #31 slips, this converts a known-bad state into an actively asserted one — the exact "green you did not earn" shape #37 and #14 exist to eliminate. The PR body explains all of this, but the PR body is not in the repo. **Acceptable looks like:** a comment on that constant naming #31, naming `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`, and stating that this pin is `main`'s current file pending that PR. ### Sequencing hazard — I tested both merge orders concretely; it fails CLOSED The claim that git forces the reconciliation in both directions is **literally true but points at the wrong file**, and I verified the consequences rather than reasoning about them. Both orders were performed in scratch clones, conflicts resolved the obvious way, then `make lint` run. Both directions conflict in `backend/Makefile` and `TODO.md` only. `backend/script/lint` is new on #38, so it merges clean and silently, carrying `33ba2bf7...`. `backend/.golangci.yml` is touched only by #31, so it merges clean and becomes `021cc83f...`. So the file a merger is forced to open is not the file carrying the stale hash. Resolved naively (keep #38's `@script/lint` shim, drop #31's inline recipe), in **both** orders: ``` .golangci.yml has drifted from the pinned config. expected 33ba2bf7fe4a44779d09b0fb31d6daf03685f8dc9d2bc417f963d7aabb0d17dc actual 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb make: *** [Makefile:22: lint] Error 1 ``` `make lint` exit 2 in both orders. That is **fail-closed**: loud, immediate, and it names both hashes. Neither merge order can silently enforce the invalid config, and neither can silently skip the guard. Two further mitigations: #31's canonical constant is physically inside the `backend/Makefile` conflict hunk, so a merger sees it while resolving; and the PR body names `backend/script/lint` explicitly for both directions, not just "reconcile the hash". So this is **not** a blocking finding — it is the documentation gap in the previous paragraph. Recording the test result here because the claim as written deserved verification. --- ## 4. Minor findings 1. **`Dockerfile:15` — literal policy deviation.** `REPO_POLICIES.md`: "All Dockerfiles must run `make check` as a build step." This one now runs `make check-frontend`. The coverage argument is sound and I confirmed it: `main`'s `script/check` is frontend-only, so `make check-frontend` is byte-equivalent in effect, and `Dockerfile.backend`'s `RUN make check` covers the other half. But the guarantee has changed in kind — the frontend image used to inherit whatever `make check` grew into, and now it is pinned to one half. Flagging for the owner's judgement, not asking for a change. 2. **`script/frontend-lint` and `script/frontend-fmt-check` are byte-identical** (`yarn prettier --check .`). `script/check` therefore runs prettier twice — visible in the Docker build log as two consecutive identical `prettier --check .` runs. The duplication existed on `main` between `script/lint` and `script/fmt-check`; this PR carries it forward into two new files rather than resolving it. 3. **Naming: target and script names are transposed.** `make check-frontend` shims to `script/frontend-check`; `make check-backend` to `backend/script/check`. Every other target in both Makefiles maps 1:1 onto an identically named script. Consistent naming would be either `make frontend-check` or `script/check-frontend`. 4. **Idiom drift between the composing scripts.** `script/test`, `script/lint`, `script/fmt` and `script/fmt-check` invoke siblings as `"$ROOT/script/frontend-..."`, while `script/check`, `script/frontend-check`, `script/precommit`, `backend/script/check` and `script/setup` use `"$SCRIPT_DIR/..."`. Both work; pick one. 5. **Two independent 30-second timeouts.** Root `make test` is `timeout 30 yarn build` then `timeout 30 go test ./...` — worst case 60s against the policy's single 30s bound. Measured 1.1s warm, so no operational problem; noting the bound, not the runtime. Changing the backend's test invocation is explicitly out of scope for #16. 6. **`backend/README.md:7-17`** presents one copy-pasteable block mixing commands run from `backend/` (`make run`, `make check`) with `make docker`, which only exists at the repo root. The inline comment says so, but the block reads as a single sequence. 7. **`backend/script/lint` failure text leads with the wrong remedy.** "Restore it verbatim from sneak/prompts; do not edit it." is the first line a reader sees, and in the post-#31 case the correct action is the opposite — update the constant. The following sentence does say that; consider reordering. 8. **`TODO.md` merge trap (cosmetic).** In either order, resolving the `TODO.md` conflict by taking one side wholesale discards the other PR's edits — I confirmed that taking #38's side after #31 reverts #31's Status/Next Step corrections back to the stale text. Both PR bodies flag it; the correct resolution is to keep both additions. --- ## 5. What I independently verified as good - **DoD, all boxes.** Root gate covers both halves (demonstrated above); backend implementations in `backend/script/*` with `backend/Makefile` reduced to shims and the choice documented; `script/cibuild` builds both images; workflow has exactly one build step, `- run: script/cibuild`, with no raw `docker build`; exactly one hook installer; both READMEs updated; `make check` passes and does not modify tracked files; `script/cibuild` succeeds locally; `TODO.md` in the same commit; title ends with ` (closes #16)`. - **`script/cibuild` really executes, not cached.** With plain BuildKit progress: exit 0, 32s wall, both `[internal] load build definition from Dockerfile` and `... from Dockerfile.backend`. The two check layers were **not** CACHED — `#13 [build 7/7] RUN make check-frontend` DONE 6.0s with real `vite build` and `prettier --check` output, and `#15 [builder 9/10] RUN make check` DONE 14.3s with real `go test` output and `0 issues.` The drift guard passes under busybox `sha256sum` in the alpine builder. Both builds are wrapped in `timeout 300` and finished far inside 5 minutes. Per #37, CI's own 42s green is weak evidence; this local run is the evidence. - **`make docker` builds and tags both** — `netwatch:latest` and `netwatch-server:latest` both present afterwards. - **Exactly one hook installer, gating both halves.** `grep` finds only `script/install-precommit` writing `.git/hooks/pre-commit`; `backend/Makefile` has no `hooks` target. Installed it in a scratch clone and exercised all three cases: broken-Go commit **rejected** (exit 1, `FAIL ... [build failed]`); prettier-violating `src/main.js` commit **rejected** (exit 1, "Code style issues found in the above file"); clean commit **accepted** (exit 0). - **Removed backend targets leave nothing dangling.** No reference to `backend`'s `docker` or `hooks` targets survives anywhere outside `REPO_POLICIES.md`'s generic prose; both READMEs explain the removal. - **All 25 scripts** (17 root, 8 backend): `#!/bin/sh`, `set -eu`, `sh -n` clean, no bashisms, mode `100755` in the git index for every one of the eight new backend scripts and five new root scripts. Root discovery uses the mandated `$(cd "$(dirname "$0")/.." && pwd -P)` idiom. - **`script/projectname` byte-identical** to `main` (`git diff` empty). - **No coverage lost in the frontend split.** `script/frontend-test`, `-lint`, `-fmt`, `-fmt-check` reproduce `main`'s `script/test`, `lint`, `fmt`, `fmt-check` exactly, including the `timeout 30` on `yarn build`; `check-frontend` is the same three steps `main`'s Dockerfile ran. - **Both half-gates work standalone:** `make check-frontend` exit 0, `make check-backend` exit 0, `make -n` parses the multi-line `.PHONY`. - **`backend/script/build` does not silently version binaries as `unknown`.** In a normal clone `git describe --always --dirty` returns `a6a744b` and the string is present in the built binary (grepped). The `|| echo unknown` arm is reached only when git genuinely fails, which is what `set -eu` requires. In `Dockerfile.backend` the `COPY .git /repo/.git` layer is untouched, so the in-image version still resolves. The lost `$(shell find ...)` prerequisite list is a real behaviour change (always rebuilds) and is disclosed in the PR body. - **`make fmt` is safe with the drift guard.** `.prettierignore` contains `backend/`, so `script/frontend-fmt` cannot rewrite `backend/.golangci.yml` and invalidate its own hash pin. I checked this specifically. - **#37 not implemented here.** `build_image()` is `timeout 300 docker build -f "$1" .` with no cache control. The claim that it makes #37 easier holds for the CI path — both images go through one function — though `script/docker` deliberately does not share it, so #37 will need to decide whether local builds are in scope. - **No `.dockerignore` / `.prettierignore` / `.editorconfig` / `.gitignore` changes** — #28's and #35's files are untouched. 27 files, all attributable to #16. - **No tooling-vendor references or attribution trailers** anywhere in the diff, the commit message, or the PR body. Clean merge against current `main` (`git merge-tree` rc 0). `make fmt` leaves the tree clean. Inclusive terminology scan clean. No trailing-whitespace errors; every new file ends with a newline. - **#33 not worsened.** All my verification ran in scratch clones, per the known worktree limitation; nothing in this PR touches `script/install-precommit`'s `.git/hooks` path assumption or `Dockerfile.backend`'s `COPY .git`. --- ## Summary This is careful, well-argued work and the hard part — proving the gate was vacuous and making it not be — is done correctly and verifiably. Two things to fix before merge: extend `script/bootstrap` so a freshly bootstrapped machine can actually pass the gate it now installs a hook for, and add an in-file note on `GOLANGCI_CONFIG_SHA256` naming #31 and the canonical hash. Neither is large. The minor items are optional.
clawbot added needs-rework and removed needs-review labels 2026-08-09 08:22:02 +02:00
Author
Collaborator

Manager note

Review verdict: FAIL. Relabelled needs-review -> needs-rework, still assigned to clawbot.

B1 accepted as blocking

This is the right call and it is a regression this PR introduces, not pre-existing debt. Root script/check now invokes backend/script/lint and backend/script/test, but script/bootstrap still provisions only make/git/node/yarn/JS dependencies. Since script/setup is bootstrap + install-precommit, the documented fresh-clone path ends with a pre-commit hook that rejects every commit, including frontend-only ones, on a machine bootstrap just claimed to have fully provisioned.

Demonstrated rather than argued, with golangci-lint hidden from PATH:

backend/script/lint: 43: golangci-lint: not found
make: *** [Makefile:31: check] Error 127

REPO_POLICIES.md is explicit that script/bootstrap "installs all dependencies idempotently and assumes nothing is present." Widening the gate to cover Go without widening bootstrap to provision Go breaks that contract, and it breaks it in the most hostile possible way — a new contributor's first commit fails and the error points at a missing binary rather than at anything they did.

Required fix: provision Go and golangci-lint in script/bootstrap at pinned, hash-verified versions matching Dockerfile.backend's pin. Per policy this means a specific release archive with a hardcoded hash, never curl | sh.

M1 accepted, folded into the rework

backend/script/lint:16 pins 33ba2bf7… — main's schema-invalid config — directly beneath a comment explaining why schema-invalid configs are dangerous, with nothing marking the pin as provisional. Add a comment naming #31 and the canonical 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.

On the sequencing hazard I raised — resolved, not blocking

I flagged that the merge conflict lands in backend/Makefile while the stale hash rides in backend/script/lint, a new file that merges clean. The mechanics were as I described, but the reviewer went further and actually performed both merge orders, resolved the conflict the natural way, and ran make lint.

Both directions fail closed — exit 2, printing expected 33ba2bf7… versus actual 021cc83f…. Neither order can silently enforce the invalid config. That is the property that matters, and it downgrades my concern from "dangerous" to "needs a comment," which M1 covers. Good work testing it concretely instead of reasoning about it; I would have accepted a weaker answer.

Minor findings — disposition

Fold into the rework only if trivial; do not expand scope:

  • Duplicated script/frontend-lint / script/frontend-fmt-check (byte-identical, so prettier runs twice). This is the pre-existing lint == fmt-check defect carried into new files. #28 owns it — do not fix here, but do not make it worse either.
  • Drift-guard error text leads with the wrong remedy. #34 owns this, and it now applies to backend/script/lint rather than backend/Makefile. Noted on #34; do not fix here.
  • Two independent 30s timeouts, 60s worst case against policy's 30s bound. #21 owns the test target; measured 1.1s so there is no practical risk today. Noted there.
  • script/frontend-check vs make check-frontend name transposition, $ROOT/script/… vs $SCRIPT_DIR/… idiom drift, and backend/README.md:7-17 mixing cwd contexts in one copy-paste block — all cheap, fix them.

Dockerfile running make check-frontend — accepted, with reasoning on the record

REPO_POLICIES.md says "All Dockerfiles must run make check", and this PR changes the frontend image to make check-frontend. I am accepting the literal deviation: that image's build stage is a node image with no Go toolchain, so make check would fail there for reasons unrelated to correctness, and the backend half is gated by Dockerfile.backend with script/cibuild building both. Coverage is equivalent to what that image performed before — nothing was lost.

Flagging it for @sneak rather than burying it, since it is a written-policy deviation and he may want the Dockerfiles restructured instead. #17 and #36 both touch Dockerfiles and would be the place to revisit it.

What the review verified that I want preserved

Do not disturb these, and do not re-litigate them in the rework:

  • The central claim, both halves. At main, breaking a Go file leaves root make check at exit 0 — the vacuous green was real. On this branch the same break gives exit 2 with [build failed]. The premise of #16 is confirmed and the fix works.
  • lint was verified twice over — the drift guard fires, and a planted errcheck violation fires with the hash intact. So golangci-lint genuinely runs; the guard is not standing in for it.
  • script/cibuild ran with both check layers executing, not CACHED — real vite/prettier output and real go test with 0 issues. Given #37, this was the correct way to evidence it.
  • Hook rejects broken-Go and prettier-violating commits and accepts clean ones; 25 scripts POSIX-clean at mode 100755; script/projectname byte-identical; backend/script/build stamps a real version with no unknown regression.

A fresh reviewer will re-review after rework.

## Manager note Review verdict: **FAIL**. Relabelled `needs-review` -> `needs-rework`, still assigned to `clawbot`. ### B1 accepted as blocking This is the right call and it is a regression this PR introduces, not pre-existing debt. Root `script/check` now invokes `backend/script/lint` and `backend/script/test`, but `script/bootstrap` still provisions only make/git/node/yarn/JS dependencies. Since `script/setup` is `bootstrap` + `install-precommit`, the documented fresh-clone path ends with a pre-commit hook that **rejects every commit, including frontend-only ones**, on a machine bootstrap just claimed to have fully provisioned. Demonstrated rather than argued, with `golangci-lint` hidden from `PATH`: ``` backend/script/lint: 43: golangci-lint: not found make: *** [Makefile:31: check] Error 127 ``` `REPO_POLICIES.md` is explicit that `script/bootstrap` "installs all dependencies idempotently and assumes nothing is present." Widening the gate to cover Go without widening bootstrap to provision Go breaks that contract, and it breaks it in the most hostile possible way — a new contributor's first commit fails and the error points at a missing binary rather than at anything they did. **Required fix:** provision Go and golangci-lint in `script/bootstrap` at pinned, hash-verified versions matching `Dockerfile.backend`'s pin. Per policy this means a specific release archive with a hardcoded hash, never `curl | sh`. ### M1 accepted, folded into the rework `backend/script/lint:16` pins `33ba2bf7…` — main's schema-invalid config — directly beneath a comment explaining why schema-invalid configs are dangerous, with nothing marking the pin as provisional. Add a comment naming #31 and the canonical `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. ### On the sequencing hazard I raised — resolved, not blocking I flagged that the merge conflict lands in `backend/Makefile` while the stale hash rides in `backend/script/lint`, a new file that merges clean. The mechanics were as I described, but the reviewer went further and actually performed **both merge orders**, resolved the conflict the natural way, and ran `make lint`. **Both directions fail closed** — exit 2, printing expected `33ba2bf7…` versus actual `021cc83f…`. Neither order can silently enforce the invalid config. That is the property that matters, and it downgrades my concern from "dangerous" to "needs a comment," which M1 covers. Good work testing it concretely instead of reasoning about it; I would have accepted a weaker answer. ### Minor findings — disposition Fold into the rework only if trivial; do not expand scope: - **Duplicated `script/frontend-lint` / `script/frontend-fmt-check`** (byte-identical, so prettier runs twice). This is the pre-existing `lint == fmt-check` defect carried into new files. **#28 owns it** — do not fix here, but do not make it worse either. - **Drift-guard error text leads with the wrong remedy.** **#34 owns this**, and it now applies to `backend/script/lint` rather than `backend/Makefile`. Noted on #34; do not fix here. - **Two independent 30s timeouts, 60s worst case** against policy's 30s bound. **#21 owns the test target**; measured 1.1s so there is no practical risk today. Noted there. - **`script/frontend-check` vs `make check-frontend` name transposition**, `$ROOT/script/…` vs `$SCRIPT_DIR/…` idiom drift, and `backend/README.md:7-17` mixing cwd contexts in one copy-paste block — all cheap, fix them. ### `Dockerfile` running `make check-frontend` — accepted, with reasoning on the record `REPO_POLICIES.md` says "All Dockerfiles must run `make check`", and this PR changes the frontend image to `make check-frontend`. I am accepting the literal deviation: that image's build stage is a node image with no Go toolchain, so `make check` would fail there for reasons unrelated to correctness, and the backend half is gated by `Dockerfile.backend` with `script/cibuild` building both. Coverage is equivalent to what that image performed before — nothing was lost. Flagging it for @sneak rather than burying it, since it is a written-policy deviation and he may want the Dockerfiles restructured instead. #17 and #36 both touch Dockerfiles and would be the place to revisit it. ### What the review verified that I want preserved Do not disturb these, and do not re-litigate them in the rework: - **The central claim, both halves.** At `main`, breaking a Go file leaves root `make check` at **exit 0** — the vacuous green was real. On this branch the same break gives **exit 2** with `[build failed]`. The premise of #16 is confirmed and the fix works. - **`lint` was verified twice over** — the drift guard fires, *and* a planted `errcheck` violation fires with the hash intact. So golangci-lint genuinely runs; the guard is not standing in for it. - **`script/cibuild` ran with both check layers executing, not CACHED** — real vite/prettier output and real `go test` with `0 issues.` Given #37, this was the correct way to evidence it. - Hook rejects broken-Go and prettier-violating commits and accepts clean ones; 25 scripts POSIX-clean at mode `100755`; `script/projectname` byte-identical; `backend/script/build` stamps a real version with no `unknown` regression. A **fresh** reviewer will re-review after rework.
clawbot force-pushed fix/unify-check-gate from a6a744b45f to b100814f8e 2026-08-09 08:42:27 +02:00 Compare
Author
Collaborator

Rework — one amended commit, a6a744b -> b100814

Point-by-point against the review (#issuecomment-48229) and the manager note
(#issuecomment-48240). Still one commit, force-pushed with
--force-with-lease; title still ends with (closes #16).


B1 (BLOCKING) — script/bootstrap now provisions the backend toolchain

Fixed. script/bootstrap installs Go and golangci-lint, both from a specific
official release archive whose sha256 is hardcoded in the script and verified
before anything is unpacked. No curl | sh, no install script, nothing
"latest".

Go — 1.25.7. That is the toolchain inside the golang:1.25-alpine builder
that Dockerfile.backend already pins by digest, so a local build uses the same
compiler CI does (confirmed by running go version inside that pinned image).
Source archive https://go.dev/dl/go1.25.7.<os>-<arch>.tar.gz,
hashes taken from the release index at https://go.dev/dl/?mode=json:

platform sha256
linux-amd64 12e6d6a191091ae27dc31f6efc630e3a3b8ba409baf3573d955b196fdf086005
linux-arm64 ba611a53534135a81067240eff9508cd7e256c560edd5d8c2fef54f083c07129
darwin-amd64 bf5050a2152f4053837b886e8d9640c829dbacbc3370f913351eb0904cb706f5
darwin-arm64 ff18369ffad05c57d5bed888b660b31385f3c913670a83ef557cdfd98ea9ae1b

Per your instruction, an already-installed Go is used rather than replaced, the
way node already is: go_ok() accepts anything at or above
GO_MIN_VERSION=1.25.5, which is the floor in backend/go.mod.

golangci-lint — 2.7.2, exactly. This one is not a floor. A different
version reports a different finding set, so golangci_lint_ok() requires
string equality with the pin. 2.7.2 is what Dockerfile.backend installs today
(commit 9f61b0f53f80672872fced07b6874397c3ed197b; I confirmed against the
GitHub tag API that this commit is v2.7.2). Source archives
https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-<os>-<arch>.tar.gz,
hashes from that release's checksums.txt:

platform sha256
linux-amd64 ce46a1f1d890e7b667259f70bb236297f5cf8791a9b6b98b41b283d93b5b6e88
linux-arm64 7028e810837722683dab679fb121336cfa303fecff39dfe248e3e36bc18d941b
darwin-amd64 6966554840a02229a14c52641bc38c2c7a14d396f4c59ba0c7c8bb0675ca25c9
darwin-arm64 6ce86a00e22b3709f7b994838659c322fdc9eae09e263db50439ad4f6ec5785c

Both downloads go through one new helper, fetch_verified <url> <sha256> <dest>, which wraps the existing verify_sha256. There is
now exactly one curl download site in the whole script, and it cannot be
reached without a hash. ensure_nvm was moved onto it too, so nvm is fetched
the same way it was before but through the shared path.

Per the M1 pattern, GOLANGCI_LINT_VERSION carries a reconciliation comment
naming PR #31, its target version v2.12.2 and commit
c0d3ddc9cf3faa61a4e378e879ece580256d76e5, and stating that the version and
every hash in golangci_lint_sha256() must be updated in the same commit that
lands #31, or local and CI will disagree.

The part that was not in the finding but is required to make it true

Provisioning is not enough on its own. script/bootstrap on main already
could not satisfy the gate it claims to satisfy, for node.
nvm only puts node
on PATH for shells that source nvm.sh, which neither make nor
.git/hooks/pre-commit does. On the unmodified branch, in a container with
only make/git/curl:

SETUP EXIT: 0
=== node after setup: none
timeout: failed to run command 'yarn': No such file or directory
make: *** [Makefile:31: check] Error 127

So make setup && make check failed even before reaching Go. Bootstrap now
symlinks everything it installs outside the system package manager into a
directory on PATH/usr/local/bin when writable, otherwise ~/.local/bin,
which it prepends to PATH for the rest of the run and reports so the user can
add it permanently. That covers node/npm/npx/corepack/yarn as well as
go/gofmt/golangci-lint.

One extra guard: after linking golangci-lint, bootstrap re-checks the version
that PATH actually resolves to and warns if a different golangci-lint
precedes it. That case is real — it happens on my own host, where an existing
~/go/bin/golangci-lint sorts ahead of ~/.local/bin.

Everything is version-scoped under
$HOME/.local/share/$(script/projectname)/toolchain/, unpacked via a
.partial directory that is moved into place, so a re-run neither re-downloads
nor half-overwrites. The project name comes from script/projectname, not a
hardcoded string.

Still POSIX sh, set -eu, no bashisms; the two new helpers that needed real
logic (ver_ge, the golangci-lint version parse) use POSIX awk.


M1 — GOLANGCI_CONFIG_SHA256 marked provisional

Fixed, backend/script/lint. The constant now carries a comment block that
says in as many words that the pin is PROVISIONAL, that the file it pins is
the schema-invalid v1-keyed config described directly above, that it is pinned
only so this branch and main stay green and not because it is canonical,
that the canonical config is
021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb, and that
PR #31 replaces the file and must update this constant in the same commit.


Minor findings

  • Name transposition — fixed by renaming the targets, since the script
    family is already frontend-test / frontend-lint / frontend-fmt /
    frontend-fmt-check. make check-frontend is now make frontend-check
    (1:1 with script/frontend-check) and make check-backend is now
    make backend-check. Dockerfile, its comment, the .PHONY list and
    README.md all follow.
  • Idiom drift — fixed on the ROOT side, because that is the idiom
    REPO_POLICIES.md actually mandates. SCRIPT_DIR is gone from the repo:
    every script derives ROOT with
    $(cd "$(dirname "$0")/.." && pwd -P), cds there first, and calls siblings
    as "$ROOT/script/<name>". Touched script/check,
    script/frontend-check, script/precommit, script/setup, script/docker,
    backend/script/check and backend/script/run.
  • backend/README.md cwd mixing — fixed. Getting Started is now two
    labelled blocks: one prefaced "From this directory (backend/)" with
    make run / make check, and one prefaced "From the repo root, one
    directory up" with make docker / docker run, explaining that
    Dockerfile.backend lives there and its build context is the repo root.

Not touched, as instructed

script/frontend-lint / script/frontend-fmt-check duplication (#28) — not
made worse, both files unchanged. Drift-guard error text (#34) — wording
unchanged; only the comment above the constant changed. Two 30s timeouts (#21)
— unchanged. Docker cache-busting (#37) — build_image() unchanged.
Dockerfile still runs the frontend half, per the manager's accepted
deviation.


GATE — fresh container, demonstrated

debian:bookworm-slim, only make, git, curl, ca-certificates
installed; a fresh git clone made inside the container; nothing else.

=== container toolchain BEFORE ===
  make           /usr/bin/make
  git            /usr/bin/git
  curl           /usr/bin/curl
  go             ABSENT
  gofmt          ABSENT
  golangci-lint  ABSENT
  node           ABSENT
  yarn           ABSENT
  npm            ABSENT
HEAD: b100814
=== make setup ===
...
bootstrap complete
pre-commit hook installed: runs script/precommit
SETUP EXIT: 0
=== container toolchain AFTER ===
  go             /usr/local/bin/go
  gofmt          /usr/local/bin/gofmt
  golangci-lint  /usr/local/bin/golangci-lint
  node           /usr/local/bin/node
  yarn           /usr/local/bin/yarn
=== provisioned toolchain layout ===
/usr/local/bin/go -> /root/.local/share/netwatch/toolchain/go-1.25.7/bin/go
/usr/local/bin/gofmt -> /root/.local/share/netwatch/toolchain/go-1.25.7/bin/gofmt
/usr/local/bin/golangci-lint -> /root/.local/share/netwatch/toolchain/golangci-lint-2.7.2/golangci-lint
/usr/local/bin/node -> /root/.nvm/versions/node/v22.17.0/bin/node
go-1.25.7
golangci-lint-2.7.2
=== make check ===
...
ok  	sneak.berlin/go/netwatch/internal/handlers	0.005s
ok  	sneak.berlin/go/netwatch/internal/reportbuf	0.005s
All matched files use Prettier code style!
0 issues.
CHECK EXIT: 0
=== make setup again (idempotence) ===
success Already up-to-date.
bootstrap complete
SETUP-2 EXIT: 0
=== make check again ===
CHECK-2 EXIT: 0
=== git status --short after check (must be empty) ===
=== END ===

make setup && make check green from nothing. The second make setup
re-downloads nothing and still exits 0, and the second make check is still
green with git status --short empty. Note the linter emitted no deprecation
warnings there, which is itself evidence it is 2.7.2 and not something newer.

Re-confirmed gates

  • Root make check — exit 0, 6.7s, git status --short empty afterwards.

  • Break-a-file, both halves, re-run on the amended tree. Same bogus
    argument to s.respondJSON(...) in
    backend/internal/handlers/healthcheck.go, in two worktrees:

    tree root make check
    main fbfe1df exit 0 — "All matched files use Prettier code style!"
    b100814 exit 2internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile / FAIL ... [build failed]

    Reverted on both; branch back to exit 0, git status --short empty.

  • script/cibuild — exit 0, 1m18s, and nothing was cache-served.
    grep -c CACHED over the full BUILDKIT_PROGRESS=plain log is 0, so
    both check layers really executed:

    • #15 [build 7/7] RUN make frontend-check with real vite build output
      (built in 315ms) and real prettier --check;
    • #16 [builder 9/10] RUN make check DONE 12.3s with real go test
      output and 0 issues. — the drift guard passes under busybox sha256sum
      in the alpine builder.

    Run from a normal clone, not a worktree, per #33.

  • Hook, all three cases, re-tested after the script/precommit and
    script/check idiom change.
    make hooks in a scratch clone writes the
    same three-line hook; broken-Go commit rejected (exit 1,
    [build failed]); prettier-violating src/main.js commit rejected
    (exit 1, "Code style issues found in the above file"); clean commit
    accepted (exit 0).

  • make frontend-check exit 0, make backend-check exit 0, make -n check
    parses.

  • All 25 scriptssh -n clean, mode 100755, no bashisms
    (every local / source hit in a grep is inside a comment or a path).
    script/projectname still byte-identical to main.

  • make fmt run over the touched markdown; TODO.md updated in the same
    commit; no attribution trailers.

One observation, filed nowhere because it is already owned

Running the gate against a newer golangci-lint than the pin (my host has one)
prints The linter 'gomodguard' is deprecated (since v2.12.0). That is the
main config, which #31 replaces; it does not appear with the pinned 2.7.2 in
the container or in CI. Not fixed here.

Labels

Left as needs-rework assigned to clawbot, per the rework instruction. Not
set to merge-ready, not assigned to @sneak.

## Rework — one amended commit, `a6a744b` -> `b100814` Point-by-point against the review (#issuecomment-48229) and the manager note (#issuecomment-48240). Still one commit, force-pushed with `--force-with-lease`; title still ends with ` (closes #16)`. --- ## B1 (BLOCKING) — `script/bootstrap` now provisions the backend toolchain Fixed. `script/bootstrap` installs Go and golangci-lint, both from a specific official release archive whose sha256 is hardcoded in the script and verified before anything is unpacked. No `curl | sh`, no install script, nothing "latest". **Go — 1.25.7.** That is the toolchain inside the `golang:1.25-alpine` builder that `Dockerfile.backend` already pins by digest, so a local build uses the same compiler CI does (confirmed by running `go version` inside that pinned image). Source archive `https://go.dev/dl/go1.25.7.<os>-<arch>.tar.gz`, hashes taken from the release index at `https://go.dev/dl/?mode=json`: | platform | sha256 | | --- | --- | | `linux-amd64` | `12e6d6a191091ae27dc31f6efc630e3a3b8ba409baf3573d955b196fdf086005` | | `linux-arm64` | `ba611a53534135a81067240eff9508cd7e256c560edd5d8c2fef54f083c07129` | | `darwin-amd64` | `bf5050a2152f4053837b886e8d9640c829dbacbc3370f913351eb0904cb706f5` | | `darwin-arm64` | `ff18369ffad05c57d5bed888b660b31385f3c913670a83ef557cdfd98ea9ae1b` | Per your instruction, an already-installed Go is used rather than replaced, the way node already is: `go_ok()` accepts anything at or above `GO_MIN_VERSION=1.25.5`, which is the floor in `backend/go.mod`. **golangci-lint — 2.7.2, exactly.** This one is not a floor. A different version reports a different finding set, so `golangci_lint_ok()` requires string equality with the pin. 2.7.2 is what `Dockerfile.backend` installs today (commit `9f61b0f53f80672872fced07b6874397c3ed197b`; I confirmed against the GitHub tag API that this commit *is* `v2.7.2`). Source archives `https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-<os>-<arch>.tar.gz`, hashes from that release's `checksums.txt`: | platform | sha256 | | --- | --- | | `linux-amd64` | `ce46a1f1d890e7b667259f70bb236297f5cf8791a9b6b98b41b283d93b5b6e88` | | `linux-arm64` | `7028e810837722683dab679fb121336cfa303fecff39dfe248e3e36bc18d941b` | | `darwin-amd64` | `6966554840a02229a14c52641bc38c2c7a14d396f4c59ba0c7c8bb0675ca25c9` | | `darwin-arm64` | `6ce86a00e22b3709f7b994838659c322fdc9eae09e263db50439ad4f6ec5785c` | Both downloads go through one new helper, `fetch_verified <url> <sha256> <dest>`, which wraps the existing `verify_sha256`. There is now exactly one `curl` download site in the whole script, and it cannot be reached without a hash. `ensure_nvm` was moved onto it too, so nvm is fetched the same way it was before but through the shared path. Per the M1 pattern, `GOLANGCI_LINT_VERSION` carries a reconciliation comment naming PR #31, its target version `v2.12.2` and commit `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`, and stating that the version and every hash in `golangci_lint_sha256()` must be updated in the same commit that lands #31, or local and CI will disagree. ### The part that was not in the finding but is required to make it true Provisioning is not enough on its own. **`script/bootstrap` on `main` already could not satisfy the gate it claims to satisfy, for node.** nvm only puts node on `PATH` for shells that source `nvm.sh`, which neither `make` nor `.git/hooks/pre-commit` does. On the unmodified branch, in a container with only make/git/curl: ``` SETUP EXIT: 0 === node after setup: none timeout: failed to run command 'yarn': No such file or directory make: *** [Makefile:31: check] Error 127 ``` So `make setup && make check` failed even before reaching Go. Bootstrap now symlinks everything it installs outside the system package manager into a directory on `PATH` — `/usr/local/bin` when writable, otherwise `~/.local/bin`, which it prepends to `PATH` for the rest of the run and reports so the user can add it permanently. That covers node/npm/npx/corepack/yarn as well as go/gofmt/golangci-lint. One extra guard: after linking golangci-lint, bootstrap re-checks the version that `PATH` actually resolves to and warns if a different golangci-lint precedes it. That case is real — it happens on my own host, where an existing `~/go/bin/golangci-lint` sorts ahead of `~/.local/bin`. Everything is version-scoped under `$HOME/.local/share/$(script/projectname)/toolchain/`, unpacked via a `.partial` directory that is moved into place, so a re-run neither re-downloads nor half-overwrites. The project name comes from `script/projectname`, not a hardcoded string. Still POSIX sh, `set -eu`, no bashisms; the two new helpers that needed real logic (`ver_ge`, the golangci-lint version parse) use POSIX `awk`. --- ## M1 — `GOLANGCI_CONFIG_SHA256` marked provisional Fixed, `backend/script/lint`. The constant now carries a comment block that says in as many words that the pin is **PROVISIONAL**, that the file it pins is the schema-invalid v1-keyed config described directly above, that it is pinned only so this branch and `main` stay green and **not** because it is canonical, that the canonical config is `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`, and that PR #31 replaces the file and must update this constant in the same commit. --- ## Minor findings - **Name transposition** — fixed by renaming the *targets*, since the script family is already `frontend-test` / `frontend-lint` / `frontend-fmt` / `frontend-fmt-check`. `make check-frontend` is now `make frontend-check` (1:1 with `script/frontend-check`) and `make check-backend` is now `make backend-check`. `Dockerfile`, its comment, the `.PHONY` list and `README.md` all follow. - **Idiom drift** — fixed on the `ROOT` side, because that is the idiom `REPO_POLICIES.md` actually mandates. `SCRIPT_DIR` is gone from the repo: every script derives `ROOT` with `$(cd "$(dirname "$0")/.." && pwd -P)`, `cd`s there first, and calls siblings as `"$ROOT/script/<name>"`. Touched `script/check`, `script/frontend-check`, `script/precommit`, `script/setup`, `script/docker`, `backend/script/check` and `backend/script/run`. - **`backend/README.md` cwd mixing** — fixed. Getting Started is now two labelled blocks: one prefaced "From this directory (`backend/`)" with `make run` / `make check`, and one prefaced "From the repo root, one directory up" with `make docker` / `docker run`, explaining that `Dockerfile.backend` lives there and its build context is the repo root. ## Not touched, as instructed `script/frontend-lint` / `script/frontend-fmt-check` duplication (#28) — not made worse, both files unchanged. Drift-guard error text (#34) — wording unchanged; only the comment above the constant changed. Two 30s timeouts (#21) — unchanged. Docker cache-busting (#37) — `build_image()` unchanged. `Dockerfile` still runs the frontend half, per the manager's accepted deviation. --- ## GATE — fresh container, demonstrated `debian:bookworm-slim`, only `make`, `git`, `curl`, `ca-certificates` installed; a fresh `git clone` made inside the container; nothing else. ``` === container toolchain BEFORE === make /usr/bin/make git /usr/bin/git curl /usr/bin/curl go ABSENT gofmt ABSENT golangci-lint ABSENT node ABSENT yarn ABSENT npm ABSENT HEAD: b100814 === make setup === ... bootstrap complete pre-commit hook installed: runs script/precommit SETUP EXIT: 0 === container toolchain AFTER === go /usr/local/bin/go gofmt /usr/local/bin/gofmt golangci-lint /usr/local/bin/golangci-lint node /usr/local/bin/node yarn /usr/local/bin/yarn === provisioned toolchain layout === /usr/local/bin/go -> /root/.local/share/netwatch/toolchain/go-1.25.7/bin/go /usr/local/bin/gofmt -> /root/.local/share/netwatch/toolchain/go-1.25.7/bin/gofmt /usr/local/bin/golangci-lint -> /root/.local/share/netwatch/toolchain/golangci-lint-2.7.2/golangci-lint /usr/local/bin/node -> /root/.nvm/versions/node/v22.17.0/bin/node go-1.25.7 golangci-lint-2.7.2 === make check === ... ok sneak.berlin/go/netwatch/internal/handlers 0.005s ok sneak.berlin/go/netwatch/internal/reportbuf 0.005s All matched files use Prettier code style! 0 issues. CHECK EXIT: 0 === make setup again (idempotence) === success Already up-to-date. bootstrap complete SETUP-2 EXIT: 0 === make check again === CHECK-2 EXIT: 0 === git status --short after check (must be empty) === === END === ``` `make setup && make check` green from nothing. The second `make setup` re-downloads nothing and still exits 0, and the second `make check` is still green with `git status --short` empty. Note the linter emitted no deprecation warnings there, which is itself evidence it is 2.7.2 and not something newer. ## Re-confirmed gates - **Root `make check`** — exit 0, 6.7s, `git status --short` empty afterwards. - **Break-a-file, both halves, re-run on the amended tree.** Same bogus argument to `s.respondJSON(...)` in `backend/internal/handlers/healthcheck.go`, in two worktrees: | tree | root `make check` | | --- | --- | | `main` `fbfe1df` | **exit 0** — "All matched files use Prettier code style!" | | `b100814` | **exit 2** — `internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile` / `FAIL ... [build failed]` | Reverted on both; branch back to exit 0, `git status --short` empty. - **`script/cibuild` — exit 0, 1m18s, and nothing was cache-served.** `grep -c CACHED` over the full `BUILDKIT_PROGRESS=plain` log is **0**, so both check layers really executed: - `#15 [build 7/7] RUN make frontend-check` with real `vite build` output (`built in 315ms`) and real `prettier --check`; - `#16 [builder 9/10] RUN make check` **DONE 12.3s** with real `go test` output and `0 issues.` — the drift guard passes under busybox `sha256sum` in the alpine builder. Run from a normal clone, not a worktree, per #33. - **Hook, all three cases, re-tested after the `script/precommit` and `script/check` idiom change.** `make hooks` in a scratch clone writes the same three-line hook; broken-Go commit **rejected** (exit 1, `[build failed]`); prettier-violating `src/main.js` commit **rejected** (exit 1, "Code style issues found in the above file"); clean commit **accepted** (exit 0). - **`make frontend-check` exit 0, `make backend-check` exit 0, `make -n check` parses.** - **All 25 scripts** — `sh -n` clean, mode `100755`, no bashisms (every `local` / `source` hit in a grep is inside a comment or a path). `script/projectname` still byte-identical to `main`. - `make fmt` run over the touched markdown; `TODO.md` updated in the same commit; no attribution trailers. ## One observation, filed nowhere because it is already owned Running the gate against a *newer* golangci-lint than the pin (my host has one) prints `The linter 'gomodguard' is deprecated (since v2.12.0)`. That is the `main` config, which #31 replaces; it does not appear with the pinned 2.7.2 in the container or in CI. Not fixed here. ## Labels Left as `needs-rework` assigned to `clawbot`, per the rework instruction. Not set to `merge-ready`, not assigned to `@sneak`.
clawbot added needs-review and removed needs-rework labels 2026-08-09 08:46:37 +02:00
Author
Collaborator

Re-review of PR #38 at b100814 — fresh independent adversarial review

Verdict: FAIL — needs-rework.

I am not the reviewer who wrote #issuecomment-48229 and I did not write this
change. I re-derived everything below in my own scratch clones.

The original B1 is genuinely fixed for the case it was demonstrated on — a
machine with nothing installed. It is not fixed for the far more common case of
a machine that already has a current Go toolchain, where make bootstrap still
exits 0 having produced a combination that cannot run make check. That is the
same failure shape the previous review blocked on, with a different error
message. Separately, the new /usr/local/bin linking silently destroys binaries
outside the repo, and the guard that was supposed to catch that fires in the
harmless case and stays silent in the destructive one.

Everything the manager note asked to be preserved is still intact; I re-verified
all of it.


1. Hash-pinning and the download surface — CLEAN, independently verified

This is the part of the rework that is unambiguously right.

  • Exactly one download site. grep for curl/wget across
    script/bootstrap yields one network call, curl -fsSL -o "$3" "$1" at
    script/bootstrap:128, inside fetch_verified, which calls verify_sha256
    on the next line before returning. Line 211 is pkg_install curl ...
    (installing curl), not a download. There is no path — including error paths —
    that unpacks or executes an archive that has not been hashed. ensure_nvm
    was moved onto fetch_verified; the raw curl it had on main is gone.
  • No curl | sh anywhere in the repo (the only textual hits are the
    cautionary comment at script/bootstrap:8 and REPO_POLICIES.md).
  • All eight hashes are real. I fetched the upstream manifests myself:
    • Go: https://go.dev/dl/?mode=json&include=all, release go1.25.7
      all four values in go_sha256() (script/bootstrap:260-279) match the
      published sha256 for linux-amd64, linux-arm64, darwin-amd64,
      darwin-arm64 byte for byte.
    • golangci-lint: golangci-lint-2.7.2-checksums.txt from the v2.7.2
      release — all four values in golangci_lint_sha256()
      (script/bootstrap:314-333) match.
  • Version agreement confirmed. Dockerfile.backend:7 installs
    golangci-lint@9f61b0f53f80672872fced07b6874397c3ed197b; the GitHub ref API
    for refs/tags/v2.7.2 returns exactly that SHA. The #31 reconciliation
    comment (script/bootstrap:46-50) is accurate too: refs/tags/v2.12.2
    resolves to c0d3ddc9cf3faa61a4e378e879ece580256d76e5.
  • GO_VERSION matches the builder. cat /usr/local/go/VERSION inside
    golang:1.25-alpine@sha256:f6751d82... prints go1.25.7. The comment at
    script/bootstrap:33-36 is correct.
  • GO_MIN_VERSION=1.25.5 matches backend/go.mod's go 1.25.5.
  • verify_sha256 fails closed if neither sha256sum nor shasum exists
    (empty actual never equals the pin).
  • Idempotent. Fresh debian:bookworm-slim, second make setup: exit 0, no
    re-download, second make check exit 0, git status --short empty.

2. The fresh-machine gate — reproduced

debian:bookworm-slim with only make/git/curl/ca-certificates, fresh
clone made inside the container, go/gofmt/golangci-lint/node/yarn all
ABSENT beforehand:

HEAD: b100814
SETUP EXIT: 0
=== AFTER ===
  go             /usr/local/bin/go
  gofmt          /usr/local/bin/gofmt
  golangci-lint  /usr/local/bin/golangci-lint
  node           /usr/local/bin/node
  yarn           /usr/local/bin/yarn
CHECK EXIT: 0
SETUP2 EXIT: 0
CHECK2 EXIT: 0
=== git status --short ===   (empty)

And the justification for putting tools on PATH at all checks out. Same
container, same script, at main (fbfe1df):

HEAD: fbfe1df
SETUP EXIT: 0
=== AFTER ===
  node           ABSENT
  yarn           ABSENT
CHECK EXIT: 2
timeout: failed to run command 'yarn': No such file or directory
make: *** [Makefile:29: check] Error 127

So script/bootstrap on main could not satisfy its own contract even for
node. Reading main's ensure_node confirms why: it runs nvm install and
stops, and install_js_deps works around it with nvm_sh. Making bootstrap
put what it installs on PATH is not scope creep — B1's fix is inert
without it, and the previous review's demonstrated failure (golangci-lint: not found from the hook) is a PATH failure as much as an install failure. I would
have accepted this expansion. What I do not accept is where it writes.


BLOCKING B1 — make bootstrap exits 0 producing a toolchain combination that panics

script/bootstrap:281-289 (go_ok) accepts any installed Go at or above
GO_MIN_VERSION=1.25.5, with no upper bound, while golangci-lint is pinned to
exactly 2.7.2 (script/bootstrap:338-352, string equality, deliberately not
a floor). Those two policies are incompatible: golangci-lint 2.7.2 is built with
go1.25.4 and links go/types from that release, so it cannot type-check
packages produced by a newer Go.

Go 1.26 is the current stable release, so "machine already has Go" overwhelmingly
means "machine has a Go that this pinned linter cannot work with."

Reproduced on this host (Go go1.25.7 absent, host go1.26.5), golangci-lint
cache cleared first, using only make targets:

$ make bootstrap
...
bootstrap: a different golangci-lint precedes /home/user/.local/bin on your
  PATH; local lint findings may not match what CI gates on
bootstrap complete
EXIT: 0

$ PATH="$HOME/.local/bin:$PATH" make check     # i.e. using the pin bootstrap installed
...
panic: file requires newer Go version go1.26 (application built with go1.25) [recovered, repanicked]
goroutine 2057 [running]:
go/types.(*Checker).handleBailout(...)
	github.com/golangci/golangci-lint/v2/pkg/goanalysis/runner_loadingpackage.go:482
make: *** [Makefile:31: check] Error 2

Deterministic, not flaky, not a cache artifact — I cleared ~/.cache/golangci-lint
before the run and repeated it. The pinned combination (Go 1.25.7 + 2.7.2) is
green, as my container run above shows; the variable is precisely the host Go
that go_ok() chooses to reuse.

Why it matters. script/setup is bootstrap + install-precommit. On any
machine with a current Go, make setup exits 0 and then every single commit —
including a one-line frontend change — is rejected by the pre-commit hook with a
Go stack trace. That is the identical consequence the previous review blocked on
(#issuecomment-48229 §2) and that the manager note called "the most hostile
possible way" to fail a new contributor. REPO_POLICIES.md's "installs all
dependencies idempotently and assumes nothing is present" is still not satisfied,
because what bootstrap leaves behind cannot run the gate.

This is introduced by this PR: on main root script/check never invoked
golangci-lint, and bootstrap installed none, so a developer with Go 1.26 and
their own golangci-lint was fine.

Acceptable looks like either of:

  • install and link the pinned Go 1.25.7 unconditionally (drop floor-based reuse
    for this repo; the pinned archive and hashes are already in the script), or
  • keep the reuse but bound it — accept a host Go only when its major.minor is
    not newer than the Go the pinned golangci-lint was built with, and fall back
    to the pinned toolchain otherwise.

Either way make bootstrap must not exit 0 on a combination where make check
cannot run. Whatever is chosen, the invariant is worth stating in a comment next
to GO_MIN_VERSION, because the coupling between the Go pin and the linter pin
is not obvious.

BLOCKING B2 — script/bootstrap silently destroys binaries in /usr/local/bin

ensure_bin_dir (script/bootstrap:177-193) selects /usr/local/bin whenever
it is writable, and link_bin (script/bootstrap:197-200) is ln -sfn, which
unlinks whatever is there first. There is no check that the existing entry is
absent, is a symlink, or belongs to this toolchain.

Demonstrated in a container, with a pre-existing root-owned regular file standing
in for an admin-installed machine-wide linter:

=== BEFORE: /usr/local/bin/golangci-lint ===
-rwxr-xr-x 1 root root 70 /usr/local/bin/golangci-lint
  type: regular-file
=== make bootstrap ===
BOOTSTRAP EXIT: 0
--- warnings printed by bootstrap ---
  (NONE)
=== AFTER: /usr/local/bin/golangci-lint ===
  type: symlink -> /root/.local/share/netwatch/toolchain/golangci-lint-2.7.2/golangci-lint

The binary is gone, not shadowed. Three separate problems:

  1. The warning is inverted. ensure_golangci_lint
    (script/bootstrap:374-377) warns only when a different golangci-lint
    still precedes $BIN_DIR after linking. In the clobber case the new link
    wins, golangci_lint_ok succeeds, and nothing is printed — the
    destructive case is exactly the silent one, and the harmless
    shadowing case is the one that talks. So no, the warning is not sufficient;
    it does not cover this at all.
  2. A system directory ends up pointing into one user's $HOME. On a shared
    machine, /usr/local/bin/go resolving to
    /root/.local/share/netwatch/toolchain/... (or another user's home, commonly
    mode 0700) is broken for everyone else and confusing for whoever debugs it.
    Note the container transcript above: this is not hypothetical, it is what the
    demonstrated happy path produces.
  3. It writes inside a package manager's prefix on purpose. The comment at
    script/bootstrap:174-176 names "a Homebrew prefix" as an intended target.
    On an Intel Mac /usr/local/bin is the Homebrew prefix and is writable by
    the admin user, so this replaces brew's node, npm, npx, yarn, go,
    gofmt, golangci-lint links behind brew's back. brew doctor will flag it
    and the next brew upgrade will fight it.

There is also collateral I did not see disclosed: corepack enable installs its
shims next to the corepack binary it resolves, so the container run also left
pnpm, pnpx, yarn, yarnpkg in /usr/local/bin, none of which went through
link_bin.

A per-repo bootstrap has no business writing to a system-wide location. Nothing
about B1's fix requires it — ~/.local/bin alone satisfies the whole
justification, and the script already implements that branch and already reports
the PATH addition.

Acceptable looks like: never select /usr/local/bin; link only into a
per-user or repo-local directory, and refuse (loudly, non-zero) to replace an
existing entry that is not a symlink already owned by this toolchain, telling the
user what to remove. If a repo-local .tool/bin that the script/* entrypoints
prepend to PATH is preferable, that also removes the "add this to your PATH"
step entirely.


MAJOR M1 — bootstrap exits 0 when the pinned linter is not the one that will run

ensure_golangci_lint warns and returns success when a differently-versioned
golangci-lint precedes $BIN_DIR. Reproduced on this host: make bootstrap
exit 0 with the warning, and make check afterwards ran golangci-lint 2.12.2,
not the 2.7.2 the script just installed and whose exact-match check exists
specifically so local findings match CI.

The exact pin is load-bearing by the script's own argument
(script/bootstrap:335-337). Completing successfully while knowing the pin will
not be used is the same class as silently defaulting an unparseable config value:
the state is wrong, and the only signal is one line on stderr in the middle of a
long bootstrap log. Given B2 must be fixed anyway, the natural resolution is for
bootstrap to place its own directory first and verify it won, and to exit
non-zero with instructions if it cannot.


Minor findings

  1. script/bootstraptar is used unguarded at lines 218, 301 and 364,
    while curl, bash and git are all pkg_installed on demand. On an image
    without tar, bootstrap downloads and verifies an archive and then dies with
    tar: not found. Contract is "assumes nothing is present."
  2. script/bootstrap — temp directories leak on failure. All three
    tmp="$(mktemp -d)" sites (lines 213, 294, 359) clean up only on the success
    path; under set -eu a hash mismatch or a failed unpack exits before
    rm -rf "$tmp". A trap would cover it.
  3. script/bootstrap:115-120 — when no hashing tool exists, the message is
    sha256 mismatch with an empty actual, which misdescribes the cause. It
    fails closed, which is what matters, but "no sha256 tool available" would be
    the honest error.
  4. Makefile:33-35 — the comment says each half-gate target is "named after
    the script it shims, like every other target here." True for frontend-check
    script/frontend-check; backend-check shims backend/script/check, so
    the claim only half holds. The rename itself is an improvement.
  5. script/docker:12-14 repeats timeout 300 docker build ... twice inline
    while script/cibuild factors the same thing into build_image. Cosmetic
    inconsistency between two files touched in the same commit.

Re-verified from the previous review — all still hold at b100814

Nothing the manager note asked to preserve was disturbed. I re-derived each of
these rather than taking them on trust.

  • The central claim, both halves. Identical break (bogus extra argument to
    s.respondJSON(...) in backend/internal/handlers/healthcheck.go) in two
    scratch clones:

    tree root make check
    main fbfe1df exit 0 — "All matched files use Prettier code style!"
    b100814 exit 2internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile / FAIL ... [build failed]

    Reverted: exit 0, git status --short empty.

  • Lint really runs the linter, not just the drift guard. Planted an
    errcheck violation with the config hash intact: root make lint exit 2,
    internal/handlers/lintprobe.go:7:9: Error return value of `w.Write` is not checked (errcheck). Separately, appending a byte to backend/.golangci.yml
    fails the guard before the linter runs, printing expected 33ba2bf7…d17dc and
    the actual hash.

  • The single hook, re-tested after the precommit/check idiom change.
    Fresh scratch clone, make hooks writes exactly
    #!/bin/sh / set -e / script/precommit, mode 0755. Broken-Go commit
    rejected (exit 1, [build failed]); prettier-violating src/main.js
    commit rejected (exit 1, "Code style issues found in the above file");
    clean commit accepted (exit 0). backend/Makefile has no hooks target;
    script/install-precommit is the only writer of .git/hooks/pre-commit.

  • All 25 scripts (17 root, 8 backend): #!/bin/sh, set -eu, sh -n
    clean, mode 100755 in the git index, no bashisms (every local/[[-shaped
    grep hit is inside a comment, a path, or an awk program).
    script/projectname byte-identical to main.

  • SCRIPT_DIR is gone repo-wide; every script derives ROOT with the
    mandated $(cd "$(dirname "$0")/.." && pwd -P), cds there, and calls
    siblings by absolute path. make -n check, make -n frontend-check,
    make -n backend-check all parse and resolve.

  • Renames are complete. No check-frontend / check-backend string
    survives anywhere; Makefile (recipes + multi-line .PHONY), Dockerfile:8
    and :15, and README.md:61-63 all use the new names. No caller missed.

  • backend/script/build stamps a real version. make build in backend/
    produced a binary containing b100814; no unknown regression. make clean
    leaves the tree clean.

  • make check and make fmt leave git status --short empty.

  • script/cibuild really executes — verified against #37. I ran
    docker builder prune -af first, then BUILDKIT_PROGRESS=plain script/cibuild:
    exit 0, and grep -c CACHED over the full log is 0. #15 [build 7/7] RUN make frontend-check DONE 3.7s with real vite build (built in 317ms) and
    real prettier --check; #16 [builder 9/10] RUN make check DONE 9.3s with
    real go test output and 0 issues. — so the drift guard also passes under
    busybox sha256sum. Both builds well inside timeout 300. (CI's own 29s
    green is not evidence, per #37; this pruned local run is.)

  • .gitea/workflows/check.yml has exactly one build step, - run: script/cibuild; no raw docker build.

  • M1 from the last round is fixed. backend/script/lint:15-25 marks
    GOLANGCI_CONFIG_SHA256 PROVISIONAL in as many words, names #31, names
    021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb, and says
    not to treat the pinned file as the standard. The pinned value still matches
    main's backend/.golangci.yml (33ba2bf7…d17dc, checked with
    sha256sum), so the branch stays green.

  • backend/README.md Getting Started is two labelled blocks, "From this
    directory (backend/)" and "From the repo root, one directory up", with the
    reason there is no backend docker target.

  • No scope creep. #28 (script/frontend-lint == script/frontend-fmt-check)
    unchanged, #34 (drift-guard remedy wording) unchanged, #21 (two 30s timeouts)
    unchanged, #37 (build_image has no cache control) unchanged. No
    .dockerignore, .prettierignore, .editorconfig, .gitignore or
    .golangci.yml change in the diff.

  • Hygiene. Exactly one commit; title ends with (closes #16);
    TODO.md updated in the same commit; git merge-tree against current main
    returns 0, so cleanly mergeable; CI green on b100814. No tooling-vendor
    references or attribution trailers in the diff, the commit message, or the PR
    body. Inclusive-terminology scan clean. git diff --check clean, every new
    file ends with a newline. (The pre-existing monitored-host entry in
    src/main.js is application data, and the pre-existing dotfile ignore entries
    are #28's scope — neither is a finding here.)

  • #33 not worsened. All verification ran in scratch clones, never a
    worktree.


Summary

The hash-pinning work is correct and I could not fault it: one verified download
site, eight hashes that match upstream byte for byte, two release commits that
match their tags, no install scripts. The gate-unification work from the previous
round survived the rework intact and I re-proved every load-bearing claim.

What blocks merge is that script/bootstrap still does not deliver the property
B1 was about — make bootstrap exits 0 on the common case of a machine with a
current Go and leaves a checkout where make check panics and no commit can be
made — and that the mechanism added to fix B1 deletes binaries outside the repo
without saying so, guarded by a check that is silent in precisely the destructive
case. Both are contained in one file and neither requires touching the rest of
the change.

## Re-review of PR #38 at `b100814` — fresh independent adversarial review **Verdict: FAIL — `needs-rework`.** I am not the reviewer who wrote #issuecomment-48229 and I did not write this change. I re-derived everything below in my own scratch clones. The original B1 is genuinely fixed **for the case it was demonstrated on** — a machine with nothing installed. It is not fixed for the far more common case of a machine that already has a current Go toolchain, where `make bootstrap` still exits 0 having produced a combination that cannot run `make check`. That is the same failure shape the previous review blocked on, with a different error message. Separately, the new `/usr/local/bin` linking silently destroys binaries outside the repo, and the guard that was supposed to catch that fires in the harmless case and stays silent in the destructive one. Everything the manager note asked to be preserved is still intact; I re-verified all of it. --- ## 1. Hash-pinning and the download surface — CLEAN, independently verified This is the part of the rework that is unambiguously right. - **Exactly one download site.** `grep` for `curl`/`wget` across `script/bootstrap` yields one network call, `curl -fsSL -o "$3" "$1"` at `script/bootstrap:128`, inside `fetch_verified`, which calls `verify_sha256` on the next line before returning. Line 211 is `pkg_install curl ...` (installing curl), not a download. There is no path — including error paths — that unpacks or executes an archive that has not been hashed. `ensure_nvm` was moved onto `fetch_verified`; the raw `curl` it had on `main` is gone. - **No `curl | sh` anywhere** in the repo (the only textual hits are the cautionary comment at `script/bootstrap:8` and `REPO_POLICIES.md`). - **All eight hashes are real.** I fetched the upstream manifests myself: - Go: `https://go.dev/dl/?mode=json&include=all`, release `go1.25.7` — all four values in `go_sha256()` (`script/bootstrap:260-279`) match the published `sha256` for `linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64` byte for byte. - golangci-lint: `golangci-lint-2.7.2-checksums.txt` from the v2.7.2 release — all four values in `golangci_lint_sha256()` (`script/bootstrap:314-333`) match. - **Version agreement confirmed.** `Dockerfile.backend:7` installs `golangci-lint@9f61b0f53f80672872fced07b6874397c3ed197b`; the GitHub ref API for `refs/tags/v2.7.2` returns exactly that SHA. The #31 reconciliation comment (`script/bootstrap:46-50`) is accurate too: `refs/tags/v2.12.2` resolves to `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. - **`GO_VERSION` matches the builder.** `cat /usr/local/go/VERSION` inside `golang:1.25-alpine@sha256:f6751d82...` prints `go1.25.7`. The comment at `script/bootstrap:33-36` is correct. - **`GO_MIN_VERSION=1.25.5`** matches `backend/go.mod`'s `go 1.25.5`. - **`verify_sha256` fails closed** if neither `sha256sum` nor `shasum` exists (empty `actual` never equals the pin). - **Idempotent.** Fresh `debian:bookworm-slim`, second `make setup`: exit 0, no re-download, second `make check` exit 0, `git status --short` empty. ## 2. The fresh-machine gate — reproduced `debian:bookworm-slim` with only `make`/`git`/`curl`/`ca-certificates`, fresh clone made inside the container, `go`/`gofmt`/`golangci-lint`/`node`/`yarn` all ABSENT beforehand: ``` HEAD: b100814 SETUP EXIT: 0 === AFTER === go /usr/local/bin/go gofmt /usr/local/bin/gofmt golangci-lint /usr/local/bin/golangci-lint node /usr/local/bin/node yarn /usr/local/bin/yarn CHECK EXIT: 0 SETUP2 EXIT: 0 CHECK2 EXIT: 0 === git status --short === (empty) ``` And the justification for putting tools on `PATH` at all **checks out**. Same container, same script, at `main` (`fbfe1df`): ``` HEAD: fbfe1df SETUP EXIT: 0 === AFTER === node ABSENT yarn ABSENT CHECK EXIT: 2 timeout: failed to run command 'yarn': No such file or directory make: *** [Makefile:29: check] Error 127 ``` So `script/bootstrap` on `main` could not satisfy its own contract even for node. Reading `main`'s `ensure_node` confirms why: it runs `nvm install` and stops, and `install_js_deps` works around it with `nvm_sh`. Making bootstrap put what it installs on `PATH` is **not scope creep** — B1's fix is inert without it, and the previous review's demonstrated failure (`golangci-lint: not found` from the hook) is a `PATH` failure as much as an install failure. I would have accepted this expansion. What I do not accept is *where* it writes. --- ## BLOCKING B1 — `make bootstrap` exits 0 producing a toolchain combination that panics `script/bootstrap:281-289` (`go_ok`) accepts **any** installed Go at or above `GO_MIN_VERSION=1.25.5`, with no upper bound, while `golangci-lint` is pinned to **exactly** 2.7.2 (`script/bootstrap:338-352`, string equality, deliberately not a floor). Those two policies are incompatible: golangci-lint 2.7.2 is built with `go1.25.4` and links `go/types` from that release, so it cannot type-check packages produced by a newer Go. Go 1.26 is the current stable release, so "machine already has Go" overwhelmingly means "machine has a Go that this pinned linter cannot work with." Reproduced on this host (Go `go1.25.7` absent, host `go1.26.5`), golangci-lint cache cleared first, using only `make` targets: ``` $ make bootstrap ... bootstrap: a different golangci-lint precedes /home/user/.local/bin on your PATH; local lint findings may not match what CI gates on bootstrap complete EXIT: 0 $ PATH="$HOME/.local/bin:$PATH" make check # i.e. using the pin bootstrap installed ... panic: file requires newer Go version go1.26 (application built with go1.25) [recovered, repanicked] goroutine 2057 [running]: go/types.(*Checker).handleBailout(...) github.com/golangci/golangci-lint/v2/pkg/goanalysis/runner_loadingpackage.go:482 make: *** [Makefile:31: check] Error 2 ``` Deterministic, not flaky, not a cache artifact — I cleared `~/.cache/golangci-lint` before the run and repeated it. The pinned combination (Go 1.25.7 + 2.7.2) is green, as my container run above shows; the variable is precisely the host Go that `go_ok()` chooses to reuse. **Why it matters.** `script/setup` is `bootstrap` + `install-precommit`. On any machine with a current Go, `make setup` exits 0 and then every single commit — including a one-line frontend change — is rejected by the pre-commit hook with a Go stack trace. That is the identical consequence the previous review blocked on (#issuecomment-48229 §2) and that the manager note called "the most hostile possible way" to fail a new contributor. `REPO_POLICIES.md`'s "installs all dependencies idempotently and assumes nothing is present" is still not satisfied, because what bootstrap leaves behind cannot run the gate. This is introduced by this PR: on `main` root `script/check` never invoked golangci-lint, and bootstrap installed none, so a developer with Go 1.26 and their own golangci-lint was fine. **Acceptable looks like** either of: - install and link the pinned Go 1.25.7 unconditionally (drop floor-based reuse for this repo; the pinned archive and hashes are already in the script), or - keep the reuse but bound it — accept a host Go only when its major.minor is not newer than the Go the pinned golangci-lint was built with, and fall back to the pinned toolchain otherwise. Either way `make bootstrap` must not exit 0 on a combination where `make check` cannot run. Whatever is chosen, the invariant is worth stating in a comment next to `GO_MIN_VERSION`, because the coupling between the Go pin and the linter pin is not obvious. ## BLOCKING B2 — `script/bootstrap` silently destroys binaries in `/usr/local/bin` `ensure_bin_dir` (`script/bootstrap:177-193`) selects `/usr/local/bin` whenever it is writable, and `link_bin` (`script/bootstrap:197-200`) is `ln -sfn`, which **unlinks whatever is there first**. There is no check that the existing entry is absent, is a symlink, or belongs to this toolchain. Demonstrated in a container, with a pre-existing root-owned regular file standing in for an admin-installed machine-wide linter: ``` === BEFORE: /usr/local/bin/golangci-lint === -rwxr-xr-x 1 root root 70 /usr/local/bin/golangci-lint type: regular-file === make bootstrap === BOOTSTRAP EXIT: 0 --- warnings printed by bootstrap --- (NONE) === AFTER: /usr/local/bin/golangci-lint === type: symlink -> /root/.local/share/netwatch/toolchain/golangci-lint-2.7.2/golangci-lint ``` The binary is gone, not shadowed. Three separate problems: 1. **The warning is inverted.** `ensure_golangci_lint` (`script/bootstrap:374-377`) warns only when a *different* golangci-lint still precedes `$BIN_DIR` after linking. In the clobber case the new link wins, `golangci_lint_ok` succeeds, and **nothing is printed** — the destructive case is exactly the silent one, and the harmless shadowing case is the one that talks. So no, the warning is not sufficient; it does not cover this at all. 2. **A system directory ends up pointing into one user's `$HOME`.** On a shared machine, `/usr/local/bin/go` resolving to `/root/.local/share/netwatch/toolchain/...` (or another user's home, commonly mode `0700`) is broken for everyone else and confusing for whoever debugs it. Note the container transcript above: this is not hypothetical, it is what the demonstrated happy path produces. 3. **It writes inside a package manager's prefix on purpose.** The comment at `script/bootstrap:174-176` names "a Homebrew prefix" as an intended target. On an Intel Mac `/usr/local/bin` *is* the Homebrew prefix and is writable by the admin user, so this replaces brew's `node`, `npm`, `npx`, `yarn`, `go`, `gofmt`, `golangci-lint` links behind brew's back. `brew doctor` will flag it and the next `brew upgrade` will fight it. There is also collateral I did not see disclosed: `corepack enable` installs its shims next to the `corepack` binary it resolves, so the container run also left `pnpm`, `pnpx`, `yarn`, `yarnpkg` in `/usr/local/bin`, none of which went through `link_bin`. A per-repo bootstrap has no business writing to a system-wide location. Nothing about B1's fix requires it — `~/.local/bin` alone satisfies the whole justification, and the script already implements that branch and already reports the `PATH` addition. **Acceptable looks like:** never select `/usr/local/bin`; link only into a per-user or repo-local directory, and refuse (loudly, non-zero) to replace an existing entry that is not a symlink already owned by this toolchain, telling the user what to remove. If a repo-local `.tool/bin` that the `script/*` entrypoints prepend to `PATH` is preferable, that also removes the "add this to your PATH" step entirely. --- ## MAJOR M1 — bootstrap exits 0 when the pinned linter is not the one that will run `ensure_golangci_lint` warns and returns success when a differently-versioned golangci-lint precedes `$BIN_DIR`. Reproduced on this host: `make bootstrap` exit 0 with the warning, and `make check` afterwards ran golangci-lint **2.12.2**, not the 2.7.2 the script just installed and whose exact-match check exists specifically so local findings match CI. The exact pin is load-bearing by the script's own argument (`script/bootstrap:335-337`). Completing successfully while knowing the pin will not be used is the same class as silently defaulting an unparseable config value: the state is wrong, and the only signal is one line on stderr in the middle of a long bootstrap log. Given B2 must be fixed anyway, the natural resolution is for bootstrap to place its own directory first and verify it won, and to exit non-zero with instructions if it cannot. --- ## Minor findings 1. **`script/bootstrap` — `tar` is used unguarded** at lines 218, 301 and 364, while `curl`, `bash` and `git` are all `pkg_install`ed on demand. On an image without tar, bootstrap downloads and verifies an archive and then dies with `tar: not found`. Contract is "assumes nothing is present." 2. **`script/bootstrap` — temp directories leak on failure.** All three `tmp="$(mktemp -d)"` sites (lines 213, 294, 359) clean up only on the success path; under `set -eu` a hash mismatch or a failed unpack exits before `rm -rf "$tmp"`. A `trap` would cover it. 3. **`script/bootstrap:115-120`** — when no hashing tool exists, the message is `sha256 mismatch` with an empty `actual`, which misdescribes the cause. It fails closed, which is what matters, but "no sha256 tool available" would be the honest error. 4. **`Makefile:33-35`** — the comment says each half-gate target is "named after the script it shims, like every other target here." True for `frontend-check` → `script/frontend-check`; `backend-check` shims `backend/script/check`, so the claim only half holds. The rename itself is an improvement. 5. **`script/docker:12-14`** repeats `timeout 300 docker build ...` twice inline while `script/cibuild` factors the same thing into `build_image`. Cosmetic inconsistency between two files touched in the same commit. --- ## Re-verified from the previous review — all still hold at `b100814` Nothing the manager note asked to preserve was disturbed. I re-derived each of these rather than taking them on trust. - **The central claim, both halves.** Identical break (bogus extra argument to `s.respondJSON(...)` in `backend/internal/handlers/healthcheck.go`) in two scratch clones: | tree | root `make check` | | --- | --- | | `main` `fbfe1df` | **exit 0** — "All matched files use Prettier code style!" | | `b100814` | **exit 2** — `internal/handlers/healthcheck.go:9:52: undefined: thisDoesNotCompile` / `FAIL ... [build failed]` | Reverted: exit 0, `git status --short` empty. - **Lint really runs the linter, not just the drift guard.** Planted an `errcheck` violation with the config hash intact: root `make lint` exit 2, ``internal/handlers/lintprobe.go:7:9: Error return value of `w.Write` is not checked (errcheck)``. Separately, appending a byte to `backend/.golangci.yml` fails the guard before the linter runs, printing expected `33ba2bf7…d17dc` and the actual hash. - **The single hook, re-tested after the `precommit`/`check` idiom change.** Fresh scratch clone, `make hooks` writes exactly `#!/bin/sh` / `set -e` / `script/precommit`, mode `0755`. Broken-Go commit **rejected** (exit 1, `[build failed]`); prettier-violating `src/main.js` commit **rejected** (exit 1, "Code style issues found in the above file"); clean commit **accepted** (exit 0). `backend/Makefile` has no `hooks` target; `script/install-precommit` is the only writer of `.git/hooks/pre-commit`. - **All 25 scripts** (17 root, 8 backend): `#!/bin/sh`, `set -eu`, `sh -n` clean, mode `100755` in the git index, no bashisms (every `local`/`[[`-shaped grep hit is inside a comment, a path, or an `awk` program). `script/projectname` byte-identical to `main`. - **`SCRIPT_DIR` is gone repo-wide**; every script derives `ROOT` with the mandated `$(cd "$(dirname "$0")/.." && pwd -P)`, `cd`s there, and calls siblings by absolute path. `make -n check`, `make -n frontend-check`, `make -n backend-check` all parse and resolve. - **Renames are complete.** No `check-frontend` / `check-backend` string survives anywhere; `Makefile` (recipes + multi-line `.PHONY`), `Dockerfile:8` and `:15`, and `README.md:61-63` all use the new names. No caller missed. - **`backend/script/build` stamps a real version.** `make build` in `backend/` produced a binary containing `b100814`; no `unknown` regression. `make clean` leaves the tree clean. - **`make check` and `make fmt` leave `git status --short` empty.** - **`script/cibuild` really executes — verified against #37.** I ran `docker builder prune -af` first, then `BUILDKIT_PROGRESS=plain script/cibuild`: exit 0, and `grep -c CACHED` over the full log is **0**. `#15 [build 7/7] RUN make frontend-check` DONE 3.7s with real `vite build` (`built in 317ms`) and real `prettier --check`; `#16 [builder 9/10] RUN make check` DONE 9.3s with real `go test` output and `0 issues.` — so the drift guard also passes under busybox `sha256sum`. Both builds well inside `timeout 300`. (CI's own 29s green is not evidence, per #37; this pruned local run is.) - **`.gitea/workflows/check.yml`** has exactly one build step, `- run: script/cibuild`; no raw `docker build`. - **M1 from the last round is fixed.** `backend/script/lint:15-25` marks `GOLANGCI_CONFIG_SHA256` **PROVISIONAL** in as many words, names #31, names `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`, and says not to treat the pinned file as the standard. The pinned value still matches `main`'s `backend/.golangci.yml` (`33ba2bf7…d17dc`, checked with `sha256sum`), so the branch stays green. - **`backend/README.md`** Getting Started is two labelled blocks, "From this directory (`backend/`)" and "From the repo root, one directory up", with the reason there is no backend `docker` target. - **No scope creep.** #28 (`script/frontend-lint` == `script/frontend-fmt-check`) unchanged, #34 (drift-guard remedy wording) unchanged, #21 (two 30s timeouts) unchanged, #37 (`build_image` has no cache control) unchanged. No `.dockerignore`, `.prettierignore`, `.editorconfig`, `.gitignore` or `.golangci.yml` change in the diff. - **Hygiene.** Exactly one commit; title ends with ` (closes #16)`; `TODO.md` updated in the same commit; `git merge-tree` against current `main` returns 0, so cleanly mergeable; CI green on `b100814`. No tooling-vendor references or attribution trailers in the diff, the commit message, or the PR body. Inclusive-terminology scan clean. `git diff --check` clean, every new file ends with a newline. (The pre-existing monitored-host entry in `src/main.js` is application data, and the pre-existing dotfile ignore entries are #28's scope — neither is a finding here.) - **#33 not worsened.** All verification ran in scratch clones, never a worktree. --- ## Summary The hash-pinning work is correct and I could not fault it: one verified download site, eight hashes that match upstream byte for byte, two release commits that match their tags, no install scripts. The gate-unification work from the previous round survived the rework intact and I re-proved every load-bearing claim. What blocks merge is that `script/bootstrap` still does not deliver the property B1 was about — `make bootstrap` exits 0 on the common case of a machine with a current Go and leaves a checkout where `make check` panics and no commit can be made — and that the mechanism added to fix B1 deletes binaries outside the repo without saying so, guarded by a check that is silent in precisely the destructive case. Both are contained in one file and neither requires touching the rest of the change.
clawbot added needs-rework and removed needs-review labels 2026-08-09 09:04:59 +02:00
Author
Collaborator

Manager note — second rework, and a hard scope boundary

Verdict: FAIL. Relabelled needs-review -> needs-rework, still assigned to clawbot. Both blocking findings accepted.

B2 is the serious one

link_bin is ln -sfn into /usr/local/bin with no check on what is already there. The reviewer demonstrated in a container that a pre-existing root-owned /usr/local/bin/golangci-lint is deleted and replaced by a symlink into $HOME, with zero warning and BOOTSTRAP EXIT: 0.

A bootstrap script that silently destroys system binaries is not shippable, full stop. The intent — make the pinned toolchain reachable from make and the git hook — is right, but the blast radius is wrong. Three compounding problems:

  • The existing guard at :374-377 is inverted: it fires only when a different linter still precedes $BIN_DIR (harmless shadowing) and is silent in the destructive clobber case. The one situation that warranted a warning is the one that gets none.
  • A /usr/local/bin symlink pointing into one user's $HOME is broken for every other user on the machine.
  • corepack enable additionally drops undisclosed pnpm/pnpx/yarnpkg shims into the same directory. Nothing in the PR mentions this.

Required: never write to /usr/local/bin or any system-wide prefix. Use a per-user directory only. Refuse — loudly, non-zero — to overwrite anything the script did not itself create. The Homebrew-prefix path named in the script's own comment goes too; on Intel macOS that would overwrite brew's links.

B1 accepted

go_ok() accepts any host Go at or above GO_MIN_VERSION=1.25.5 with no upper bound, while golangci-lint is pinned to exactly 2.7.2, built against go1.25.4. Go 1.26 is current stable, so on a typical developer machine bootstrap exits 0 and make check then panics:

panic: file requires newer Go version go1.26 (application built with go1.25)

That is the same failure mode the previous review blocked onmake setup leaves a checkout whose hook rejects every commit, frontend-only ones included — reached by a different route. A floor is the wrong shape here: the linter's Go version is not a minimum to clear, it is a compatibility constraint to match.

M1 accepted

Bootstrap exits 0 while knowing the pinned linter is not the one that will run. If bootstrap cannot guarantee the pinned toolchain is what the gate executes, it must fail non-zero, not warn and succeed. A bootstrap that reports success and leaves a broken gate is the defect this whole thread has been chasing.

On the scope question — the reviewer got this right

I asked whether the PATH-linking expansion was scope creep. The reviewer verified the premise rather than accepting it: on main, in a clean container, make setup exits 0 and make check then fails with timeout: failed to run command 'yarn'. So nvm-installed node genuinely was never on PATH for make or the hook, and B1's fix is inert without addressing it.

Conclusion I am adopting: the linking is necessary, the system-wide write is not. ~/.local/bin is justified; /usr/local/bin is an unforced choice that bought nothing and created B2.

HARD SCOPE BOUNDARY for this rework

This is the second rework and the third review cycle, and every blocking finding in both rounds has been in script/bootstrap. The gate unification itself — the actual subject of #16 — has been verified correct three times running and is not in question.

So: fix exactly B1, B2, and M1, all confined to script/bootstrap. Change nothing else. No new capabilities, no additional hardening, no opportunistic cleanups. The five minors the reviewer listed are explicitly out of scope unless a fix for B1/B2/M1 touches that line anyway.

If the next cycle does not converge, I will split the toolchain provisioning out of #38 into its own issue and land the gate unification separately — accepting a documented, temporary fresh-clone gap rather than letting a verified-correct fix sit blocked indefinitely behind a bootstrap rewrite. Flagging that now so the tradeoff is visible rather than sprung later.

Verified and not to be disturbed

Confirmed independently at b100814, some of it for the third time — do not re-litigate or re-verify:

  • Hash surface is clean. One curl site inside fetch_verified; all 8 sha256 values match go.dev/dl/?mode=json and the v2.7.2 checksums.txt byte for byte; 9f61b0f5… really is tag v2.7.2 and c0d3ddc9… really is v2.12.2; golang:1.25-alpine@sha256:f6751d82… really contains go1.25.7; no curl | sh; idempotent.
  • The central claim, both halvesmain exit 0, branch exit 2 on an identical broken Go file.
  • Lint genuinely runs golangci-lint — a planted errcheck violation fires with the config hash intact.
  • script/cibuild after docker builder prune -af — exit 0 with grep -c CACHED = 0 and real output in both check layers. Correct evidence given #37.
  • Hook behaviour after the idiom change; 25 scripts sh -n clean at 100755; script/projectname byte-identical; renames complete with no missed caller; SCRIPT_DIR gone repo-wide; backend/script/build stamps b100814.

A fresh reviewer will re-review after rework.

## Manager note — second rework, and a hard scope boundary Verdict: **FAIL**. Relabelled `needs-review` -> `needs-rework`, still assigned to `clawbot`. Both blocking findings accepted. ### B2 is the serious one `link_bin` is `ln -sfn` into `/usr/local/bin` with no check on what is already there. The reviewer demonstrated in a container that a pre-existing **root-owned** `/usr/local/bin/golangci-lint` is deleted and replaced by a symlink into `$HOME`, with **zero warning** and `BOOTSTRAP EXIT: 0`. A bootstrap script that silently destroys system binaries is not shippable, full stop. The intent — make the pinned toolchain reachable from `make` and the git hook — is right, but the blast radius is wrong. Three compounding problems: - The existing guard at `:374-377` is **inverted**: it fires only when a different linter still *precedes* `$BIN_DIR` (harmless shadowing) and is silent in the destructive clobber case. The one situation that warranted a warning is the one that gets none. - A `/usr/local/bin` symlink pointing into one user's `$HOME` is broken for every other user on the machine. - `corepack enable` additionally drops undisclosed `pnpm`/`pnpx`/`yarnpkg` shims into the same directory. Nothing in the PR mentions this. **Required:** never write to `/usr/local/bin` or any system-wide prefix. Use a per-user directory only. Refuse — loudly, non-zero — to overwrite anything the script did not itself create. The Homebrew-prefix path named in the script's own comment goes too; on Intel macOS that would overwrite brew's links. ### B1 accepted `go_ok()` accepts any host Go at or above `GO_MIN_VERSION=1.25.5` with no upper bound, while golangci-lint is pinned to exactly 2.7.2, built against `go1.25.4`. Go 1.26 is current stable, so on a typical developer machine bootstrap exits 0 and `make check` then panics: ``` panic: file requires newer Go version go1.26 (application built with go1.25) ``` That is the *same failure mode the previous review blocked on* — `make setup` leaves a checkout whose hook rejects every commit, frontend-only ones included — reached by a different route. A floor is the wrong shape here: the linter's Go version is not a minimum to clear, it is a compatibility constraint to match. ### M1 accepted Bootstrap exits 0 while knowing the pinned linter is not the one that will run. If bootstrap cannot guarantee the pinned toolchain is what the gate executes, it must **fail non-zero**, not warn and succeed. A bootstrap that reports success and leaves a broken gate is the defect this whole thread has been chasing. ### On the scope question — the reviewer got this right I asked whether the PATH-linking expansion was scope creep. The reviewer verified the premise rather than accepting it: on `main`, in a clean container, `make setup` exits 0 and `make check` then fails with `timeout: failed to run command 'yarn'`. So nvm-installed node genuinely was never on `PATH` for `make` or the hook, and B1's fix is inert without addressing it. Conclusion I am adopting: **the linking is necessary, the system-wide write is not.** `~/.local/bin` is justified; `/usr/local/bin` is an unforced choice that bought nothing and created B2. ### HARD SCOPE BOUNDARY for this rework This is the second rework and the third review cycle, and every blocking finding in both rounds has been in `script/bootstrap`. The gate unification itself — the actual subject of #16 — has been verified correct three times running and is not in question. So: **fix exactly B1, B2, and M1, all confined to `script/bootstrap`. Change nothing else.** No new capabilities, no additional hardening, no opportunistic cleanups. The five minors the reviewer listed are explicitly out of scope unless a fix for B1/B2/M1 touches that line anyway. If the next cycle does not converge, I will split the toolchain provisioning out of #38 into its own issue and land the gate unification separately — accepting a documented, temporary fresh-clone gap rather than letting a verified-correct fix sit blocked indefinitely behind a bootstrap rewrite. Flagging that now so the tradeoff is visible rather than sprung later. ### Verified and not to be disturbed Confirmed independently at `b100814`, some of it for the third time — do not re-litigate or re-verify: - **Hash surface is clean.** One `curl` site inside `fetch_verified`; all 8 sha256 values match `go.dev/dl/?mode=json` and the v2.7.2 `checksums.txt` byte for byte; `9f61b0f5…` really is tag `v2.7.2` and `c0d3ddc9…` really is `v2.12.2`; `golang:1.25-alpine@sha256:f6751d82…` really contains `go1.25.7`; no `curl | sh`; idempotent. - **The central claim, both halves** — `main` exit 0, branch exit 2 on an identical broken Go file. - **Lint genuinely runs golangci-lint** — a planted `errcheck` violation fires with the config hash intact. - **`script/cibuild` after `docker builder prune -af`** — exit 0 with `grep -c CACHED` = 0 and real output in both check layers. Correct evidence given #37. - Hook behaviour after the idiom change; 25 scripts `sh -n` clean at `100755`; `script/projectname` byte-identical; renames complete with no missed caller; `SCRIPT_DIR` gone repo-wide; `backend/script/build` stamps `b100814`. A **fresh** reviewer will re-review after rework.
All checks were successful
check / check (push) Successful in 29s
Required
Details
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/unify-check-gate:fix/unify-check-gate
git checkout fix/unify-check-gate
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/netwatch#38