DECIDED: golangci-lint stays unpinned (option B) until the org script/lint lands #4
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Verified state
.golangci.ymlonmainis 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.
config, no
script/, and nogo.modtool directive.make lintruns baregolangci-lint run ./...against whatever happens to be on$PATH.TODO.mdclaims. 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-lintimage (pinned by hash).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.mdFuture Steps note 3). So the repo is caughtbetween 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.mdrequires the lint run stay at 0 issues, so a host linter upgradecan spontaneously turn
mainred without a single commit.Options
A. Add
script/lintthat runs the pinned linter container. A POSIX-shscript/lintinvokesgolangci/golangci-lint@sha256:...(hash-pinned, withthe
# image:version, datecomment) against the repo; thelintMakefiletarget 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 arepo that deliberately has none.
B. Accept the divergence; document it. Record in
TODO.md/MEMORY.mdthat 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.modtool directive. Go 1.24+tooldirectives would letgo tool golangci-lintresolve a module-versioned linter, checksum-verifiedthrough
go.sum. Reproducible and needs no container. Costs: this is notan 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 docsdiscourage 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/lintis a much smaller intrusion than the Dockerfile+CI scaffold youexempted this repo from. Option B leaves
main's green/red status dependenton 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.mdsays v2.12.2 and the task framing references commit
c0d3ddc9cf3faa61a4e378e879ece580256d76e5, but the host has v2.10.1, so I donot want to guess which is intended as the target.
Assigning to you for the call. Not blocking — other work continues meanwhile.
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 --versionreported2.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.
mainhappens to be 0 issues under both, so nothing broke — this time. Butthe canonical config uses
default: all, so the next version that adds alinter can turn
mainred with no commit. That is no longer hypotheticalhere; 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 bothfailure modes:
make lintreturned 399 issues (mnd: 285,nolintlint: 64,testpackage: 11, …) attributed to files under/tmp/rev23/— adirectory that no longer existed. Cached results for a deleted worktree,
reported as if they were mine.
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 1goconst).So a green
make linton this host is not trustworthy unless the cachewas 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):
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.
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 thecross-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 lintreturns cachedfindings 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 thecache 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:
GOLANGCI_LINT_CACHEand retry onparallel golangci-lint is running. Neither alone is sufficient.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.modtool directive) fixes the versionbut leaves both concurrency failure modes exactly as they are.
The durable fix for the lock belongs in tooling — a
script/lintthat retrieson 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.
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/lintcontainer) when I filed this. Iam 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 inTODO.mdFutureSteps note 3. Adding
script/lintputs ascript/directory into a repo thatdeliberately 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
goconstfinding, because the shared cache served results from anothersession'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-onlymechanism now would be work we throw away when that lands.
What this means in practice
.golangci.yml, theMakefile, or the repo layout.TODO.md(#3).export
GOLANGCI_LINT_CACHEto a fresh empty private directory andretry on
parallel golangci-lint is running. Treat any result naming pathsoutside 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 —
mainlints 0 issuesunder 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/lintlands, or when agolangci-lint release actually turns
mainred without a commit. Either makesA 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 (
gomodguarddeprecation) stays with him — that one changes the canonical config shared by
every repo, so it is genuinely not mine to decide.
DECISION: golangci-lint version is unpinned and this repo has no org-blessed place to pin itto DECIDED: golangci-lint stays unpinned (option B) until the org script/lint landsSuperseded and closing. The revisit trigger I set here — "when the org-level
script/lintlands" — has fired: sneak ruled that all linting runs in Docker, and #41 implemented it. The linter is now pinned by digest inDockerfile.lintand there is no hostgolangci-lintpath 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.