GitHub Actions for Go Monorepos, A 2022 Setup
TL;DR — Path-filtered jobs so unrelated services don’t rebuild.
actions/setup-go@v3with built-in module caching.golangci-lint-actionfor lint.go test -race -count=1for 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 ongo.sum - Caches
~/.cache/go-build(build cache) keyed on a hash of.gofiles
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:
-raceenables the data race detector. ~2× slower but catches concurrent-access bugs that nothing else catches. Worth it in CI.-count=1disables test caching. Without it, Go reuses old test results, which makes “did this PR really pass?” ambiguous. In CI, always use-count=1.-timeout=5mupper-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:
- Wed Feb 23: actions/cache @v3 and BuildKit cache integration — for Dockerfile builds and cross-job caching
- Fri Feb 25: matrix builds and parallel test sharding — when your test suite gets big
- Mon Feb 28: deploying Docker images from Actions to staging — the deploy half of CI/CD
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.