gocacheprog

gocacheprog is a remote Go build/test cache purpose-built for CI, and a drop-in replacement for
actions/cache when what you're actually caching is GOCACHE/GOPATH build output: precise
commit/PR-scoped reuse, preload before the build starts, and no tarball-shaped cache blobs.
In GitHub Actions it comes down to one setup call and one teardown call:
- run: gocacheprog -github-actions-init "https://gocache.example.com?auth=${{ secrets.GOCACHE_AUTH }}"
- run: go test ./...
- run: gocacheprog -github-actions-done
if: ${{ always() }}
Everything else — commit/PR scoping, preload, daemon lifecycle, native GOCACHE restore/save — is
handled for you with sane defaults. See ADVANCED.md for manual/low-level control
and internals.
Server Setup
You need a running gocacheprog server somewhere your CI can reach — it's the same binary,
started with -http/-https instead of -github-actions-init.
go install github.com/vearutop/gocacheprog@latest
Or download a binary from releases:
wget https://github.com/vearutop/gocacheprog/releases/latest/download/linux_amd64.tar.gz
tar xf linux_amd64.tar.gz
rm linux_amd64.tar.gz
./gocacheprog -http :8080 -auth-token secret-token -max-disk-bytes 2000000000
See ADVANCED.md for running the server via Docker/Docker
Compose, disk/age-based eviction, native GOCACHE storage quotas, and observability endpoints
(/status, /inspect, /clear).
Quick Start
Once you have a server URL, add three steps to your job:
- name: Init gocacheprog
run: |
wget -q -O /tmp/gocacheprog.tar.gz https://github.com/vearutop/gocacheprog/releases/latest/download/linux_amd64.tar.gz
tar xf /tmp/gocacheprog.tar.gz -C /tmp
rm /tmp/gocacheprog.tar.gz
/tmp/gocacheprog -github-actions-init "https://gocache.example.com?auth=${{ secrets.GOCACHE_AUTH }}&build_type=unit"
- name: Test
run: go test ./...
- name: Finalize gocacheprog
if: ${{ always() }}
run: /tmp/gocacheprog -github-actions-done
-github-actions-init takes a single DSN — the remote server URL plus optional query
parameters — and:
- derives commit / PR / base-commit scoping automatically from the GitHub Actions environment
(no
${{ github.event... }} plumbing needed in your YAML)
- canonicalizes repo timestamps (stable cache keys on fresh checkouts)
- preloads the likely-needed cache working set in bulk
- sets
GOCACHEPROG (or GOCACHE, depending on mode — see Modes below) via
$GITHUB_ENV so the rest of the job just runs go normally, quietly: in direct/shim mode
the GOCACHEPROG helper instances that run under go build/go test pass -quiet, so only a
fatal error ever prints — routine cache logging doesn't clutter your test output
-github-actions-done reverses whatever -github-actions-init set up, then prints a final cache
summary: hits/misses/puts, total wall-clock time since -github-actions-init started, and — where
a remote round trip is involved — bytes read/written, how many round trips it took, and time spent
on them. The round trip count is usually much lower than the miss count: misses are batched
through a short barrier before each batch is resolved in a single round trip (see
ADVANCED.md), and those batches themselves now
run concurrently rather than one at a time — a real speedup for shim mode in particular, where one
daemon fields batches from many go invocations over the life of a job.
shim mode: stops the daemon and reports its job-wide cumulative stats, including how many
separate go invocations shared that one daemon session, and forced_closes — a count of shim
clients that hit their close-wait safety timeout instead of a clean shutdown (see
ADVANCED.md).
Normally 0; if it's not, the job still finished, but it's worth investigating why a response
was ever late or lost.
gocache mode: uploads freshly-built cache entries and reports combined restore + save stats
direct mode: no daemon to stop, but each -quiet invocation appends its stats (plus its
parent process's PID and, on Linux, command line) to a small file next to the cache dir, so
-github-actions-done still reports a summary aggregated across invocations if the job ran
go more than once — and, when it did, breaks the count down by parent command, so an
unexpectedly high invocation count (e.g. go tool covdata calls fanned out by -coverprofile)
is traceable straight from the job log
Run it in an if: ${{ always() }} step so it also finalizes on test failure.
See sample-workflow.yml for a minimal example.
DSN Parameters
Only the base URL is required; everything else has a default.
| Parameter |
Default |
Meaning |
auth |
(none) |
bearer token for the remote server (and the local daemon socket in shim mode) |
mode |
shim |
direct, shim, gocache, or local-gocache — see Modes |
cache_dir |
automatic |
local cache / native GOCACHE directory; ~/foo resolves against $HOME |
preload_size |
3000000 |
maps to -max-file-bytes: max size of a single preloaded/cached file |
build_type |
(none) |
maps to -build-type, e.g. unit, race, lint — always prefixed with the repository name (see below) |
canonicalize_timestamps |
. |
repo root to canonicalize before anything else |
skip_canonicalize_timestamps |
false |
skip timestamp canonicalization entirely |
skip_preload |
false |
skip the explicit preload pass entirely (direct/shim only) |
max_cache_bytes |
0 (unlimited) |
local-gocache mode only; total cache dir size limit, enforced by evicting the oldest files on -github-actions-done |
fallback_remote |
false |
local-gocache mode only; fall back to the remote when this build type's local cache is cold — see mode=local-gocache |
Timestamp canonicalization runs against the repo root by default, since fresh CI checkouts almost
always need it for stable cache keys (see ADVANCED.md
for why). Set canonicalize_timestamps=<path> to canonicalize somewhere other than the checkout
root, or skip_canonicalize_timestamps=true to disable it entirely.
build_type is always prefixed with $GITHUB_REPOSITORY (e.g. owner-repo-unit, or just
owner-repo if you don't set one) so that pointing several repositories at the same server keeps
their manifests — and the /inspect//clear admin endpoints — isolated per repository.
Modes
-github-actions-init's mode= parameter picks one of four underlying strategies. Pick based on
how many go invocations your job runs, how large the cache working set is, and whether the
runner has a remote server to talk to at all:
mode=direct
One gocacheprog helper per go invocation, talking to the remote server directly. No background
process, nothing to tear down beyond the (no-op) -github-actions-done call.
Use this for jobs with a single Go invocation per step, e.g. one go test ./... or go build ./...
step and nothing else.
- run: gocacheprog -github-actions-init "https://gocache.example.com?auth=${{ secrets.GOCACHE_AUTH }}&mode=direct"
- run: go test ./...
- run: gocacheprog -github-actions-done
mode=shim (default)
A background daemon starts once, preloads once, and serves every subsequent go invocation in the
job over a local unix socket — GOCACHEPROG just points at the socket.
Use this when a job runs multiple go invocations that should share one warm cache session, e.g.
go generate followed by go vet followed by go test, or golangci-lint alongside go test.
Direct mode would otherwise preload independently (and redundantly) for each invocation.
- run: gocacheprog -github-actions-init "https://gocache.example.com?auth=${{ secrets.GOCACHE_AUTH }}&mode=shim"
- run: go generate ./... && go vet ./... && go test ./...
- run: gocacheprog -github-actions-done
if: ${{ always() }}
mode=gocache
Restores/saves native GOCACHE files directly — no GOCACHEPROG helper runs during the build at
all, so there's no per-lookup protocol overhead.
Use this for large builds with heavy cache lookup traffic, where GOCACHEPROG round-trips (even to
a local daemon) become a measurable fraction of build time. The trade-off is coarser reuse: it
restores/saves whole native cache files rather than individual ActionID results.
- run: gocacheprog -github-actions-init "https://gocache.example.com?auth=${{ secrets.GOCACHE_AUTH }}&mode=gocache"
- run: go test ./...
- run: gocacheprog -github-actions-done
if: ${{ always() }}
mode=local-gocache
Points GOCACHE straight at cache_dir — by default, no remote server involved at all, no
restore, no preload, no upload on -github-actions-done; the remote URL in the DSN is only used if
fallback_remote is also set (see below).
Use this on self-hosted runners with a persistent home directory across jobs, where the cache dir
itself survives between runs on disk: this is the fastest possible option there's no round trip to
pay for, remote or local. -github-actions-init logs the cache dir's current file count/size on
the way in, and -github-actions-done logs it again on the way out, so growth is visible in the
job log.
- run: gocacheprog -github-actions-init "?mode=local-gocache&cache_dir=~/.cache/gocacheprog&max_cache_bytes=5000000000"
- run: go test ./...
- run: gocacheprog -github-actions-done
if: ${{ always() }}
Since the same persistent cache dir can end up shared across several repos/build types on one
self-hosted runner, local-gocache mode also keeps a small gocacheprog.json right in cache_dir:
a per-build_type count/first-used/last-used, so you can tell what's actually been using that
runner's disk. Both -github-actions-init and -github-actions-done print it in full; -done is
also what updates it (it re-reads the file immediately before writing, to keep the race window
small when several jobs finish around the same time on the same runner).
Set max_cache_bytes to cap the cache dir's total size. It's checked only on
-github-actions-done (a single scan of cache_dir covers both the size log and eviction, so
setting it doesn't add a second directory walk): once the dir exceeds the limit, the oldest files
(by mtime, wherever they sit in cache_dir) are deleted first until the total drops strictly below
90% of the limit — e.g. max_cache_bytes=10000000000 (10GB) trims down to just under 9GB, not
exactly 9GB and not exactly 10GB. Trimming to that margin rather than to the limit itself avoids
evicting again on almost every subsequent job once the cache settles near the cap. Deleting
individual files out of a native GOCACHE is always
safe: it's content-addressed, so a missing file just costs a cache miss, never a corruption.
fallback_remote: recovering from a rotated/cold self-hosted runner
Self-hosted runners aren't always as persistent as you'd like — a host can get rotated,
reprovisioned, or simply lose its disk, and the next job to land on it finds cache_dir empty (or
warm for every build type except the one that just landed there). fallback_remote=1 (or true)
lets local-gocache mode fall back to the remote you'd otherwise skip entirely in exactly that
situation, so a cold host doesn't run at full-miss speed until it happens to warm back up on its
own:
- run: gocacheprog -github-actions-init "https://gocache.example.com?auth=${{ secrets.GOCACHE_AUTH }}&mode=local-gocache&cache_dir=~/.cache/gocacheprog&fallback_remote=1"
- run: go test ./...
- run: gocacheprog -github-actions-done
if: ${{ always() }}
On -github-actions-init, if gocacheprog.json has no recorded usage at all for the current
build_type — the same check -github-actions-init already prints on every run — it restores
from the remote into cache_dir before the build starts, exactly like mode=gocache does. If
build_type already has recorded usage, nothing remote happens; this is a no-op layered on top of
plain local-gocache mode, not a permanent switch to talking to a remote every job.
Having paid for that restore, -github-actions-done then uploads back only the files this job
itself produced — anything under cache_dir modified at or after -github-actions-init ran,
excluding what the restore itself just wrote. It does not upload the rest of cache_dir: that's a
large, persistent, cross-build-type cache dir this job merely shares the disk with, not this job's
cache to claim credit for re-uploading.
Embedding
The entire CLI lives in the importable cli package; main.go at the repo root is
just:
func main() {
if err := cli.Main(); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
A third-party binary can call cli.Main() directly from its own main to embed gocacheprog
as-is, or vendor/soft-fork the cli package to change behavior without forking the rest of the
module — internal/... stays off-limits to external importers either way, but cli.Main() is the
whole CLI surface. cli.Main(options ...func(o *cli.Options)) takes functional options; currently
Options.VersionLabel lets an embedding binary append its own identifier to the -version output.
Further Reading
ADVANCED.md covers everything below -github-actions-init/-done:
- manual CLI flags for direct/shim/native modes, for power users who don't want the wrapper
- the full
-h flag reference
- manifest model, compression, eviction, authentication internals
- observability (
/status, /inspect, /clear, logs)
- concurrency notes and known edge cases