Scaling Golang CI by Replacing actions/setup-go
Scaling Go CI: Moving Beyond actions/setup-go
By Lukas Schwab and Peter Downs | CloudX Engineering | Sep 16, 2026
We have discovered a method to significantly accelerate parallel Golang continuous integration (CI) workflows by optimizing how we leverage the Golang build cache. By swapping GitHub's standard actions/setup-go with a custom, drop-in replacement, we managed to slash our test job runtimes by 69%.
To help others achieve these gains, we are open-sourcing our solution: cloudx-io/setup-go.
The Problem: Why the Official Action Struggles
While actions/setup-go is the recommended path for most, it introduces performance bottlenecks in parallel environments. Specifically, it causes parallel jobs to interfere with one another and frequently restores outdated stale cache values.
In our monorepo—which typically runs a few parallel Go jobs (linting, testing, and building)—our analysis revealed that 86% of the work performed by the default action is entirely redundant.
Takeaway: If you are managing a Go project of moderate complexity, you can likely expect similar performance boosts.
Our Philosophy: The Pursuit of Fast CI
At CloudX, our velocity is increasing as we ship more features. This isn't accidental; it is the result of heavy investment in our developer tooling. The "software factory" relies on CI workflows to ensure stability.
Our CI Objectives
- Reliable test execution.
- Rapid feedback loops.
- Zero production regressions.
Our internal benchmark is strict: when a developer pushes code, they should receive a definitive answer regarding its acceptability (build status, test results, and linting compliance) within 90 seconds.
To achieve this, we focus on algorithmic improvements. While we already utilize Warp Build for high-performance, cost-effective compute, we realized that actions/setup-go had become a scaling bottleneck.
Technical Analysis: How actions/setup-go Fails
The official action relies on actions/cache to manage the Go module and build caches. Ideally, this allows artifacts from one run to be reused in subsequent runs. However, the cache key construction is the root of the problem:
The Flaw in the Logic
This key is far too static. In a typical development cycle, the OS, architecture, Go version, and go.mod file rarely change. Consequently:
- The first job generates the key and saves the cache.
- Every subsequent run loads that same initial value.
- As the application code evolves, the restored build archives become less relevant, forcing the runner to do more work from scratch.
- Test outputs become stale, leading to unnecessary re-testing.
The Parallel Race Condition
When multiple jobs run in parallel, they compete to write to the same cache key. Because the final state of the Go cache depends on the specific commands executed, the jobs "race" to overwrite the cache.
Example of a problematic workflow:
# Simplified .github/workflows/go.yaml
jobs:
test:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- run: go test ./...
lint:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- uses: golangci/golangci-lint-action@v9
Understanding the Go Toolchain Cache
Linting, building, and testing are essentially pure functions of the source code:
If the input hasn't changed, we should be able to reuse the output (memoization). Go handles this via two primary directories:
| Cache Type | Env Variable | Default Linux Path | Purpose |
|---|---|---|---|
| Module Cache | GOMODCACHE | $GOPATH/pkg/mod | Stores downloaded dependency source code. |
| Build/Test Cache | GOCACHE | ~/.cache/go-build | Stores compiled artifacts and test results. |
Module Cache
The GOMODCACHE simply stores source code organized by package identifiers. You can see this by inspecting the directory:
$ head -5 $(go env GOPATH)/pkg/mod/github.com/peterldowns/pgmigrate@v0.3.1/pgmigrate.go
package pgmigrate
import (
context
database/sql
Build and Test Cache
Both build and test outputs reside in GOCACHE. Go hashes the full set of inputs to create a key, then stores the result in a prefixed subdirectory structure:
$ tree $(go env GOCACHE) | head -6
~/.cache/go-build
├── 00
│ ├── 000131ed61b57fbbb4ad26f5862b2aa
(Visual representation of the hashed directory structure in GOCACHE)