Scaling Golang CI by Replacing actions/setup-go

Hacker News Top Tools

Summary

CloudX has open-sourced a replacement for GitHub's actions/setup-go that optimizes caching to speed up Golang CI workflows by up to 69%.

No content available
Original Article
View Cached Full Text

Cached at: 09/16/26, 03:06 PM

# Scaling Golang CI by Replacing actions/setup-go Source: [https://www.cloudx.ai/posts/setup-go](https://www.cloudx.ai/posts/setup-go) We've found a new way to speed up parallel Golang continuous integration workflows by taking advantage of the Golang build cache\. Replacing GitHub's officialactions/setup\-goaction with a drop\-in equivalent cut our test job runtimes by 69%\. We're open\-sourcing[cloudx\-io/setup\-go\(opens in a new tab\)](https://github.com/cloudx-io/setup-go)so you can do the same\. GitHub's officialactions/setup\-gostep makes parallel jobs interfere with each other's performance, and it continuously loads stale cache values\. Backtesting in our monorepo, which has the common situation of a few parallel Golang test jobs \(one for lints, one for tests, one for builds\), suggests that 86% of the work the default action does is completely unnecessary\. If you manage a moderately complex Go project, you can expect similar performance improvements; see[our CI measurement methodology](https://www.cloudx.ai/posts/setup-go#measuring-performance)or just try it for yourself\. ## We Care About Fast CI We've been shipping a lot of new products and features, and the pace at which we do it is actually increasing over time\. This is no accident — we invest heavily in the tools and processes required to make this possible\. At the center of every "software factory" are the test suites and Continuous Integration \(CI\) workflows that ensure code changes won't break in production\. If our tests run reliably, and quickly, on every change, we can build at fantastic speed without worrying about breaking things for our customers\. This is important to us, so we measure and invest in the speed of our CI jobs\. If you push code to a CloudX repository, our goal is that you get a clear answer as to its acceptability — whether it builds, its tests pass, and it abides by our linter rules — within 90 seconds\. Speed can be achieved in a number of ways, but at the end of the day if you want things to be fast you have to make algorithmic improvements\. We're already using[Warp Build\(opens in a new tab\)](https://www.warpbuild.com/)to run our CI jobs on fast, cost\-efficient machines\. As our test suite has scaled with our product surface area, we realized thatactions/setup\-gowas not setting us up for success\. ## Howactions/setup\-gofails for parallel jobs GitHub's[actions/setup\-go\(opens in a new tab\)](https://github.com/actions/setup-go)is the GitHub\-encouraged way to install and run Go in GitHub Actions\. It usesactions/cacheinternally to save and restore the local Go module cache and build cache directories\. In principle, that should make downloaded module source code and build/test artifacts from one job run available to all the subsequent job runs in your repo\. Here's the defaultactions/setup\-gocache key construction: This cache key is woefully incomplete: in a typical product under active development, only a tiny minority of code changes modify the target operating system, architecture, Go version, or`go\.mod`files\. The first time a job computes this hash key, it persists the*final*cache state to the GitHub cache service\. Until the next change that modifies one of those key elements,*every single CI run*will load that first value\. As you change your application, the restored`go build`module archives from this first run weaken — each subsequent`build`does more work from scratch\. The restored`go test`outputs go stale too, so each subsequent job reruns more tests\. CI degrades until you update`go\.mod`\! Moreover, multiple parallel jobs runningactions/setup\-gorace to write different local cache states to the GitHub cache service — different because the final Go cache state on a runner depends both on the source code and on the commands run\. For example, you might run separate lint and test jobs in parallel: Both jobs resolve the same default cache key, then race to write its value\. Suppose the`lint`job finishes first: it saves a value without an updated test cache state\. Subsequent`test`jobs will keep using that stale value until the cache key changes, and therefore re\-run tests unnecessarily\. Linting, building, and testing a codebase are ideal candidates for memoization: their outputs \(linter messages, built binaries, and test results respectively\) should be pure functions of the source code\. You can store outputs and reuse them rather than recomputing them, so long as the inputs haven't changed\. Several parts of the standard Go toolchain save their outputs to the filesystem and check if they can reuse an existing output instead of recomputing a new one from scratch: CacheControlling env variableDefault Linux locationModule cache`GOMODCACHE``$GOPATH/pkg/mod`Build cache`GOCACHE``~/\.cache/go\-build`Test cache`GOCACHE``~/\.cache/go\-build`Go's*module cache*saves time spent downloading source code for your module dependencies, which you can trigger explicitly with`go mod download`but also implicitly with`go build`\. There's nothing mysterious here, just source code organized by the package identifiers in your`go\.mod`: You trigger fresh downloads when you change your`go\.mod`, e\.g\. to add a new dependency or upgrade an existing one\. Go's*build cache*and*test cache*are actually located together in the`GOCACHE`directory and share a general structure\. Both build and test processes hash their full inputs for use as a cache key\. Those hashes are organized into subdirectories by prefix, and used as filenames for the reusable process outputs: Files with the suffix`\-d`are data payloads, and the`\-a`\-suffixed files serve as indexes\. Of course, build and test processes yield different data payloads: - `go build`stores package archives, intermediates that are linked into a final binary\. - `go test`stores`stdout`,`stderr`, and the final exit code of the test execution\. The Go test runner spies on the test process, automatically detects what files it reads, and incorporates their contents as inputs to the cache key\. The principles underlying these tool caches are the same: they maximize hit rates by making keys of*complete but minimal*sets of dependencies, so misses only occur when absolutely necessary\. Whenever there's a miss, the new result is*always*persisted to the cache so future processes can reuse it\. This works brilliantly in a single persistent filesystem, but CI runners don't have the benefit of a single persistent filesystem\. In GitHub Actions, these toolchain caches are smuggled from one ephemeral runner to the next by stowing them in yet another cache — one with very different design priorities\. ## The GitHub Actions cache GitHub's base[actions/cache\(opens in a new tab\)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)just knows keys and filepaths\. You give GitHub's cache service a key of your own design\. If the cache service recognizes the key, it loads the corresponding cached files into your runner; otherwise, it loads nothing\.*If and only if*this primary\-key lookup missed,actions/cachesaves these files to the cache service after your CI job completes\. actions/cacheonly writes a fresh blob to the GitHub Actions cache service if the job succeeds and there was no exact match for that key initially\. Once written, key\-value pairs in the Actions cache are immutable\.Once you write an object to the GitHub cache service under a certain key, that key\-value pair is immutable\. Any subsequent calls that would persist a different value for that key are rejected\. There is nothing wrong with any of that\. Indeed,actions/cacheis indispensable, and it uses[GitHub's cache access restrictions\(opens in a new tab\)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache)to prevent cache poisoning\. The hard part is picking good keys\. ## Improvingsetup\-go cloudx\-io/setup\-gois effectively a drop\-in replacement foractions/setup\-go; here's why we actually prefer it for our web monorepo: - We're happy to pay a premium to keep engineers and coding agents unblocked\. That means parallelizable work*must*run in parallel \(even if this increases billed runner time by repeating setup work\), and we gladly pay a few bucks per month for extra cache space\. - Our build, test, and lint workloads are much faster when they can reuse prior cached values\. If all our tests were wicked fast \(maybe one day they will be\!\) or all our lint rules wimpy, we wouldn't sweat our`GOCACHE`hit rates\. Our main insight is just that the Go toolchain is really, really good; a good CI caching strategy has to preserve that toolchain's most important properties across lots of ephemeral runner instances, while working within GitHub's constraints — i\.e\. still adaptingactions/cache\. Let's revisit the important properties one by one\. > They maximize hit rates by making keys of*complete but minimal*sets of dependencies, so misses only occur when absolutely necessary\. GitHub's default`setup\-go`keying is incomplete because it doesn't capture what a given job actually*does\.*That's why the test and lint jobs in the example above race to write a single, partial cache entry\. cloudx\-io/setup\-gosolves this by making the job identity \(or any arbitrary`cache\-key\-prefix`input\) part of the Actions cache key\. The lint job and test job save and restore separate caches without conflicts\. > Whenever there's a miss, the new result is always persisted to the cache so future processes can reuse it\. GitHub's default`setup\-go`only saves a new cache entry when`go\.mod`changes, even though there's new data written to the runner's local cache directories every time you build or test a new version of your source code\. Instead of discarding that incremental effort,cloudx\-io/setup\-gowrites a cache entry*every single time:*the final element in its key is the GitHub Actions run ID\. The fully\-qualified cache key includes several other elements to encourage prefix\-matching in a git\-aware way: By rendering exact key matches impossible, cloudx\-io/setup\-go ensures every job concludes with a freshly\-written blob in the GitHub Actions cache service\.## Measuring performance Late last year, while we still used the default action, we encountered exactly the race condition discussed above: our parallel`lint`job saved a Go cache without test results, which slowed our`test`jobs from a 76\-second median runtime to an unacceptable 180\-second median\. Remember, this slowdown represents exactly zero value: the jobs slowed down to re\-test logic completely unchanged from the run before\. Eliminating the race by separating caches for our various jobs immediately solved this problem: we introducedcloudx\-io/setup\-go,`test`jobs resumed loading appropriate caches, and the median job runtime fell to 41 seconds, a 69% improvement\. cloudx\-io/setup\-goimmediately cut test job runtimes by 69%\. GitHub Actions test job durations from the CloudX monorepo`main`and feature branches\. Chart legendactions/setup\-gocloudx\-io/setup\-goTest job duration by sequential run index0s45s90s135s180sOct 16, 2025Oct 29, 2025Nov 21, 2025Jan 2, 2026Oct 29, 2025actions/setup\-golint cache displaces test cacheThe parallel lint job wins the shared cache\-key race, saving a build\-cache state that is very stale for tests\. Subsequent test runs repeatedly restore that lint\-shaped cache\. Nov 21, 2025cloudx\-io/setup\-goseparates test caches from lint cachesMedian test runtime falls from 131 seconds to 41 seconds, a 69% reduction\. Even if we lint and test in series,cloudx\-io/setup\-gowould outperform the default because it saves an updated cache state after every run\. Using the GitHub default, the loaded cache grows progressively staler between key changes \(`go\.mod`changes\)\. With our new strategy, the loaded cache is*always*fresh from the run before; the`test`run for a commit only exercises test packages genuinely modified by that commit\. In aggregate, we wait for 86% fewer test packages to run now that we load a fresher cache\. To run the counterfactual comparison on real data, we took a sequence of 4,000 real commits, calculated the action IDs for each snapshot's test packages, and modeled cache\-hit rates under the old and new key constructions\. 86% ofactions/setup\-gotest runs are unnecessary\. Count of test package runs for commits on the CloudX monorepo`main`branch\. Show percentage of total test packages Chart legendactions/setup\-gocloudx\-io/setup\-goTotal test package countCount of test package runs over consecutive commits0400Total test package countCommit index Selected range summary for uncached test package runsGitHub ActionTest package runsactions/setup\-go526,166cloudx\-io/setup\-go−86%71,928All commits\. Data from the 4,011 latest commits in the CloudX monorepo\. Chart displays 25\-commit averages for clarity\. Of course, your mileage will vary \(according to how often you change`go\.mod`\)\. To be transparent, we've seen two downsides to the switch, both because we save so many more cache objects: 1. Initially our cache blobs grew linearly with each run; eventually they grew so large that cache\-load times became a major factor in our overall CI time\. This is an issue present in theactions/setup\-godefault behavior too: Go's cache doesn't prune itself; it grows until you clear it\. We save the cache more often, so it grows faster\. We solved this with automatic pruning\. 2. You may need an expanded GitHub Actions cache capacity\. This is offset by making your jobs faster — runners bill by the minute — but locating the necessary settings in GitHub is a pain\. [cloudx\-io/setup\-go\(opens in a new tab\)](https://github.com/cloudx-io/setup-go)has been stable internally since November of last year\. We hope it saves your team some time, and we look forward to hearing what you think\! [![xkcd 303: Compiling](https://www.cloudx.ai/_next/image?url=%2Fimages%2Fposts%2Fsetup-go%2Fcompiling_4.png&w=640&q=75)\(opens in a new tab\)](https://xkcd.com/303/)Did you read this while waiting for your CI to finish? [Explore careers at CloudX\!](https://www.cloudx.ai/careers)

Similar Articles

Fast Haskell Scripts on GitHub Actions

Lobsters Hottest

The article explains how to speed up running Haskell scripts with Magix on GitHub Actions by caching dependencies and compiled artifacts, reducing full build times from over 100 seconds to near-instant reruns.

Migrating Your GitHub CI to Hugging Face Jobs

Hugging Face Blog

This article provides a step-by-step guide on migrating GitHub Actions CI to run on Hugging Face Jobs, enabling GPU-based test suites and cutting CPU CI time by 30% for projects like Trackio.

Using uvx in GitHub Actions in a cache-friendly way

Simon Willison's Blog

Simon Willison shares a cache-friendly approach to using uvx in GitHub Actions by setting an environment variable and incorporating it into the cache key to avoid repeated PyPI downloads.