golangci-lint setup: a practical baseline
Most golangci-lint setups fall into one of two failure modes. Either the config enables every linter the author could find, and the first run produces hundreds of warnings nobody fixes, or it disables so many checks that the tool becomes a formatting gate in disguise. Neither is a baseline. A baseline is a config you understand, that runs quickly, and that fails CI only on issues you actually care about.
What golangci-lint actually is
golangci-lint is a meta-linter: it runs a collection of individual linters —
go vet, staticcheck, revive, errcheck, and dozens more — behind one command
and one configuration file. That aggregation is the point. Instead of wiring
ten tools into CI separately, you install one binary and configure everything
in .golangci.yml.
A baseline config
Start small. A defensible baseline enables:
- govet — the standard library’s own checker, already run by
go build. - staticcheck — the most maintained set of static analyses for Go; it catches real bugs rather than style preferences.
- errcheck — unchecked errors are the most common Go defect; flag them.
- ineffassign and unused — dead code is code you still have to audit.
Skip the chattier style linters at first. dupl, gocognit, and gocyclo
produce noise that teaches a team to ignore the whole tool. Add them later,
one at a time, if the codebase benefits.
Pin the version. golangci-lint activates new linters in minor releases, and CI results that change because a linter upgraded are useless. Use a pinned binary in CI rather than “latest”.
Running it in CI
Two decisions matter: when to run, and how to fail. Run on pull requests,
not only on main — the point is feedback before merge. Decide how to handle
existing findings before the first CI run. If the project already has a
backlog of warnings, failing on everything makes the check useless; failing
on nothing makes it theater. A pragmatic middle ground is
golangci-lint run --new-from-rev=origin/main, which reports only what a
change introduced.
Set a timeout explicitly. The default can be tight on slow CI runners, and a
fixed --timeout=5m makes failures reproducible instead of flaky.
Keeping the baseline honest
A linter config decays the same way test suites do: people add exceptions for real annoyances, then copy-paste the growing file into new projects. Rules that slow the decay:
- Add every exclusion with a comment explaining the pattern and why it is there.
- Review the config when you upgrade golangci-lint — new default linters should be a decision, not an accident.
- Treat the config as code: it gets reviewed, and it is part of the repo’s quality posture, not a build detail.
Where lint fits in a security workflow
Static analysis is one layer. For a credential-handling tool, the bar is higher: lint catches bugs and style drift, but the supply chain and runtime behavior need separate checks — pinned dependencies, vulnerability scanning, signed releases. A clean lint baseline is the prerequisite that keeps those other tools’ output readable. If the linter output is noise, the vulnerability scanner output will be noise too.
trustless, a zero-dependency Go
CLI for agent credentials, is the kind of codebase where a minimal baseline
pays off: 321 tests with -race and one external module keep the reviewable
surface small, and a small surface keeps lint and audit results meaningful.
Start with ten linters and a pinned version. When every warning you see is one you want to fix, the baseline is working — what is the first rule you would add once the noise is gone?