Skip to content

Give goTest its own Go build cache - #372

Open
smst-jeff wants to merge 1 commit into
mainfrom
ci/go-test-build-cache
Open

smst-jeff wants to merge 1 commit into
mainfrom
ci/go-test-build-cache

Conversation

@smst-jeff

@smst-jeff smst-jeff commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Every goCI job (goTest, goBuild, govulncheck, codeql-analysis) sets up Go with cache: true and cache-dependency-path: "**/go.sum". setup-go's key is setup-go-<os>-<arch>-<image>-go-<ver>-<go.sum hash>, the same in every job, and a key can only be saved once. govulncheck usually finishes first and saves. Test, build and CodeQL then log:

Failed to save: Unable to reserve cache with key setup-go-Linux-x64-ubuntu24-go-1.27.1-292d…, another job may be creating this cache.

So the test job's -race/coverage build output is never stored. On a "hit" it restores govulncheck's cache, which doesn't include that output, and recompiles from scratch.

Measured on smallstep/inventory across all 205 successful "CI & Upload" runs since 2026-07-10: Run Test Suite has a median of 886 s whether setup-go's cache hit (n=154) or missed (n=51). About 4–7 minutes of that is compiling before the first test result. Jobs that save their own cache show what's possible: inventory's lint goes from 487 s to 39 s, and upload-image's docker-make from 303 s to 8 s.

Change

  • goTest.yml
    • Turns off cache on setup-go.
    • Restores and saves GOCACHE and GOMODCACHE with actions/cache/restore and actions/cache/save (v6.1.0, SHA-pinned). Paths come from go env, so macOS and Windows runners work too.
    • Key: go-test-[<suffix>-]<RUNNER_OS>-<ImageOS>-<RUNNER_ARCH>-<GOVERSION>-<hash of go.sum, go.work.sum>.
      • A prefix restore-key means a go.sum change starts warm, not cold.
      • The suffix goes first, so a job without one can't restore another job's entry through the prefix.
      • ImageOS keeps cgo objects from outliving the system headers they were built against.
      • GOVERSION separates the stable and oldstable matrix entries.
    • Saves only on the default branch (github.ref == refs/heads/<default>) and only when the key didn't match exactly. Feature branches restore main's entry instead of each storing a ~500 MB near-duplicate. inventory's caches are currently 10.53 GB across 80 entries, over the 10 GB repo limit.
    • Runs go clean -testcache before saving. GOCACHE also holds test results, and the default gotestsum command is cacheable. Without this, restored caches would replay (cached) passes for tests whose outcome depends on things Go doesn't track: Docker/testcontainers, the network, the clock. With it, the build output is reused and every test still runs.
    • Adds a cache-key-suffix input. It rejects commas and newlines, which would break the key.
  • goCI.yml: adds test-cache-key-suffix and passes it through. It's needed when a repo calls goCI more than once, for example a separate e2e job. Inside a reusable workflow github.job is always test, so without it two callers would share a key and the first to finish would win, which is the same bug again.
  • AGENTS.md: documents the input and the caching behavior.

The other jobs (goBuild, govulncheck, codeql-analysis) still share setup-go's key with each other. The test job no longer competes for it. Giving those jobs their own caches can be a follow-up.

Things to know

  • Cache footprint. Repos will store a go-test-… entry per Go version alongside their existing setup-go entries, on the default branch only. Go trims GOCACHE entries unused for 5 days. GOMODCACHE isn't trimmed, but it resets when the Go version changes.
  • Freshness. On main, an exact key hit means no new save until go.sum, the Go version or the runner image changes. Dependency and stdlib build output, which is the bulk of the compile, stays valid. Changed in-repo packages and their dependents recompile as usual.

Checks

  • actionlint (the pinned 1.7.11 image, as in ci.yml): 0 errors. Same ignored-error counts as main.
  • zizmor 1.30.1 (--min-severity medium --min-confidence medium, online): no findings. Output is identical to main.

Test plan

Opened as a draft. Callers use @main, so merging changes every goCI caller immediately. Following AGENTS.md:

  • Point a consuming repo's caller at the branch: smallstep/inventory#633 used @ci/go-test-build-cache-trial, which is this branch plus one trial-only commit that lets the cache save from any ref. A feature branch can't otherwise warm a cache.
  • First run misses, then "Expire cached test results" and "Save Go caches" run: 37820990741, 855 MB saved. This ran on a feature branch under the trial commit, not on a default branch.
  • Re-runs hit exactly, compile time drops and the save is skipped: 37823105538, 37825070282, 37827195701. Numbers are in the results comment below.
  • The default-branch-only save condition (github.ref == refs/heads/<default>) itself: not exercised yet. Check the first main run after merge.
  • A feature branch restores main's entry and doesn't save: needs a main entry first, so this also has to wait until after merge.
  • No (cached) test results in gotestsum output: 0 in all four runs, each "DONE 2015 tests".
  • Delete the ci/go-test-build-cache-trial branch and close smallstep/inventory#633 (the consumer never changed @main on its default branch).

🤖 Generated with Claude Code

Every goCI job sets up Go with `cache: true` and the same go.sum-based key,
so only the first job to finish can save it. That is usually govulncheck;
test, build and CodeQL then log "Unable to reserve cache ... another job may
be creating this cache". The test job's -race/coverage build output is never
stored, and it recompiles everything on every run even when the cache "hits".

goTest now turns off setup-go's cache and restores/saves GOCACHE and
GOMODCACHE itself, under a key of its own: an optional suffix, OS, ImageOS,
arch, Go version and the go.sum/go.work.sum hash, with a prefix restore-key so
a go.sum change starts warm. Only the default branch saves, so feature
branches stop filling the cache quota with near-duplicates. Test results are
expired with `go clean -testcache` before saving, so restored caches never
replay a pass for tests that depend on Docker, the network or the clock.

goCI gains a `test-cache-key-suffix` input for repositories that call it more
than once (for example a separate e2e job), so each test job keeps its own
entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@smst-jeff

Copy link
Copy Markdown
Contributor Author

Trial results on smallstep/inventory: the test job is about 66% shorter with a warm cache

Setup. smallstep/inventory#633 pointed ci-upload.yml's goCI call at ci/go-test-build-cache-trial. That branch is this PR plus one commit that lets the cache save from any ref; this PR only saves on the default branch. The trial made four runs: one cold, then three empty commits. All succeeded with 2015 tests each. They're compared with 20 recent inventory branch-push runs on goCI.yml@main, measured the same way. All values are in seconds.

test (stable) job

run cache Setup Go + Restore Run Test Suite compile → first result e2e pkg job total
37820990741 miss → saved 855 MB 17 781 416 328 863
37823105538 exact hit 74 237 116 43 335
37825070282 exact hit 71 192 62 47 281
37827195701 exact hit 72 286 167 42 377
metric warm median (n=3) baseline median (n=20, p25–p75) change baseline min–max
Run Test Suite 237 943 (887–975) −75% 673–1123
compile → first result 116 483 (449–498) −76% 300–646
e2e package 43 416 (391–432) −90% 318–444
job total 335 991 (961–1022) −66% 722–1202

Three runs is a small sample. Even so, the slowest warm run beats the fastest baseline run on every metric.

What the baseline shows about the bug

With setup-go's shared cache today, the baseline's compile median is 469 s on a "hit" (n=21) and 510 s on a miss (n=9). The hit restores govulncheck's ~318 MB cache, which barely helps the -race/coverage build. With this PR's cache, compile drops to 62–167 s.

Behavior checks

  • Key: go-test-Linux-ubuntu24-X64-go1.27.1-<go.sum hash>, the same in all four runs, with restore-key prefix go-test-Linux-ubuntu24-X64-go1.27.1-.
  • Expire and Save: ran on the cold run only (Expire 1 s, Save 31 s). Both were skipped on the exact hits.
  • (cached) package results: 0 in all four runs, so go clean -testcache before saving works.
  • Cold run cost: not slower than the baseline (job total 863 s against a median of 991 s), so the save upload is within the normal spread.
  • No warnings or errors from the new steps.
  • Cache entries: exactly one go-test-* entry was created (896 MB).

Caveats

  • Restore time. Restoring the 855 MB cache takes about 58 s, of which about 52 s is unpacking; pkg/mod is many small files. Setup plus restore is about 72 s against about 22 s before, which gives back about 50 s of the saving.
  • Whole-run wall time for inventory improved less: a median of 892 s against 1114 s (−20%). With fast tests, CodeQL (586–838 s) is now the slowest job that upload-image waits for. That is a caller-side issue, not something this PR changes.
  • Not exercised: the default-branch-only save condition, and a feature branch restoring main's entry. Both need a main run after merge. The test plan above is updated.
  • Staleness. An exact hit never re-saves, so main's entry refreshes only when go.sum, the Go version or the runner image changes. ubuntu-latest moves to Ubuntu 26 on Oct 19, which will cost one cold run per repo.

🤖 Generated with Claude Code

@smst-jeff
smst-jeff marked this pull request as ready for review October 8, 2026 19:59
@smst-jeff
smst-jeff requested a review from a team as a code owner October 8, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant