ADR-0001: golangci-lint as Go quality gate

Status

Accepted

Tags

go, linting, ci, code-quality, static-analysis

Decision

Use golangci-lint for all Go backend static analysis. Run it in CI as a non-blocking parallel job, with the migration path to blocking deploys once the baseline is clean.

Why

The Go backend had no automated quality gate. Issues like unchecked errors, deprecated API usage, dead code, and style inconsistencies accumulated silently. golangci-lint runs multiple linters in a single pass with low overhead and integrates directly into GitHub Actions via the official golangci/golangci-lint-action. A non-blocking CI job was chosen first to establish a clean baseline without halting existing deploys.

Enabled linters

LinterPurpose
govetDetects common Go mistakes (misaligned structs, suspicious printf calls)
staticcheckAdvanced static analysis — deprecated APIs (SA1019), dead loops (SA4004), style (QF1012)
unusedFlags unexported symbols never referenced
errcheckEnforces acknowledgment of all error return values
gocriticCode pattern checks — deprecatedComment, ifElseChain, elseif, exitAfterDefer
gofumptStricter gofmt formatter enforced as a linter
gosimpleSimplification suggestions (subset of staticcheck)
errcheck is excluded on _test.go files to avoid noise from test assertions.

Rules for agents

  • Never use defer x.Close() without //nolint:errcheck — defer cannot capture the return value
  • Use _ = fn() for fire-and-forget calls where the error is intentionally ignored (event publish, cache write)
  • Use _, _ = fmt.Sscanf(...) and _, _ = fmt.Fprintf(...) — both return (int, error)
  • Use t.Setenv(key, val) in tests, never os.Setenv + defer os.Unsetenv
  • Deprecated functions must have a blank line before Deprecated: in the godoc comment
  • Collapse else { if cond {} } to else if cond {} — gocritic elseif rule
  • Rewrite long if / else if / else if chains as switch — gocritic ifElseChain rule

Bad pattern (do not generate)

defer resp.Body.Close()          // errcheck: return value ignored
os.Setenv("K", "v")             // test: use t.Setenv instead
defer os.Unsetenv("K")

if a != "" {
    doA()
} else {
    if b != "" {                 // gocritic elseif: use else if
        doB()
    }
}

// Deprecated: use NewFoo instead.  // gocritic: missing blank line before Deprecated

Good pattern

defer resp.Body.Close() //nolint:errcheck

t.Setenv("K", "v")

if a != "" {
    doA()
} else if b != "" {
    doB()
}

// OldFoo does something.
//
// Deprecated: use NewFoo instead.

CI setup

  • Job: lint-go in backend-flyio-deploy.yml
  • Runs in parallel with build/test, non-blocking (no needs: dependency on deploy yet)
  • Uses golangci/golangci-lint-action@v6 pinned to v1.64
  • Config: backend/go/.golangci.yml

Migration path to blocking

Add needs: [lint-go] to the deploy job once the lint baseline is clean and the team has a session or two of experience with the rules.