1 Commits

Author SHA1 Message Date
2d1f123e65 config: migrate gomodguard to gomodguard_v2 in canonical .golangci.yml
All checks were successful
check / check (push) Successful in 6s
golangci-lint v2.12.0 deprecated `gomodguard` in favour of `gomodguard_v2`.
Because the canonical config uses `default: all`, the deprecated linter is
auto-enabled and every lint run in every consuming Go repo emits a
deprecation warning.

`gomodguard` and `gomodguard_v2` are two independently registered linters
in v2.12.2, so `gomodguard_v2` is already enabled by `default: all`; only
the deprecated one needs disabling. This mirrors the existing `wsl` entry,
which was disabled for the same reason when `wsl_v5` superseded it.

No settings migration was required: the canonical config configures no
gomodguard settings at all.

Refs: #25
2026-08-10 14:16:21 +00:00
10 changed files with 49 additions and 371 deletions

View File

@@ -16,6 +16,7 @@ linters:
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:

View File

@@ -12,17 +12,4 @@ COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and
# script/docker. Without it an unchanged tree serves this layer from
# cache and the build reports a green it never ran. ARG is stage-scoped,
# so it must be redeclared in every stage that runs checks. The guard
# makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
RUN make check

View File

@@ -123,13 +123,9 @@ alpine. We provide:
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as
`script/cibuild`
- `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image
build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what
stops Docker serving that check from cache on an unchanged tree — a bare
`docker build .` fails closed on purpose)
(byte-identical across repos)
- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the
image build runs `script/check`)
- `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own

17
TODO.md
View File

@@ -21,23 +21,6 @@ fmt-check, and commit.
# Completed Steps
- 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md
now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old
`if missing` guard tested PATH presence only, so pins were inert on any
provisioned machine and CI silently disagreed with local) and then re-resolves
the binary through `PATH` and fails loudly, naming the shadowing path, when
the install did not take effect — the failure mode the naive
compare-then-install fix leaves behind while reporting success.
- 2026-08-09: Fixed the false green in the canonical CI gate: `script/cibuild`
and `script/docker` now pass a per-invocation `CHECK_EPOCH` nonce, and the
`Dockerfile` (plus the Go multistage template in REPO_POLICIES.md, in both its
lint and builder stages) declares `ARG CHECK_EPOCH` with a guard that makes a
bare `docker build .` fail closed. Corrected the org-canonical text that
asserted a successful build implies all checks pass, across every document
carrying it: `REPO_POLICIES.md`, both repo checklists (which still told agents
to write the pre-fix `script/cibuild` and ended on an acceptance item the
guard makes unsatisfiable), and the Go styleguide.
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
config already deployed byte-identical across the org's Go repos (settings
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually

View File

@@ -1,6 +1,6 @@
---
title: Code Styleguide — Go
last_modified: 2026-08-09
last_modified: 2026-03-18
---
1. Try to hard wrap long lines at 77 characters or less.
@@ -101,16 +101,9 @@ last_modified: 2026-08-09
`golangci-lint`.
1. Write a `Dockerfile` for every repo, even if it only runs the tests and
linting. `script/cibuild` and `script/docker` should always make sure that
the code is in an able-to-be-compiled state, linted, and any tests run, and
the build should fail if linting doesn't pass. That guarantee holds only
because those scripts pass a per-invocation `CHECK_EPOCH` build arg that
busts the check layers out of the Docker cache; without it an unchanged tree
serves those layers from cache and the build reports a green it never ran. A
bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]`
guard — always go through `script/cibuild` or `script/docker`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form.
linting. `docker build .` should always make sure that the code is in an
able-to-be-compiled state, linted, and any tests run. The Docker build
should fail if linting doesn't pass.
1. Every repo must have a `Makefile`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)

View File

@@ -1,6 +1,6 @@
---
title: Existing Repo Checklist
last_modified: 2026-08-09
last_modified: 2026-07-06
---
Use this checklist when beginning work in a repo that may not yet conform to our
@@ -29,15 +29,10 @@ with your task.
if missing
- [ ] `.editorconfig` exists — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`);
Dockerfile runs `make check` as a build step, and every stage containing a
check-running `RUN` declares `ARG CHECK_EPOCH` with the
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
- [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
build step — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `docker build .` on
push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
- [ ] Language-specific config:
@@ -109,6 +104,5 @@ with your task.
# Final
- [ ] `make check` passes
- [ ] `script/cibuild` succeeds (a bare `docker build .` fails closed by design,
on the `CHECK_EPOCH` guard)
- [ ] `docker build` succeeds
- [ ] Commit and merge fixes before starting your actual task

View File

@@ -1,6 +1,6 @@
---
title: New Repo Checklist
last_modified: 2026-08-09
last_modified: 2026-07-06
---
Use this checklist when creating a new repository from scratch. Follow the steps
@@ -52,12 +52,7 @@ Template files can be fetched from:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- All Dockerfiles must run `make check` as a build step, and every stage
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- All Dockerfiles must run `make check` as a build step
- Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check`
- Image pinned by sha256 hash with version/date comment
@@ -95,14 +90,8 @@ are thin shims calling them. Model scripts:
- [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); assigns
`epoch="$(date +%s%N)$$"` on its own line and passes
`--build-arg CHECK_EPOCH="$epoch"`
- [ ] `script/cibuild` — cd to repo root, assign `epoch="$(date +%s%N)$$"` on
its own line, then run `docker build --build-arg CHECK_EPOCH="$epoch" .`
(what CI runs). The build arg is mandatory: see the `CHECK_EPOCH` rule in
`REPO_POLICIES.md` for why each element is load-bearing. A bare
`docker build .` fails closed by design.
`script/projectname` (byte-identical across repos)
- [ ] `script/cibuild` — cd to repo root, `docker build .` (what CI runs)
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit`

View File

@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-08-09
last_modified: 2026-08-07
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -60,19 +60,17 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build --build-arg CHECK_EPOCH="$epoch" .`, where
`epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below); the
Gitea workflow calls it. Four further scripts are our own extensions to the
standard: `script/check` runs `script/test`, `script/lint`, and
`script/fmt-check`; `script/precommit` is what the git pre-commit hook runs,
and it calls `script/check`; `script/install-precommit` installs the git
pre-commit hook (the `make hooks` target shims to it); and
`script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
the hook itself. Model scripts are at
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
@@ -92,70 +90,14 @@ style conventions are in separate documents:
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green — which requires
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies
this criterion while its check layers are served from cache, so the build
cannot fail on a branch that is not green. For non-server repos, the
Dockerfile should bring up a development environment and run `make check`. For
server repos, `make check` should run as an early build stage before the final
image is assembled. Dockerfiles install development prerequisites by running
`script/bootstrap` rather than duplicating installs inline; COPY `script/` and
the dependency manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`,
etc.) before running it so the bootstrap layer stays cached until dependencies
change.
- **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
unchanged tree the `RUN make check` layer is served from cache, the suite
never runs, and the build still exits 0. A sub-second `docker build` reporting
success is a cache hit, not a result. The canonical form, in **every** stage
containing a check-running `RUN`:
```dockerfile
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
```
and in both `script/cibuild` and `script/docker`:
```sh
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" .
```
All four elements are load-bearing; none is optional, and each guards a
failure mode that otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`.
- Expand the value into the command. This makes the cache miss contractual
rather than dependent on BuildKit's handling of an unreferenced `ARG`, and
it puts the epoch in the build log. The guard is itself value-keyed, for
the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the
epoch into that layer's description (rendered as
`RUN [ -n "<epoch>" ] || exit 1`) and re-runs it whenever the value
changes. Each stage therefore has two independent invalidation points, and
the guard always precedes the check `RUN`. Keep both: the expansion is
defence in depth, and it is what makes the epoch visible in the build
output.
- The `[ -n ... ]` guard is required: an unset `ARG` is empty, and empty is
a stable cache key, so without it a bare `docker build .` still produces
the false green. Failed steps are never cached, so the guard fails on
every such invocation, loudly. A bare `docker build .` failing is by
design.
- Assign `epoch=` on its own line, never inline in the `--build-arg`
argument: a failing command substitution inside an argument does not trip
`set -e`, so the inline form silently degrades to an empty constant. The
`$$` suffix is required because busybox `date` drops `%N` and exits 0, so
on an alpine host the epoch would degrade to second granularity and
concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving
`go mod download`, `script/bootstrap`, and the pinned toolchain install
cached, so it does not push against the five-minute Docker build ceiling.
Blanket `--no-cache` also works but is wasteful and can blow that ceiling.
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
@@ -177,9 +119,7 @@ style conventions are in separate documents:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make fmt-check
RUN make lint
# Build stage
@@ -193,9 +133,7 @@ style conventions are in separate documents:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
@@ -216,16 +154,6 @@ style conventions are in separate documents:
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.**
The cache-bust turns this no-op `COPY` into a content-cache hit, so an
ordering guarantee established on a cold cache does not automatically
carry over; it has to be re-checked warm. This was re-proved in another
repo in the org that uses the same file-dependency trick (there with a
marker file in place of `go.sum`), and the ordering held. It has **not**
been verified in this repo, which is single-stage and has no lint stage to
order against. Any repo relying on a file-dependency trick for stage
ordering should re-check it warm after adopting the bust rather than
assuming this result transfers.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
@@ -237,28 +165,11 @@ style conventions are in separate documents:
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is
stage-scoped: declaring it only in the lint stage leaves `make test`
frozen at the last cached result. In each stage the guard sits immediately
below the `ARG` so a bare `docker build .` fails instead of reusing the
empty cache key, and the value is expanded into the first check `RUN` so
the cache miss does not rely on BuildKit's unreferenced-`ARG` handling.
Both of those lines reference `$CHECK_EPOCH`, so both are value-keyed:
each stage is invalidated at two independent points. The later `RUN`s in
the same stage need no expansion of their own: they are already
invalidated by their busted parent layer.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs
`docker build --build-arg CHECK_EPOCH="$epoch" .`) on push. The Dockerfile
runs `make check`, so a successful build implies all checks pass — but that
implication holds **only** because of the `CHECK_EPOCH` cache-bust described
above. Without it, an unchanged tree serves the check layer from cache and the
build reports a green it never earned. A bare `docker build .` fails closed by
design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through
`script/cibuild` or `script/docker`. Never accept a `script/cibuild` pass as
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the
check layer, means nothing was executed.
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -354,166 +265,6 @@ style conventions are in separate documents:
commit-pinned via
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
- **`script/bootstrap` in Go repos must install the pinned golangci-lint
whenever the installed version does not match the pin — not merely when the
binary is absent — and must then verify the install took effect by
re-resolving the binary through `PATH`.** The presence test
`if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi` is wrong:
it tests `PATH` presence and never version, so on any already-provisioned
machine the pin is inert and a version bump is a no-op. Meanwhile the
Dockerfile installs unconditionally into a clean image, so CI and local
silently disagree about what the linter even is. Observed consequences: a
local `make check` green while `make docker` rejected the same commit with six
`goconst` findings, and a container linter surfacing thirteen findings the
host run missed. A stale host linter does not merely fail to prove the tree is
clean — it hides findings only the container can see. This is a deliberate
departure from the node handling described above, which uses whatever node is
installed: the linter version is the specific thing being held equal between
host and container, so for it, presence is not enough.
Comparing versions is necessary but **not sufficient**, because the obvious
fix also fails green. `go install` writes to `GOBIN` (or `GOPATH/bin`) while
callers resolve `golangci-lint` through `PATH`. If a different binary
shadows it earlier in `PATH`, the install genuinely succeeds and changes
nothing any caller will ever see: bootstrap prints success and the next
`make lint` still runs the stale linter. That is worse than no fix, because
it converts a known-stale toolchain into one everyone believes is pinned.
The canonical form, placed in `script/bootstrap` after Go itself is present:
```sh
# golangci-lint v2.12.2, 2026-05-06. The ref must resolve to a TAGGED
# release; see the note on untagged pins below.
GOLANGCI_LINT_VERSION="2.12.2"
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# The version golangci-lint reports, resolved the way callers resolve it.
# Prints nothing when the binary is absent, exits non-zero, or prints
# something unparseable: all of those must read as "does not match".
# The trailing `|| true` is required, not tidiness. Under `set -o pipefail`
# a non-zero --version would otherwise propagate out of the pipeline and
# kill the script through `set -e` before the diagnostic below is printed.
golangci_lint_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0
golangci-lint --version 2>/dev/null | head -n 1 |
sed -n 's/.*has version v\{0,1\}\([0-9][0-9.]*\).*/\1/p' || true
}
ensure_golangci_lint() {
if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then
return 0
fi
echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION"
go install "$GOLANGCI_LINT_REF"
# go install writes to GOBIN (or GOPATH/bin); callers resolve through
# PATH. Re-resolve through PATH and assert the install took effect.
# `hash -r` is load-bearing: without it a shell that already resolved
# a stale golangci-lint answers from its own lookup cache, and this
# check false-fails with the shadowing message below.
hash -r 2>/dev/null || true
gcl_got="$(golangci_lint_version)"
if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then
return 0
fi
gcl_bin="$(go env GOBIN)"
[ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin"
gcl_found="$(command -v golangci-lint 2>/dev/null || true)"
echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \
"$gcl_bin, but that is not what callers will get." >&2
case "$gcl_found" in
"")
echo "bootstrap: PATH resolves no golangci-lint at all." \
"Add $gcl_bin to PATH, then re-run bootstrap." >&2
;;
"$gcl_bin"/*)
echo "bootstrap: PATH resolves $gcl_found, inside that same" \
"directory, reporting version ${gcl_got:-unparseable}." \
"Nothing is shadowing it, so the install itself did not" \
"produce the pinned version: check that" \
"GOLANGCI_LINT_VERSION matches GOLANGCI_LINT_REF." >&2
;;
*)
echo "bootstrap: PATH resolves $gcl_found instead, reporting" \
"version ${gcl_got:-unparseable}. Remove that binary or" \
"put $gcl_bin earlier in PATH, then re-run bootstrap." >&2
;;
esac
exit 1
}
# The definitions above are inert on their own; the call site is part of
# the canonical form. In a script/bootstrap that follows the "define all
# functions, then call main" convention, this line belongs inside main()
# next to the other ensure_* steps.
ensure_golangci_lint
```
Four properties are load-bearing; each guards a failure mode that otherwise
fails green:
- **Compare the installed version against the pin**, never test presence.
This is what makes a version bump propagate to machines that already have
some golangci-lint.
- **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the path `go install` wrote to — and assert
`--version` reports the pin. When it does not, fail non-zero and name the
path `command -v` actually found, the version it reports, and the
directory the install wrote to. That is a condition a human has to fix by
hand, so bootstrap must not print success in it. Use `hash -r` first so
the shell does not answer from its own lookup cache. Diagnose the cause
from the resolved path rather than asserting one: only a path **outside**
the install directory is shadowing. When the resolved path is inside it,
nothing is shadowing and telling the operator to delete that binary or
reorder `PATH` sends them after a fault that does not exist.
- **A mis-parse must fall through to reinstall, never to a false match.**
Absent binary, non-zero exit, empty output, and unrecognised output all
yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one.
- **Call it.** Two function definitions with no call site are a silent no-op
that reproduces the original defect exactly: exit 0, nothing installed, no
output, stale linter still resolved.
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
**On the hash-pinning rule.** `@c0d3ddc9cf3faa61a4e378e879ece580256d76e5` is
a commit hash, not a server-mutable version tag, and the go command verifies
the fetched module against the checksum database — the mechanism the
hash-pinning rule at the top of this document already names as acceptable
for Go modules. Note that `go install pkg@version` runs in module-aware mode
ignoring the `go.mod` in the current directory or any parent, so no repo
`go.sum` is consulted for this install; the checksum database is what
verifies it. The linter is a bootstrap prerequisite rather than part of any
repo's module graph, which is why the canonical form installs it by
commit-pinned ref instead of declaring it in `go.mod`.
**The pin must be a tagged release commit.** `GOLANGCI_LINT_VERSION` is a
separate string because the ref is a hash and carries no readable version,
so the two must be updated together. It works out to a clean `2.12.2` only
because that commit is the `v2.12.2` tag commit: the go command resolves a
commit query to a semver tag on that commit when one exists, so the built
binary reports `2.12.2`. Pin to an **untagged** commit and the go command
falls back to a pseudo-version — the binary then reports something like
`2.12.3-0.20260506110758-c0d3ddc9cf3f`, and this comparison cannot express
it: the parser above stops at the first `-`, so the value to match would
have to be the truncated numeric prefix `2.12.3`, which is also the number
of a real future release. Setting `GOLANGCI_LINT_VERSION` to the full string
`--version` prints can never match, and bootstrap would then reinstall on
every run and exit 1 blaming a shadowing binary that does not exist. Keep
pins on tagged releases.
**Verifying a change to this logic requires a negative control run in an
environment where a shadowing binary exists earlier in `PATH` than the
install target.** Without that, the control passes against the naive
compare-then-install form as well and therefore proves nothing. Also check
the mis-parse direction by feeding it unparseable `--version` output and
confirming it reinstalls rather than reporting a match.
**Run those controls against the block as a consuming repo would adopt it**
— pasted into a `script/bootstrap`-shaped file that is then executed — not
by sourcing it and invoking the function yourself. Driving the function
directly tests something the artifact does not do, and it is exactly how a
missing call site passes every control while the adopted snippet does
nothing.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).

View File

@@ -1,20 +1,13 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but
# that only proves anything because CHECK_EPOCH is a fresh nonce on every
# invocation: without it Docker serves the check layer from cache on an
# unchanged tree and the build exits 0 without running the suite.
# script/cibuild: run the CI build. The Dockerfile runs script/check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" .
docker build .
}
main "$@"

View File

@@ -1,9 +1,6 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. The
# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh
# nonce on every invocation; without it a warm cache turns this into a
# green that proves nothing.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,13 +8,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" \
-t "$("$SCRIPT_DIR/projectname")" .
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"