DECIDED: golangci-lint stays unpinned (option B) until the org script/lint lands #4

Closed
opened 2026-08-09 03:39:22 +02:00 by clawbot · 4 comments
Collaborator

Verified state

  • .golangci.yml on main is byte-identical to the org canonical config
    — sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.
    Confirmed by hash, not assumption. PR #1 landed it 2026-08-07. No action
    needed here.
  • The linter version is pinned nowhere. There is no Dockerfile, no CI
    config, no script/, and no go.mod tool directive. make lint runs bare
    golangci-lint run ./... against whatever happens to be on $PATH.
  • The host linter is currently v2.10.1, not the v2.12.2 that TODO.md
    claims. The canonical config still reports 0 issues under v2.10.1, so
    nothing is broken today — but the gate is not reproducible.

Why this needs your decision

The org standard defines exactly one mechanism for pinning golangci-lint,
and it is the Dockerfile lint stage. REPO_POLICIES.md:

> Go repos use a multistage build where linting runs in an independent stage
> based on the golangci/golangci-lint image (pinned by hash).

# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint

Governed by the hardest rule in the document:

> ALL external references must be pinned by cryptographic hash. ... Version
> tags are server-mutable and therefore remote code execution vulnerabilities
> ... This is the single most important rule in this document ... There are
> zero exceptions to this rule.

rgoue is explicitly exempt from having a Dockerfile or CI config (your
decision, recorded in TODO.md Future Steps note 3). So the repo is caught
between two of your own rules: it must pin all external references by hash,
and it must not have the only artifact the standard uses to do so. Every way
forward either invents a new mechanism or accepts the divergence — and both
are your call, not mine.

Note also that a floating linter is a live footgun for this repo specifically:
MEMORY.md requires the lint run stay at 0 issues, so a host linter upgrade
can spontaneously turn main red without a single commit.

Options

A. Add script/lint that runs the pinned linter container. A POSIX-sh
script/lint invokes golangci/golangci-lint@sha256:... (hash-pinned, with
the # image:version, date comment) against the repo; the lint Makefile
target becomes a thin shim. Fully satisfies the hash-pinning rule and matches
the org's script-shim shape without adding a Dockerfile or CI. Costs: requires
a container runtime to lint, and introduces a single script/ file into a
repo that deliberately has none.

B. Accept the divergence; document it. Record in TODO.md/MEMORY.md
that this repo intentionally lints against the host linter, and drop the
false "currently v2.12.2" claim (already covered by #3). Zero new machinery,
honest about reality, but the gate stays non-reproducible and the
hash-pinning rule stays violated.

C. Pin via a go.mod tool directive. Go 1.24+ tool directives would let
go tool golangci-lint resolve a module-versioned linter, checksum-verified
through go.sum. Reproducible and needs no container. Costs: this is not
an org-blessed mechanism (it appears nowhere in the standard), it pulls the
linter's full dependency tree into go.mod, and golangci-lint's own docs
discourage module-based installation.

Recommendation

Option A. It is the only choice that actually satisfies the hash-pinning
rule you call the single most important one in the standard, and a lone
script/lint is a much smaller intrusion than the Dockerfile+CI scaffold you
exempted this repo from. Option B leaves main's green/red status dependent
on whichever linter a given machine happens to have, which for a repo whose
policy is "keep the run at 0 issues" will eventually bite.

If you pick A, please also confirm the exact version to pinTODO.md
says v2.12.2 and the task framing references commit
c0d3ddc9cf3faa61a4e378e879ece580256d76e5, but the host has v2.10.1, so I do
not want to guess which is intended as the target.

Assigning to you for the call. Not blocking — other work continues meanwhile.

## Verified state - `.golangci.yml` on `main` is **byte-identical to the org canonical config** — sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. Confirmed by hash, not assumption. PR #1 landed it 2026-08-07. No action needed here. - **The linter version is pinned nowhere.** There is no Dockerfile, no CI config, no `script/`, and no `go.mod` tool directive. `make lint` runs bare `golangci-lint run ./...` against whatever happens to be on `$PATH`. - The host linter is currently **v2.10.1**, not the v2.12.2 that `TODO.md` claims. The canonical config still reports 0 issues under v2.10.1, so nothing is broken today — but the gate is not reproducible. ## Why this needs your decision The org standard defines exactly **one** mechanism for pinning golangci-lint, and it is the Dockerfile lint stage. `REPO_POLICIES.md`: > Go repos use a multistage build where linting runs in an independent stage > based on the `golangci/golangci-lint` image (pinned by hash). ```dockerfile # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint ``` Governed by the hardest rule in the document: > **ALL external references must be pinned by cryptographic hash.** ... Version > tags are server-mutable and therefore remote code execution vulnerabilities > ... This is the single most important rule in this document ... There are > zero exceptions to this rule. **rgoue is explicitly exempt from having a Dockerfile or CI config** (your decision, recorded in `TODO.md` Future Steps note 3). So the repo is caught between two of your own rules: it must pin all external references by hash, and it must not have the only artifact the standard uses to do so. Every way forward either invents a new mechanism or accepts the divergence — and both are your call, not mine. Note also that a floating linter is a live footgun for this repo specifically: `MEMORY.md` requires the lint run stay at 0 issues, so a host linter upgrade can spontaneously turn `main` red without a single commit. ## Options **A. Add `script/lint` that runs the pinned linter container.** A POSIX-sh `script/lint` invokes `golangci/golangci-lint@sha256:...` (hash-pinned, with the `# image:version, date` comment) against the repo; the `lint` Makefile target becomes a thin shim. Fully satisfies the hash-pinning rule and matches the org's script-shim shape without adding a Dockerfile or CI. Costs: requires a container runtime to lint, and introduces a single `script/` file into a repo that deliberately has none. **B. Accept the divergence; document it.** Record in `TODO.md`/`MEMORY.md` that this repo intentionally lints against the host linter, and drop the false "currently v2.12.2" claim (already covered by #3). Zero new machinery, honest about reality, but the gate stays non-reproducible and the hash-pinning rule stays violated. **C. Pin via a `go.mod` tool directive.** Go 1.24+ `tool` directives would let `go tool golangci-lint` resolve a module-versioned linter, checksum-verified through `go.sum`. Reproducible and needs no container. Costs: this is **not** an org-blessed mechanism (it appears nowhere in the standard), it pulls the linter's full dependency tree into `go.mod`, and golangci-lint's own docs discourage module-based installation. ## Recommendation **Option A.** It is the only choice that actually satisfies the hash-pinning rule you call the single most important one in the standard, and a lone `script/lint` is a much smaller intrusion than the Dockerfile+CI scaffold you exempted this repo from. Option B leaves `main`'s green/red status dependent on whichever linter a given machine happens to have, which for a repo whose policy is "keep the run at 0 issues" will eventually bite. If you pick A, please also confirm the **exact version to pin** — `TODO.md` says v2.12.2 and the task framing references commit `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`, but the host has v2.10.1, so I do not want to guess which is intended as the target. Assigning to you for the call. Not blocking — other work continues meanwhile.
sneak was assigned by clawbot 2026-08-09 03:39:22 +02:00
Author
Collaborator

Two new pieces of evidence for this decision, both found while gating PR #23.

1. The host linter changed underneath us mid-session — exactly the failure
this issue predicts.
When I filed this, golangci-lint --version reported
2.10.1. A few hours later, on the same host with no repo change, it
reports 2.12.2. Nothing in the repo pins it, so the gate silently moved.

main happens to be 0 issues under both, so nothing broke — this time. But
the canonical config uses default: all, so the next version that adds a
linter can turn main red with no commit. That is no longer hypothetical
here; only the outcome was lucky.

2. A worse reproducibility problem than version drift: the gate is not
reproducible against itself on one machine.

golangci-lint keeps a single cache at ~/.cache/golangci-lint, and there are
~18 concurrent agent sessions on this host invoking it from throwaway
worktrees under /tmp. While reproducing a lint failure on PR #23 I hit both
failure modes:

  • make lint returned 399 issues (mnd: 285, nolintlint: 64,
    testpackage: 11, …) attributed to files under /tmp/rev23/a
    directory that no longer existed.
    Cached results for a deleted worktree,
    reported as if they were mine.
  • A later run refused outright:
    Error: parallel golangci-lint is running.

Only after clearing the cache and retrying until no run was in flight did I
get the true answer (main: 0 issues; the PR branch: exactly 1 goconst).

So a green make lint on this host is not trustworthy unless the cache
was clean and no concurrent run was active. That is worth knowing regardless
of which option you pick here, and it is an argument for option A
specifically: running the linter in a pinned container gives each invocation
an isolated cache as a side effect, which removes the cross-session
interference along with the version drift.

3. Unrelated deprecation warning, surfaced by 2.12.2 (recording it, not
acting on it — the config is canonical and must not be edited here):

The linter 'gomodguard' is deprecated (since v2.12.0) due to: new major version. Replaced by gomodguard_v2.

This belongs upstream in the shared canonical .golangci.yml, not in rgoue.
Flagging so it can be fixed at the org level; every repo on the canonical
config will be emitting it.

No action taken on any of this — the decision is still yours.

Two new pieces of evidence for this decision, both found while gating PR #23. **1. The host linter changed underneath us mid-session — exactly the failure this issue predicts.** When I filed this, `golangci-lint --version` reported **2.10.1**. A few hours later, on the same host with no repo change, it reports **2.12.2**. Nothing in the repo pins it, so the gate silently moved. `main` happens to be 0 issues under both, so nothing broke — this time. But the canonical config uses `default: all`, so the next version that adds a linter can turn `main` red with no commit. That is no longer hypothetical here; only the *outcome* was lucky. **2. A worse reproducibility problem than version drift: the gate is not reproducible against itself on one machine.** golangci-lint keeps a single cache at `~/.cache/golangci-lint`, and there are ~18 concurrent agent sessions on this host invoking it from throwaway worktrees under `/tmp`. While reproducing a lint failure on PR #23 I hit both failure modes: - `make lint` returned **399 issues** (`mnd: 285`, `nolintlint: 64`, `testpackage: 11`, …) attributed to files under `/tmp/rev23/` — **a directory that no longer existed.** Cached results for a deleted worktree, reported as if they were mine. - A later run refused outright: `Error: parallel golangci-lint is running`. Only after clearing the cache and retrying until no run was in flight did I get the true answer (`main`: 0 issues; the PR branch: exactly 1 `goconst`). So a green `make lint` on this host is **not trustworthy** unless the cache was clean and no concurrent run was active. That is worth knowing regardless of which option you pick here, and it is an argument for option A specifically: running the linter in a pinned container gives each invocation an isolated cache as a side effect, which removes the cross-session interference along with the version drift. **3. Unrelated deprecation warning, surfaced by 2.12.2** (recording it, not acting on it — the config is canonical and must not be edited here): ``` The linter 'gomodguard' is deprecated (since v2.12.0) due to: new major version. Replaced by gomodguard_v2. ``` This belongs upstream in the shared canonical `.golangci.yml`, not in rgoue. Flagging so it can be fixed at the org level; every repo on the canonical config will be emitting it. No action taken on any of this — the decision is still yours.
Author
Collaborator

Correction to my earlier comment on this issue — I was wrong about the
mitigation, and I propagated the error.

I wrote that running with a private GOLANGCI_LINT_CACHE "removes the
cross-session interference along with the version drift", and I put that
guidance into several implementer and reviewer briefs. A private cache fixes
only half the problem.

It does fix contamination — the case where make lint returns cached
findings for another session's deleted worktree, which is how a genuinely red
branch in this repo was once reported green.

It does not fix the lock. A sibling repo ran two concurrent lints from
separate worktrees with entirely separate cache directories and still got
Error: parallel golangci-lint is running. So the lock is not scoped to the
cache directory. My earlier report that isolation removed contention was
almost certainly a quiet window rather than a fix — I ran one lint at a time
and concluded too much from it.

Practical consequence, unchanged in substance but now correctly reasoned:

  • Use a private GOLANGCI_LINT_CACHE and retry on
    parallel golangci-lint is running. Neither alone is sufficient.
  • Treat any result naming paths outside your own worktree as void.

This makes the case for option A slightly stronger rather than weaker: a
pinned lint container gives each invocation its own filesystem, so both halves
— the cache and whatever the lock is anchored to — become per-invocation
rather than per-host. Option C (a go.mod tool directive) fixes the version
but leaves both concurrency failure modes exactly as they are.

The durable fix for the lock belongs in tooling — a script/lint that retries
on that specific error — rather than in every brief restating the rule. That
is being handled at the org level; noting it here so this issue's options
reflect it. Still your decision.

**Correction to my earlier comment on this issue — I was wrong about the mitigation, and I propagated the error.** I wrote that running with a private `GOLANGCI_LINT_CACHE` "removes the cross-session interference along with the version drift", and I put that guidance into several implementer and reviewer briefs. **A private cache fixes only half the problem.** It does fix *contamination* — the case where `make lint` returns cached findings for another session's deleted worktree, which is how a genuinely red branch in this repo was once reported green. It does **not** fix the *lock*. A sibling repo ran two concurrent lints from separate worktrees with entirely separate cache directories and still got `Error: parallel golangci-lint is running`. So the lock is not scoped to the cache directory. My earlier report that isolation removed contention was almost certainly a quiet window rather than a fix — I ran one lint at a time and concluded too much from it. Practical consequence, unchanged in substance but now correctly reasoned: - Use a private `GOLANGCI_LINT_CACHE` **and** retry on `parallel golangci-lint is running`. Neither alone is sufficient. - Treat any result naming paths outside your own worktree as void. This makes the case for option A slightly stronger rather than weaker: a pinned lint container gives each invocation its own filesystem, so both halves — the cache and whatever the lock is anchored to — become per-invocation rather than per-host. Option C (a `go.mod` tool directive) fixes the version but leaves both concurrency failure modes exactly as they are. The durable fix for the lock belongs in tooling — a `script/lint` that retries on that specific error — rather than in every brief restating the rule. That is being handled at the org level; noting it here so this issue's options reflect it. Still your decision.
Author
Collaborator

Decision: Option B for now — accept and document the divergence. Not
option A.
sneak has delegated this, so I am ruling rather than waiting.

I recommended A (a hash-pinned script/lint container) when I filed this. I
am overriding my own recommendation, for three reasons that have emerged
since:

1. A would breach a standing decision, and "no opinion on this issue" is not
authority to reverse one.
The scaffold exemption — no Dockerfile, no CI, no
script/ — is an explicit decision of sneak's recorded in TODO.md Future
Steps note 3. Adding script/lint puts a script/ directory into a repo that
deliberately has none. Delegating a decision is not the same as authorising
the reversal of an earlier one, so I am treating the exemption as binding.

2. A does not actually fix the failure that has bitten this repo. The
concrete harm here was never version drift — it was a false green: an
implementer reported "lint 0 issues" on a branch genuinely red with a
goconst finding, because the shared cache served results from another
session's deleted worktree. A pinned container would have fixed that as a side
effect of filesystem isolation, but so does the private-cache discipline we
now use, at zero structural cost.

3. The durable fix belongs upstream and is already in flight. The
remaining half — the lock, which a private cache does not prevent (I was
wrong about that earlier and corrected it above) — is being addressed at the
org level as a retry inside script/lint. Inventing a parallel rgoue-only
mechanism now would be work we throw away when that lands.

What this means in practice

  • No change to .golangci.yml, the Makefile, or the repo layout.
  • The false version claim is already gone from TODO.md (#3).
  • Standing discipline for anyone linting this repo, now proven necessary:
    export GOLANGCI_LINT_CACHE to a fresh empty private directory and
    retry on parallel golangci-lint is running. Treat any result naming paths
    outside your own worktree as void.

What is knowingly accepted: the hash-pinning rule stays violated for the
linter binary, and the gate is not reproducible across hosts. That is a real
cost and I am not pretending otherwise. It is bounded — main lints 0 issues
under both 2.10.1 and 2.12.2, the only two versions this host has had — and it
is reversible the moment the org mechanism exists.

Revisit trigger: when the org-level script/lint lands, or when a
golangci-lint release actually turns main red without a commit. Either makes
A cheap and obviously correct.

Leaving this issue open as the record of the decision and its trigger, and
unassigning sneak since it no longer needs him. #29 (gomodguard
deprecation) stays with him — that one changes the canonical config shared by
every repo, so it is genuinely not mine to decide.

**Decision: Option B for now — accept and document the divergence. Not option A.** sneak has delegated this, so I am ruling rather than waiting. I recommended A (a hash-pinned `script/lint` container) when I filed this. I am overriding my own recommendation, for three reasons that have emerged since: **1. A would breach a standing decision, and "no opinion on this issue" is not authority to reverse one.** The scaffold exemption — no Dockerfile, no CI, no `script/` — is an explicit decision of sneak's recorded in `TODO.md` Future Steps note 3. Adding `script/lint` puts a `script/` directory into a repo that deliberately has none. Delegating a decision is not the same as authorising the reversal of an earlier one, so I am treating the exemption as binding. **2. A does not actually fix the failure that has bitten this repo.** The concrete harm here was never version drift — it was a **false green**: an implementer reported "lint 0 issues" on a branch genuinely red with a `goconst` finding, because the shared cache served results from another session's deleted worktree. A pinned container would have fixed that as a side effect of filesystem isolation, but so does the private-cache discipline we now use, at zero structural cost. **3. The durable fix belongs upstream and is already in flight.** The remaining half — the lock, which a private cache does **not** prevent (I was wrong about that earlier and corrected it above) — is being addressed at the org level as a retry inside `script/lint`. Inventing a parallel rgoue-only mechanism now would be work we throw away when that lands. **What this means in practice** - No change to `.golangci.yml`, the `Makefile`, or the repo layout. - The false version claim is already gone from `TODO.md` (#3). - Standing discipline for anyone linting this repo, now proven necessary: export `GOLANGCI_LINT_CACHE` to a fresh empty private directory **and** retry on `parallel golangci-lint is running`. Treat any result naming paths outside your own worktree as void. **What is knowingly accepted:** the hash-pinning rule stays violated for the linter binary, and the gate is not reproducible across hosts. That is a real cost and I am not pretending otherwise. It is bounded — `main` lints 0 issues under both 2.10.1 and 2.12.2, the only two versions this host has had — and it is reversible the moment the org mechanism exists. **Revisit trigger:** when the org-level `script/lint` lands, or when a golangci-lint release actually turns `main` red without a commit. Either makes A cheap and obviously correct. Leaving this issue open as the record of the decision and its trigger, and **unassigning sneak** since it no longer needs him. #29 (`gomodguard` deprecation) stays with him — that one changes the canonical config shared by every repo, so it is genuinely not mine to decide.
clawbot changed title from DECISION: golangci-lint version is unpinned and this repo has no org-blessed place to pin it to DECIDED: golangci-lint stays unpinned (option B) until the org script/lint lands 2026-08-09 16:29:35 +02:00
sneak was unassigned by clawbot 2026-08-09 16:29:35 +02:00
Author
Collaborator

Superseded and closing. The revisit trigger I set here — "when the org-level script/lint lands" — has fired: sneak ruled that all linting runs in Docker, and #41 implemented it. The linter is now pinned by digest in Dockerfile.lint and there is no host golangci-lint path left in the repo, so option B's knowingly-accepted cost (hash-pinning violated, gate not reproducible across hosts) is paid off rather than merely tolerated.

Worth recording that my option-B reasoning was wrong on one point, not just overtaken. I argued a pinned container "does not actually fix the failure that has bitten this repo" because the private-cache discipline already covered the false green. The container does more than that: it removed the discipline itself, which was an unenforced convention every future implementer had to remember and which nothing verified.

Superseded and closing. The revisit trigger I set here — "when the org-level `script/lint` lands" — has fired: sneak ruled that all linting runs in Docker, and https://git.eeqj.de/sneak/rgoue/issues/41 implemented it. The linter is now pinned by digest in `Dockerfile.lint` and there is no host `golangci-lint` path left in the repo, so option B's knowingly-accepted cost (hash-pinning violated, gate not reproducible across hosts) is paid off rather than merely tolerated. Worth recording that my option-B reasoning was wrong on one point, not just overtaken. I argued a pinned container "does not actually fix the failure that has bitten this repo" because the private-cache discipline already covered the false green. The container does more than that: it removed the discipline itself, which was an unenforced convention every future implementer had to remember and which nothing verified.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/rgoue#4