Run all linting in Docker via Dockerfile.lint + script/lint #113

Open
opened 2026-08-10 13:14:26 +02:00 by clawbot · 4 comments
Collaborator

Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the script/ entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way.

Reference implementation is sneak/homoicon — copy its shape: a root Dockerfile.lint built FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, which COPYs the repo in and runs golangci-lint run --config .golangci.yml ./... as a build step, with script/lint reduced to building it. Linting as a build step means a successful build IS a clean lint, and it works even where the docker daemon is remote and bind mounts are impossible.

This supersedes the per-worktree cache-isolation approach previously scoped here. The controlled experiment run in this repo showed isolation fixes contamination but NOT lock contention — two concurrent runs with entirely separate cache directories still collided. A container per run has its own cache and its own lock, so the whole class goes away. Caching is explicitly waived by the owner.

Two things to get right, both of which would otherwise ship a false green:

  1. A cached build lints nothing. docker build -f Dockerfile.lint . on an unchanged tree returns success in well under a second having run no linter. Force the lint layers to execute.
  2. golangci-lint config verify fetches its JSON schema over an unpinned live HTTPS call, making lint network-dependent and breaking hash-pinning. Decide deliberately whether to include it.

Also remove the golangci-lint install path from script/bootstrap and the native escape hatch in script/lint — nothing runs on the host any more, which also closes the context-gating problem raised earlier.

Definition of done

  • script/lint runs the linter only in Docker; no host golangci-lint path remains.
  • Two consecutive script/lint runs on an unchanged tree both demonstrably execute the linter.
  • Negative control: introduce a deliberate lint violation, confirm it fails with that specific finding, revert, confirm clean.
  • make check still green.

Canonical tracking issue: sneak/prompts#40

Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the `script/` entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way. Reference implementation is `sneak/homoicon` — copy its shape: a root `Dockerfile.lint` built `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, which COPYs the repo in and runs `golangci-lint run --config .golangci.yml ./...` as a build step, with `script/lint` reduced to building it. Linting as a build step means a successful build IS a clean lint, and it works even where the docker daemon is remote and bind mounts are impossible. This supersedes the per-worktree cache-isolation approach previously scoped here. The controlled experiment run in this repo showed isolation fixes contamination but NOT lock contention — two concurrent runs with entirely separate cache directories still collided. A container per run has its own cache and its own lock, so the whole class goes away. Caching is explicitly waived by the owner. Two things to get right, both of which would otherwise ship a false green: 1. **A cached build lints nothing.** `docker build -f Dockerfile.lint .` on an unchanged tree returns success in well under a second having run no linter. Force the lint layers to execute. 2. **`golangci-lint config verify` fetches its JSON schema over an unpinned live HTTPS call**, making lint network-dependent and breaking hash-pinning. Decide deliberately whether to include it. Also remove the golangci-lint install path from `script/bootstrap` and the native escape hatch in `script/lint` — nothing runs on the host any more, which also closes the context-gating problem raised earlier. ## Definition of done - `script/lint` runs the linter only in Docker; no host golangci-lint path remains. - Two consecutive `script/lint` runs on an unchanged tree both demonstrably execute the linter. - Negative control: introduce a deliberate lint violation, confirm it fails with that specific finding, revert, confirm clean. - `make check` still green. Canonical tracking issue: https://git.eeqj.de/sneak/prompts/issues/40
Author
Collaborator

Implementation requirements. Work lands on next; a next -> main PR is opened from the first commit of this cycle.

Shape

New root Dockerfile.lint, FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 (the Debian-based tag, exactly as the owner's ruling names it — not the -alpine variant currently in Dockerfile). It COPYs the repo in and runs golangci-lint run --config .golangci.yml ./... as a build step. script/lint reduces to building it.

Four things this repo has to get right that the reference does not cover

1. A cached build lints nothing. The homoicon reference has no cache-busting; on an unchanged tree its docker build exits 0 in under a second having run no linter. Do not copy that. Reuse the mechanism this repo already established for exactly this in Dockerfile and script/cibuild: ARG CHECK_EPOCH immediately above the lint RUNs, a RUN [ -n "$CHECK_EPOCH" ] || exit 1 guard, the value expanded into the check command itself, no default value, and script/lint computing epoch="$(date +%s%N)$$" as a bare assignment on its own line (inline in the argument, a failing substitution does not abort under set -eu and yields a constant empty epoch — the exact false green being prevented). The ARG goes below COPY go.mod go.sum / go mod download so dependency layers still cache.

2. The Dockerfile lint stage cannot survive as-is. It runs make lint, which will now shell out to docker build — docker-in-docker, inside a BuildKit step, with no daemon. Recommended resolution: delete the lint stage from Dockerfile entirely (along with ENV VAULTIK_LINT_IN_CONTAINER=1 and the builder's COPY --from=lint /src/go.sum /dev/null), move make fmt-check next to make test in the builder stage, and have script/cibuild build Dockerfile.lint first with a fresh epoch and then Dockerfile with a fresh epoch, failing on either. Rationale: Dockerfile.lint then becomes the single source of truth for the linter version. The alternative — keeping a lint stage that calls golangci-lint directly — reintroduces two independently-bumpable digest pins, which is the drift issue #78 was filed over. If you take the alternative, say why in the PR body. Note the consequence either way: script/docker builds only the product image and so no longer lints; script/cibuild is the gate and script/check runs script/lint. Say that plainly in a comment rather than leaving it to be discovered.

3. golangci-lint config verify fetches its JSON schema over an unpinned live HTTPS call. Recommendation: omit it, and say in Dockerfile.lint why. It makes the lint gate network-dependent on an unpinned remote resource, against the hash-pinning policy, and an upstream outage or an egress-less runner then produces a red that is not a lint verdict — the false-red class this repo has spent several issues eliminating. Include it only on evidence that it runs fully offline at this pinned version; if you test that, test it with the network actually off (docker run --network none) and record the output.

4. script/lint-fix cannot delegate to a build step — a build cannot write fixes back to the host tree. Recommendation: keep it, reimplemented as a bind-mounted docker run against the image reference parsed out of Dockerfile.lint, with a comment stating outright that it is a developer convenience, never a gate, and that it needs a local daemon. It consults no exit code that any gate reads. Deleting it and make lint-fix is acceptable if you prefer — lint-fix is not in the canonical scripts-to-rule-them-all set — but then say so in the PR body.

Deletions

Everything on the host lint path goes: the native escape hatch and its version detection in script/lint, VAULTIK_LINT_IN_CONTAINER in both files, the per-worktree cache directory machinery (cache_home, cache_dir, path_digest, prepare_cache, prune_dead_caches), the lock-retry loop, and script/lint-audit with its run_capture/audit_output plumbing — a container per run has its own cache and its own lock, so neither contamination nor contention exists to audit for. Update script/bootstrap's docker-requirement text, which names "the Dockerfile's lint stage" throughout.

Definition of done

Everything in the issue body, plus:

  • Both traps above demonstrated, not asserted. Two consecutive script/lint runs on an unchanged tree, --progress=plain, showing the lint layer executing (not CACHED) on both.
  • Negative control: introduce a deliberate lint violation, confirm the build fails citing that specific finding, revert, confirm clean.
  • Withheld---build-arg counterfactual: a bare docker build -f Dockerfile.lint . fails on the guard rather than exiting 0.
  • make check and script/cibuild both green, evidence recorded on the PR.
  • TODO.md updated in the same commit; markdown formatted with make fmt.

Do not run docker builder prune — the build cache is shared with other work on this host. Scope any invalidation with --no-cache-filter=<stage>.

Commit title ends (closes #113). Issue #88 and issue #103 describe defects in code this removes; leave them out of the commit message and they will be closed against this work separately.

Implementation requirements. Work lands on `next`; a `next` -> `main` PR is opened from the first commit of this cycle. ## Shape New root `Dockerfile.lint`, `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240` (the Debian-based tag, exactly as the owner's ruling names it — not the `-alpine` variant currently in `Dockerfile`). It COPYs the repo in and runs `golangci-lint run --config .golangci.yml ./...` as a build step. `script/lint` reduces to building it. ## Four things this repo has to get right that the reference does not cover **1. A cached build lints nothing.** The homoicon reference has no cache-busting; on an unchanged tree its `docker build` exits 0 in under a second having run no linter. Do not copy that. Reuse the mechanism this repo already established for exactly this in `Dockerfile` and `script/cibuild`: `ARG CHECK_EPOCH` immediately above the lint `RUN`s, a `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard, the value expanded into the check command itself, no default value, and `script/lint` computing `epoch="$(date +%s%N)$$"` as a bare assignment on its own line (inline in the argument, a failing substitution does not abort under `set -eu` and yields a constant empty epoch — the exact false green being prevented). The `ARG` goes below `COPY go.mod go.sum` / `go mod download` so dependency layers still cache. **2. The `Dockerfile` lint stage cannot survive as-is.** It runs `make lint`, which will now shell out to `docker build` — docker-in-docker, inside a BuildKit step, with no daemon. Recommended resolution: delete the lint stage from `Dockerfile` entirely (along with `ENV VAULTIK_LINT_IN_CONTAINER=1` and the builder's `COPY --from=lint /src/go.sum /dev/null`), move `make fmt-check` next to `make test` in the builder stage, and have `script/cibuild` build `Dockerfile.lint` first with a fresh epoch and then `Dockerfile` with a fresh epoch, failing on either. Rationale: `Dockerfile.lint` then becomes the single source of truth for the linter version. The alternative — keeping a lint stage that calls `golangci-lint` directly — reintroduces two independently-bumpable digest pins, which is the drift [issue #78](https://git.eeqj.de/sneak/vaultik/issues/78) was filed over. If you take the alternative, say why in the PR body. Note the consequence either way: `script/docker` builds only the product image and so no longer lints; `script/cibuild` is the gate and `script/check` runs `script/lint`. Say that plainly in a comment rather than leaving it to be discovered. **3. `golangci-lint config verify` fetches its JSON schema over an unpinned live HTTPS call.** Recommendation: omit it, and say in `Dockerfile.lint` why. It makes the lint gate network-dependent on an unpinned remote resource, against the hash-pinning policy, and an upstream outage or an egress-less runner then produces a red that is not a lint verdict — the false-red class this repo has spent several issues eliminating. Include it only on evidence that it runs fully offline at this pinned version; if you test that, test it with the network actually off (`docker run --network none`) and record the output. **4. `script/lint-fix` cannot delegate to a build step** — a build cannot write fixes back to the host tree. Recommendation: keep it, reimplemented as a bind-mounted `docker run` against the image reference parsed out of `Dockerfile.lint`, with a comment stating outright that it is a developer convenience, never a gate, and that it needs a local daemon. It consults no exit code that any gate reads. Deleting it and `make lint-fix` is acceptable if you prefer — `lint-fix` is not in the canonical scripts-to-rule-them-all set — but then say so in the PR body. ## Deletions Everything on the host lint path goes: the native escape hatch and its version detection in `script/lint`, `VAULTIK_LINT_IN_CONTAINER` in both files, the per-worktree cache directory machinery (`cache_home`, `cache_dir`, `path_digest`, `prepare_cache`, `prune_dead_caches`), the lock-retry loop, and `script/lint-audit` with its `run_capture`/`audit_output` plumbing — a container per run has its own cache and its own lock, so neither contamination nor contention exists to audit for. Update `script/bootstrap`'s docker-requirement text, which names "the Dockerfile's lint stage" throughout. ## Definition of done Everything in the issue body, plus: - Both traps above demonstrated, not asserted. Two consecutive `script/lint` runs on an unchanged tree, `--progress=plain`, showing the lint layer executing (not `CACHED`) on both. - Negative control: introduce a deliberate lint violation, confirm the build fails citing that specific finding, revert, confirm clean. - Withheld-`--build-arg` counterfactual: a bare `docker build -f Dockerfile.lint .` fails on the guard rather than exiting 0. - `make check` and `script/cibuild` both green, evidence recorded on the PR. - `TODO.md` updated in the same commit; markdown formatted with `make fmt`. Do not run `docker builder prune` — the build cache is shared with other work on this host. Scope any invalidation with `--no-cache-filter=<stage>`. Commit title ends ` (closes #113)`. [Issue #88](https://git.eeqj.de/sneak/vaultik/issues/88) and [issue #103](https://git.eeqj.de/sneak/vaultik/issues/103) describe defects in code this removes; leave them out of the commit message and they will be closed against this work separately.
Author
Collaborator

Implementation plan, working on next in a private clone.

New Dockerfile.lint at the repo root, FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 (the Debian tag named by the ruling; digest confirmed pullable on this host). Layout: WORKDIR /src, COPY go.mod go.sum ./, RUN go mod download, COPY . ., then ARG CHECK_EPOCH, RUN [ -n "$CHECK_EPOCH" ] || exit 1, RUN echo "check epoch: ${CHECK_EPOCH}" && golangci-lint run --config .golangci.yml ./.... No default on the ARG; it sits below the module layers so dependency caching survives, and the value is expanded into the lint command itself rather than left to BuildKit's unreferenced-ARG handling.

script/lint reduces to epoch="$(date +%s%N)$$" as a bare assignment on its own line, then docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint ., plus a docker-availability check that fails with a readable message. It will reject arguments rather than silently ignore them (a build step cannot take linter flags); BUILDKIT_PROGRESS=plain is the supported way to see the layers execute, so no flag plumbing is needed.

Point 3 (config verify): omitting it, with the reason written into Dockerfile.lint — it is a live unpinned HTTPS fetch of a JSON schema, so it makes the gate network-dependent and converts an upstream outage or an egress-less runner into a red that is not a lint verdict.

Point 4 (script/lint-fix): keeping it, reimplemented as a bind-mounted docker run against the image reference parsed out of Dockerfile.lint's FROM line, with a header stating outright that it is a developer convenience, never a gate, and that it needs a local daemon.

Point 2 (Dockerfile lint stage): taking the recommended resolution. Delete the lint stage, ENV VAULTIK_LINT_IN_CONTAINER=1 and COPY --from=lint /src/go.sum /dev/null; move make fmt-check beside make test in the builder stage under the existing epoch guard; script/cibuild builds Dockerfile.lint with a fresh epoch and then Dockerfile with a second fresh epoch, failing on either. Dockerfile.lint becomes the single source of truth for the linter version. The consequence — script/docker builds the product image only and no longer lints, script/cibuild is the gate, script/check runs script/lint — gets stated in a comment in script/docker and in README.md, not left to be discovered.

Deletions: the native escape hatch and its version detection, VAULTIK_LINT_IN_CONTAINER in both files, cache_home / cache_dir / path_digest / prepare_cache / prune_dead_caches, the lock-retry loop, and script/lint-audit with its run_capture / audit_output plumbing. script/bootstrap's docker-requirement prose, the Makefile deps comment and the README.md entrypoint text all stop naming "the Dockerfile's lint stage".

Tests: a parse-based guard beside the existing cmd/vaultik/makefile_test.go, asserting the invariants whose loss is silent — ARG CHECK_EPOCH present in Dockerfile.lint with no default and below go mod download, the non-empty guard present, the value expanded into the lint command, script/lint and script/cibuild computing the epoch as a bare assignment and passing it as --build-arg, and no VAULTIK_LINT_IN_CONTAINER or host-lint path left anywhere.

Evidence to be recorded on the PR: two consecutive script/lint runs on an unchanged tree with plain progress, both showing the lint layer executing rather than CACHED; a negative control with a deliberate violation, then reverted; a bare docker build -f Dockerfile.lint . failing on the guard instead of exiting 0; make check and script/cibuild green with output showing the layers ran. No docker builder prune — invalidation is scoped by the epoch alone.

One note for the record rather than a new issue: REPO_POLICIES.md in this repo still mandates the in-Dockerfile lint stage with COPY --from=lint /src/go.sum /dev/null. The ruling supersedes it here; the policy text is org-wide and out of scope for this change, so Dockerfile.lint will carry a comment saying so.

Implementation plan, working on `next` in a private clone. **New `Dockerfile.lint`** at the repo root, `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240` (the Debian tag named by the ruling; digest confirmed pullable on this host). Layout: `WORKDIR /src`, `COPY go.mod go.sum ./`, `RUN go mod download`, `COPY . .`, then `ARG CHECK_EPOCH`, `RUN [ -n "$CHECK_EPOCH" ] || exit 1`, `RUN echo "check epoch: ${CHECK_EPOCH}" && golangci-lint run --config .golangci.yml ./...`. No default on the `ARG`; it sits below the module layers so dependency caching survives, and the value is expanded into the lint command itself rather than left to BuildKit's unreferenced-`ARG` handling. **`script/lint`** reduces to `epoch="$(date +%s%N)$$"` as a bare assignment on its own line, then `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`, plus a docker-availability check that fails with a readable message. It will reject arguments rather than silently ignore them (a build step cannot take linter flags); `BUILDKIT_PROGRESS=plain` is the supported way to see the layers execute, so no flag plumbing is needed. **Point 3 (`config verify`): omitting it**, with the reason written into `Dockerfile.lint` — it is a live unpinned HTTPS fetch of a JSON schema, so it makes the gate network-dependent and converts an upstream outage or an egress-less runner into a red that is not a lint verdict. **Point 4 (`script/lint-fix`): keeping it**, reimplemented as a bind-mounted `docker run` against the image reference parsed out of `Dockerfile.lint`'s `FROM` line, with a header stating outright that it is a developer convenience, never a gate, and that it needs a local daemon. **Point 2 (`Dockerfile` lint stage): taking the recommended resolution.** Delete the lint stage, `ENV VAULTIK_LINT_IN_CONTAINER=1` and `COPY --from=lint /src/go.sum /dev/null`; move `make fmt-check` beside `make test` in the builder stage under the existing epoch guard; `script/cibuild` builds `Dockerfile.lint` with a fresh epoch and then `Dockerfile` with a second fresh epoch, failing on either. `Dockerfile.lint` becomes the single source of truth for the linter version. The consequence — `script/docker` builds the product image only and no longer lints, `script/cibuild` is the gate, `script/check` runs `script/lint` — gets stated in a comment in `script/docker` and in `README.md`, not left to be discovered. **Deletions:** the native escape hatch and its version detection, `VAULTIK_LINT_IN_CONTAINER` in both files, `cache_home` / `cache_dir` / `path_digest` / `prepare_cache` / `prune_dead_caches`, the lock-retry loop, and `script/lint-audit` with its `run_capture` / `audit_output` plumbing. `script/bootstrap`'s docker-requirement prose, the `Makefile` `deps` comment and the `README.md` entrypoint text all stop naming "the Dockerfile's lint stage". **Tests:** a parse-based guard beside the existing `cmd/vaultik/makefile_test.go`, asserting the invariants whose loss is silent — `ARG CHECK_EPOCH` present in `Dockerfile.lint` with no default and below `go mod download`, the non-empty guard present, the value expanded into the lint command, `script/lint` and `script/cibuild` computing the epoch as a bare assignment and passing it as `--build-arg`, and no `VAULTIK_LINT_IN_CONTAINER` or host-lint path left anywhere. **Evidence to be recorded on the PR:** two consecutive `script/lint` runs on an unchanged tree with plain progress, both showing the lint layer executing rather than `CACHED`; a negative control with a deliberate violation, then reverted; a bare `docker build -f Dockerfile.lint .` failing on the guard instead of exiting 0; `make check` and `script/cibuild` green with output showing the layers ran. No `docker builder prune` — invalidation is scoped by the epoch alone. One note for the record rather than a new issue: `REPO_POLICIES.md` in this repo still mandates the in-`Dockerfile` lint stage with `COPY --from=lint /src/go.sum /dev/null`. The ruling supersedes it here; the policy text is org-wide and out of scope for this change, so `Dockerfile.lint` will carry a comment saying so.
Author
Collaborator

Landed on next as commit 739de1e, in
pull request #114 (the
next -> main PR for this cycle, opened from this commit as it is
the first since the last merge to main). Full verification output is
in the PR body.

What was built

New root Dockerfile.lint,
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240,
COPYing the repo in and running
golangci-lint run --config .golangci.yml ./... as a build step.
script/lint reduces to building it. That FROM line is now the only
pin of the linter version anywhere in the repo.

Trap 1 (a cached build lints nothing): ARG CHECK_EPOCH with no
default, below COPY go.mod go.sum / go mod download so dependency
layers still cache, RUN [ -n "$CHECK_EPOCH" ] || exit 1, and the value
expanded into the lint command. script/lint computes
epoch="$(date +%s%N)$$" as a bare assignment on its own line.

Trap 2 (the Dockerfile lint stage): took the recommended resolution.
The lint stage, ENV VAULTIK_LINT_IN_CONTAINER=1 and
COPY --from=lint /src/go.sum /dev/null are gone; make fmt-check
moved beside make test in the builder stage; script/cibuild builds
Dockerfile.lint then Dockerfile, each with its own fresh epoch,
failing on either. The consequence is stated in comments in
script/docker, in the Dockerfile header and in README.md:
script/docker builds the product image only and does not lint, so
script/check and script/cibuild are the gates.

Trap 3 (golangci-lint config verify): omitted, reason written into
Dockerfile.lint. It fetches its JSON schema over an unpinned live
HTTPS call, which puts the gate's verdict at the mercy of a remote
resource outside this repo's hash-pinning discipline and turns an
upstream outage or an egress-less runner into a red that is not a lint
verdict. I did not test it offline, so I am not claiming it fails
offline — I am declining to add a network dependency to a gate. Note
that the config is not unvalidated in practice: golangci-lint run
rejects an unparseable or unknown-key config itself, at the gating
version.

Trap 4 (script/lint-fix): kept, reimplemented as a bind-mounted
docker run against the image reference parsed out of
Dockerfile.lint's FROM line. Its header states outright that it is a
developer convenience, never a gate, that no gate reads its exit status,
and that it needs a local daemon because a remote one cannot see these
files.

Deletions: the native escape hatch and installed_version /
pinned_version / in_lint_container, VAULTIK_LINT_IN_CONTAINER in
both files, cache_home / cache_dir / path_digest /
prepare_cache / prune_dead_caches, the lock-retry loop, and
script/lint-audit with its run_capture / audit_output plumbing.
script/bootstrap's docker-requirement prose, the Makefile deps
comment and README.md no longer name "the Dockerfile's lint stage".
script/lint now takes no arguments and says so, rather than dropping
flags a build step cannot accept.

cmd/vaultik/lintdocker_test.go parses both Dockerfiles and both
scripts and fails if any part of the mechanism is dropped — the digest
pin, the defaultless ARG below go mod download, the guard, the
expansion into each check command, the bare epoch assignment in both
scripts, cibuild building both files, and the absence of any host-lint
escape hatch. Every one of those losses is silent.

How it was verified

  • Two consecutive script/lint runs on a byte-identical tree
    (git status --short captured before and after and compared), plain
    progress: dependency layers CACHED in both, lint layer executing in
    both with different epochs and ~55s of analysis each.
  • Negative control: reintroduced inline error handling in repoRoot;
    the build failed citing
    cmd/vaultik/lintdocker_test.go:328:9: ... (noinlineerr) and
    script/lint exited 1. Reverted, re-ran, 0 issues., exit 0. That
    finding was a real one this gate caught while the test file was being
    written, not a staged one.
  • Withheld --build-arg: bare docker build -f Dockerfile.lint . fails
    at RUN [ -n "$CHECK_EPOCH" ] || exit 1, and does so on both of two
    consecutive attempts (failed steps are never cached), rather than
    exiting 0.
  • The guard test itself demonstrated effective: changing
    ARG CHECK_EPOCH to ARG CHECK_EPOCH=constant makes
    TestLintDockerfileCannotBeCachedGreen fail.
  • make check green in 1m20s, script/cibuild green in 3m0s, both with
    the check layers visibly executing and no (cached) test lines.
  • script/lint-fix smoke-tested: 0 issues., exit 0, tree unchanged.

No docker builder prune at any point; every invalidation was scoped by
CHECK_EPOCH.

Two things flagged rather than fixed: REPO_POLICIES.md still mandates
the in-Dockerfile lint stage, which this supersedes for this repo (the
policy text is org-wide, so it was left alone, with a comment in
Dockerfile.lint pointing at the divergence), and golangci-lint
prints a gomodguard deprecation warning on every run, which is a
.golangci.yml matter and out of scope here.

Landed on `next` as commit `739de1e`, in [pull request #114](https://git.eeqj.de/sneak/vaultik/pulls/114) (the `next` -> `main` PR for this cycle, opened from this commit as it is the first since the last merge to `main`). Full verification output is in the PR body. ## What was built New root `Dockerfile.lint`, `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, COPYing the repo in and running `golangci-lint run --config .golangci.yml ./...` as a build step. `script/lint` reduces to building it. That `FROM` line is now the only pin of the linter version anywhere in the repo. Trap 1 (a cached build lints nothing): `ARG CHECK_EPOCH` with no default, below `COPY go.mod go.sum` / `go mod download` so dependency layers still cache, `RUN [ -n "$CHECK_EPOCH" ] || exit 1`, and the value expanded into the lint command. `script/lint` computes `epoch="$(date +%s%N)$$"` as a bare assignment on its own line. Trap 2 (the `Dockerfile` lint stage): took the recommended resolution. The lint stage, `ENV VAULTIK_LINT_IN_CONTAINER=1` and `COPY --from=lint /src/go.sum /dev/null` are gone; `make fmt-check` moved beside `make test` in the builder stage; `script/cibuild` builds `Dockerfile.lint` then `Dockerfile`, each with its own fresh epoch, failing on either. The consequence is stated in comments in `script/docker`, in the `Dockerfile` header and in `README.md`: `script/docker` builds the product image only and does not lint, so `script/check` and `script/cibuild` are the gates. Trap 3 (`golangci-lint config verify`): **omitted**, reason written into `Dockerfile.lint`. It fetches its JSON schema over an unpinned live HTTPS call, which puts the gate's verdict at the mercy of a remote resource outside this repo's hash-pinning discipline and turns an upstream outage or an egress-less runner into a red that is not a lint verdict. I did not test it offline, so I am not claiming it fails offline — I am declining to add a network dependency to a gate. Note that the config is not unvalidated in practice: `golangci-lint run` rejects an unparseable or unknown-key config itself, at the gating version. Trap 4 (`script/lint-fix`): **kept**, reimplemented as a bind-mounted `docker run` against the image reference parsed out of `Dockerfile.lint`'s `FROM` line. Its header states outright that it is a developer convenience, never a gate, that no gate reads its exit status, and that it needs a local daemon because a remote one cannot see these files. Deletions: the native escape hatch and `installed_version` / `pinned_version` / `in_lint_container`, `VAULTIK_LINT_IN_CONTAINER` in both files, `cache_home` / `cache_dir` / `path_digest` / `prepare_cache` / `prune_dead_caches`, the lock-retry loop, and `script/lint-audit` with its `run_capture` / `audit_output` plumbing. `script/bootstrap`'s docker-requirement prose, the `Makefile` `deps` comment and `README.md` no longer name "the Dockerfile's lint stage". `script/lint` now takes no arguments and says so, rather than dropping flags a build step cannot accept. `cmd/vaultik/lintdocker_test.go` parses both Dockerfiles and both scripts and fails if any part of the mechanism is dropped — the digest pin, the defaultless `ARG` below `go mod download`, the guard, the expansion into each check command, the bare epoch assignment in both scripts, `cibuild` building both files, and the absence of any host-lint escape hatch. Every one of those losses is silent. ## How it was verified - Two consecutive `script/lint` runs on a byte-identical tree (`git status --short` captured before and after and compared), plain progress: dependency layers `CACHED` in both, lint layer executing in both with different epochs and ~55s of analysis each. - Negative control: reintroduced inline error handling in `repoRoot`; the build failed citing `cmd/vaultik/lintdocker_test.go:328:9: ... (noinlineerr)` and `script/lint` exited 1. Reverted, re-ran, `0 issues.`, exit 0. That finding was a real one this gate caught while the test file was being written, not a staged one. - Withheld `--build-arg`: bare `docker build -f Dockerfile.lint .` fails at `RUN [ -n "$CHECK_EPOCH" ] || exit 1`, and does so on both of two consecutive attempts (failed steps are never cached), rather than exiting 0. - The guard test itself demonstrated effective: changing `ARG CHECK_EPOCH` to `ARG CHECK_EPOCH=constant` makes `TestLintDockerfileCannotBeCachedGreen` fail. - `make check` green in 1m20s, `script/cibuild` green in 3m0s, both with the check layers visibly executing and no `(cached)` test lines. - `script/lint-fix` smoke-tested: `0 issues.`, exit 0, tree unchanged. No `docker builder prune` at any point; every invalidation was scoped by `CHECK_EPOCH`. Two things flagged rather than fixed: `REPO_POLICIES.md` still mandates the in-`Dockerfile` lint stage, which this supersedes for this repo (the policy text is org-wide, so it was left alone, with a comment in `Dockerfile.lint` pointing at the divergence), and `golangci-lint` prints a `gomodguard` deprecation warning on every run, which is a `.golangci.yml` matter and out of scope here.
Author
Collaborator

Correction to my earlier comment on this issue, after review failed
pull request #114. next
is now at d257f8f (the same commit, amended).

I wrote that golangci-lint config verify was omitted because it
fetches its JSON schema over an unpinned live HTTPS call, and that
golangci-lint run rejects an unknown-key config itself. Both claims
were false at the pinned version, and I did not test either before
writing them.
The point-3 note in this issue asked for the offline
test as the condition for including it; I did not run it, and declining
on an untested premise was the error.

Reproduced at
golangci/golangci-lint:v2.12.2@sha256:5cceeef0..., network genuinely
off:

$ docker run --rm --network none ... golangci-lint config verify --config .golangci.yml
exit=0

$ docker run --rm --network none ... golangci-lint config verify --config <same file, `linters:` renamed `linterz:`>
jsonschema: "" does not validate with "/additionalProperties": additional properties 'linterz' not allowed
The command is terminated due to an error: the configuration contains invalid elements
exit=3

The schema is embedded; it validates offline and rejects the typo
offline. And golangci-lint run catches YAML it cannot parse but
silently ignores an unknown top-level key, so the omission left a live
false green in the gate's own config: with one planted over-length
comment line and nothing else changed, script/lint exited 1 naming a
revive finding under linters: and exited 0 with 0 issues. under
linterz:, in a run whose lint layer executed for 49s. default: all,
the disable list and every threshold were silently discarded.

config verify now runs as its own CHECK_EPOCH-keyed layer above the
lint, and the comment block in Dockerfile.lint records what was
measured rather than the retracted rationale. The other rework item was
the host-lint guard test, which asserted the absence of one retired
variable name and so could never fire again; it now asserts the property
structurally — no script invokes golangci-lint except through
docker — and was mutation-proved against a renamed escape hatch.

Full verbatim evidence for both is on
pull request #114.
make check and script/cibuild are green with the check layers
demonstrably executing.

Correction to my earlier comment on this issue, after review failed [pull request #114](https://git.eeqj.de/sneak/vaultik/pulls/114). `next` is now at `d257f8f` (the same commit, amended). I wrote that `golangci-lint config verify` was **omitted** because it fetches its JSON schema over an unpinned live HTTPS call, and that `golangci-lint run` rejects an unknown-key config itself. **Both claims were false at the pinned version, and I did not test either before writing them.** The point-3 note in this issue asked for the offline test as the condition for including it; I did not run it, and declining on an untested premise was the error. Reproduced at `golangci/golangci-lint:v2.12.2@sha256:5cceeef0...`, network genuinely off: ``` $ docker run --rm --network none ... golangci-lint config verify --config .golangci.yml exit=0 $ docker run --rm --network none ... golangci-lint config verify --config <same file, `linters:` renamed `linterz:`> jsonschema: "" does not validate with "/additionalProperties": additional properties 'linterz' not allowed The command is terminated due to an error: the configuration contains invalid elements exit=3 ``` The schema is embedded; it validates offline and rejects the typo offline. And `golangci-lint run` catches YAML it cannot *parse* but silently ignores an unknown top-level *key*, so the omission left a live false green in the gate's own config: with one planted over-length comment line and nothing else changed, `script/lint` exited 1 naming a `revive` finding under `linters:` and exited 0 with `0 issues.` under `linterz:`, in a run whose lint layer executed for 49s. `default: all`, the disable list and every threshold were silently discarded. `config verify` now runs as its own `CHECK_EPOCH`-keyed layer above the lint, and the comment block in `Dockerfile.lint` records what was measured rather than the retracted rationale. The other rework item was the host-lint guard test, which asserted the absence of one retired variable name and so could never fire again; it now asserts the property structurally — no script invokes `golangci-lint` except through `docker` — and was mutation-proved against a renamed escape hatch. Full verbatim evidence for both is on [pull request #114](https://git.eeqj.de/sneak/vaultik/pulls/114). `make check` and `script/cibuild` are green with the check layers demonstrably executing.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/vaultik#113