Speeding up GitHub Actions lint pipelines for large Go codebases


TL;DR. Lint on a large Go monorepo went from 63 seconds to about 25 seconds on warm cache, with macOS skipped on branches. Five changes: concurrency group, conditional OS matrix, combined cache restore and save, explicit go mod download, and incremental golangci-lint --new-from-rev. None require a self-hosted runner.

A large Go codebase makes the CI lint stage the part developers feel most: every push, on every branch. Lint feedback that takes a minute and a half kills iteration speed and quietly trains people to push less often, which is the opposite of what you want.

Below is the set of changes we landed on a recent engagement to cut lint time on warm cache from around 63 seconds to 20 to 30 seconds, and skip the macOS leg entirely on branches.

The original lint.yml ran on every push, on Ubuntu and macOS in parallel, with three separate actions/cache invocations and a full repo scan. Typical branch push breakdown:

  • macOS lint job: about 150 seconds, almost entirely cache-cold setup and a full module compile.
  • Ubuntu lint job: about 63 seconds on warm cache, dominated by golangci-lint rescanning the entire codebase.
  • Three cache invocations: six HTTP round-trips against the GitHub Actions cache backend.
  • No concurrency control: rapid pushes stacked up and ran to completion.

The macOS leg was the most obviously wasteful. Zero darwin-specific Go files in the codebase, all OS splits in the repo are unix vs windows. Running darwin lint on every branch was paying full price for zero coverage gain.

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

Cancels in-flight runs when a new commit lands on the same branch. Costs nothing, saves a full pipeline run on every rebase or fixup push.

macOS lint is restricted to refs/heads/main. Branch builds run Ubuntu only. Documented rationale in a comment so the next person to touch this knows why.

strategy:
  matrix:
    os: ${{ github.ref == 'refs/heads/main' && fromJSON('["ubuntu-latest","macos-latest"]') || fromJSON('["ubuntu-latest"]') }}

About 150 seconds saved per branch push. The risk window is tiny because main still gets the full matrix.

Three separate actions/cache blocks became one actions/cache/restore and one actions/cache/save. The save step uses if: always() so a lint failure still warms the cache for the next attempt:

- uses: actions/cache/restore@v4
  id: lint-cache
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
      ~/.cache/golangci-lint
    key: lint-${{ runner.os }}-${{ hashFiles('**/go.sum') }}-${{ hashFiles('.golangci.yml') }}

# ... lint runs here ...

- uses: actions/cache/save@v4
  if: always() && steps.lint-cache.outputs.cache-hit != 'true'
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
      ~/.cache/golangci-lint
    key: ${{ steps.lint-cache.outputs.cache-primary-key }}

Six cache HTTP round-trips down to two. On flaky cache backend days, this alone is worth a minute.

We added go mod download as its own step. Slightly redundant with the lint binary’s own download, but it isolates network I/O from the lint timeout. When the Go proxy is slow you get a clear failure with module download in the step name, instead of a golangci-lint run that timed out for unclear reasons.

Biggest single win for branch pushes. golangci-lint supports linting only changes relative to a base ref:

run-lint-incremental:
	golangci-lint run --new-from-rev=$(BASE_REF) --timeout=5m ./...

Branches lint only what changed since divergence from main. Main keeps full lint to catch regressions in unmodified files. The base ref is computed in CI as git merge-base origin/main HEAD.

This stops “lint runs on every file even though I changed two lines” being the bottleneck.

ScenarioBeforeAfter
Branch push, warm cache (Ubuntu)63s20 to 30s
Branch push (macOS)150sskipped
Rapid re-pushfull runcancelled
After lint failure, next runcoldwarm

Across a working day with a dozen pushes, that is roughly thirty minutes back per developer, with no loss in coverage on main.

  • Self-hosted runners with tmpfs caches. Tempting on paper, but operationally heavier than it was worth for the team. Leaving the door open to revisit if cache restore stays the long pole.
  • Splitting lint into multiple parallel jobs. Possible with golangci-lint --concurrency and per-package targets, but the incremental flag covers most of what splitting would have given us.
  • Switching off analyzers. Speeding up CI by reducing checks is the wrong direction. We kept the full set.
  • Branch lint can miss cross-file regressions. Incremental mode flags issues in changed files only. A change that breaks a previously clean file elsewhere will not show until main runs full lint. Acceptable in our experience because main still catches it within minutes of merge, and pre-merge full-lint as a separate job is available for high-risk PRs.
  • Cache key churn. When .golangci.yml changes the cache invalidates. Expected, but worth knowing during config tuning sprints.
  • Concurrency cancellation can hide flakes. A lint that fails intermittently on one commit can be cancelled by the next. We track lint duration and failure rate as separate CI metrics so flakes do not vanish silently.

Most “CI is slow” problems are not in the linter itself. They are in the surrounding pipeline: redundant cache trips, OS matrices that exist out of habit, no concurrency control, and full scans where incremental works. Audit the pipeline before tuning the tool.