background-shape
GitHub Actions for Go Monorepos, A 2022 Setup
February 21, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — Path-filtered jobs so unrelated services don’t rebuild. actions/setup-go@v3 with built-in module caching. golangci-lint-action for lint. go test -race -count=1 for tests. Reusable workflow per service. Sub-3-minute CI for a 10-service monorepo.

Shifting gears from databases to CI/CD for the rest of February. The Go services we extracted in January live in a monorepo: one git repo, multiple cmd/<service> and internal/<package> trees. Single-repo, multi-service. Common shape.

GitHub Actions in 2022 is a perfectly good CI for this. The workflow patterns that make it fast aren’t obvious, though, and a naive setup spends 8 minutes on what should take 90 seconds. Here’s the setup I’m running.

The repo layout

.
├── cmd/
│   ├── billing/main.go
│   ├── notifications/main.go
│   └── api-gateway/main.go
├── internal/
│   ├── billing/...
│   ├── notifications/...
│   └── shared/...
├── go.mod
├── go.sum
└── .github/
    └── workflows/
        ├── ci.yml
        └── deploy-billing.yml

One module, multiple binaries. Tests live alongside packages. Standard Go layout.

The CI workflow

One workflow that runs lint + test + build for whichever services were affected by the change:

# .github/workflows/ci.yml
name: ci

on:
  pull_request:
  push:
    branches: [main]

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

jobs:
  changes:
    runs-on: ubuntu-22.04
    outputs:
      billing: ${{ steps.filter.outputs.billing }}
      notifications: ${{ steps.filter.outputs.notifications }}
      shared: ${{ steps.filter.outputs.shared }}
    steps:
      - uses: actions/checkout@v3
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            billing:
              - 'cmd/billing/**'
              - 'internal/billing/**'
            notifications:
              - 'cmd/notifications/**'
              - 'internal/notifications/**'
            shared:
              - 'internal/shared/**'
              - 'go.mod'
              - 'go.sum'

  lint:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v3
        with:
          go-version: '1.17'
          cache: true
      - uses: golangci/golangci-lint-action@v3
        with:
          version: v1.44
          args: --timeout=5m

  test:
    runs-on: ubuntu-22.04
    needs: changes
    if: needs.changes.outputs.billing == 'true' || needs.changes.outputs.notifications == 'true' || needs.changes.outputs.shared == 'true'
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v3
        with:
          go-version: '1.17'
          cache: true
      - run: go test -race -count=1 -timeout=5m ./...

  build-billing:
    runs-on: ubuntu-22.04
    needs: changes
    if: needs.changes.outputs.billing == 'true' || needs.changes.outputs.shared == 'true'
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v3
        with:
          go-version: '1.17'
          cache: true
      - run: go build -trimpath -ldflags="-s -w" -o /tmp/billing ./cmd/billing

  build-notifications:
    runs-on: ubuntu-22.04
    needs: changes
    if: needs.changes.outputs.notifications == 'true' || needs.changes.outputs.shared == 'true'
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v3
        with:
          go-version: '1.17'
          cache: true
      - run: go build -trimpath -ldflags="-s -w" -o /tmp/notifications ./cmd/notifications

Let me walk through what each piece does.

Path-filtering with dorny/paths-filter

The changes job uses dorny/paths-filter@v2 to detect which paths in the diff were touched. Subsequent jobs guard on its outputs. A PR that only touches cmd/billing/** won’t run notifications tests or builds. A PR that touches internal/shared/** runs everything (it could affect any consumer).

This is the single biggest CI-time saver in a monorepo. Without it, every PR re-tests and re-builds every service.

For very large monorepos there are dedicated tools (Bazel, Nx, Turborepo) that compute affected packages from the dependency graph rather than glob patterns. For a 10-service Go monorepo, paths-filter is more than enough.

actions/setup-go@v3 — module + build caching included

actions/setup-go@v3 (released early 2022) has built-in module + build cache. The cache: true flag does:

  • Caches ~/go/pkg/mod (module cache) keyed on go.sum
  • Caches ~/.cache/go-build (build cache) keyed on a hash of .go files

Before this version, you’d actions/cache@v3 manually with the right keys. Now it’s free. Cold-cache CI = ~90 sec; warm-cache = ~25 sec for a typical Go service.

If you need finer control (e.g., separate caches per service), use actions/cache@v3 directly. For most cases, the built-in is what you want.

golangci-lint as a separate fast job

- uses: golangci/golangci-lint-action@v3
  with:
    version: v1.44
    args: --timeout=5m

Lint runs independently and fails fast. Don’t wait for tests to finish to find out you have an unused variable. Costs ~30 seconds; saves 5 minutes of “fix and re-push.”

A .golangci.yml config at the repo root pins which linters run:

linters:
  disable-all: true
  enable:
    - errcheck
    - govet
    - gosimple
    - ineffassign
    - staticcheck
    - unused
    - revive
    - gofmt
    - goimports

run:
  timeout: 5m
  go: '1.17'

Keep the list short. --enable-all is a recipe for noise and bikeshedding.

go test -race -count=1

- run: go test -race -count=1 -timeout=5m ./...

Three flags that matter:

  • -race enables the data race detector. ~2× slower but catches concurrent-access bugs that nothing else catches. Worth it in CI.
  • -count=1 disables test caching. Without it, Go reuses old test results, which makes “did this PR really pass?” ambiguous. In CI, always use -count=1.
  • -timeout=5m upper-bounds test runtime so a hung goroutine doesn’t burn a 6-hour CI minute budget.

For really big test suites, shard with -shard (custom test selection) or use the matrix-builds pattern (Feb 25 post).

concurrency groups — cancel old runs on push

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

Pushed a new commit to a PR while the old commit was still building? Cancel the old one. Saves minutes and money. Every workflow should have this.

Real timings

For our 10-service Go monorepo, after this setup is in place:

Scenario CI duration
PR touching one service, deps unchanged, warm cache ~90 sec
PR touching one service, deps unchanged, cold cache ~3 min
PR touching shared/, warm cache ~4 min (all services rebuild)
Push to main, warm cache ~3 min

The warm-cache numbers are what matter — they’re what 95% of PRs experience.

What this setup doesn’t include (yet)

This is the CI base. Next two posts add:

Common Pitfalls

Running tests against the entire ./... for every PR. That’s the default and it’s slow. Use path filters to scope, or — if you can’t — accept the cost and parallelize.

Forgetting -race. Concurrent Go code without race-detector CI is a ticking bomb. Pay the 2× cost.

Not setting concurrency cancel-in-progress. Every push to a PR runs a fresh CI while the old one is still going. Costs add up.

go test without -count=1 in CI. Test caching is great locally; it’s a footgun in CI when you actually want to know “does this commit pass tests.”

Using actions/checkout@v2. v3 is the current version (released Jan 2022) and handles git history correctly for path filters and certain other actions.

Putting golangci-lint version in the workflow without pinning. A breaking linter release on a Monday morning is not a good way to start the week. Pin version: v1.44.x.

Wrapping Up

This workflow is the boring foundation. Path filters cut the most CI time; cached Go modules + build cache cut most of the rest; lint as a fast parallel job catches the easy bugs early. Next post: GitHub Actions cache + BuildKit registry cache — when you need to share state across runs more cleverly than setup-go alone allows.