Carry the Go cache between runners

A runner lives one job, so no Go cache was ever restored: 142 Go-gate
minutes in 48 hours were 31 downloading modules and 69 compiling, and a
warm pass compiled commerce's vet in 15s against 198s cold.

The cache lives in the org's own bucket behind cloud's /v1/s3, reached
with the IAM token the KMS step already holds, so the server keeps one
org out of another's and CI holds no bucket key. Go trusts a restored
cache without re-hashing it, so only the default branch and tags write.

setup-go's own cache is off: it is GitHub's store, and the forge has none.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
This commit is contained in:
2026-09-24 10:12:11 -07:00
parent 09c0fad22f
commit 5d6d7858e4
4 changed files with 325 additions and 16 deletions
+34 -16
View File
@@ -119,21 +119,18 @@ on:
type: string
default: 'true'
go-cache:
# OFF by default, and that default is the whole point: this workflow is
# the shared one, and turning Go caching on for ~2700 repos at once grows
# a per-runner cache that is EPHEMERAL and NOT on the runner PVC
# (/root/.cache/actcache, while the claim mounts at /data). Measured
# today: 3.4 GB on git-runner-0, 5.8 GB on git-runner-3, already. The
# runner config records a previous disk exhaustion as "infrastructure
# damage wearing a build defect's clothes", so the fleet does not opt in
# by accident — a repo that wants it says so.
#
# A repo that DOES want it: hanzoai/cloud's gate builds 122 app binaries
# to regenerate their documents and takes 18-27 minutes cold, which is
# wide enough that most runs are cancelled before they finish.
description: "Cache Go modules and build output between runs (setup-go)."
description: >-
Carry GOMODCACHE and GOCACHE between runs through this org's own
bucket in cloud's object store (bin/gocache). ON by default: a runner
is a pod that lives one job, so without it every Go gate downloads
every module and compiles every dependency from nothing. Measured, not
one Go cache was restored across 188 job logs, and a warm second pass
compiled hanzoai/commerce's vet in 15s instead of 198s. The cache is
keyed by go.sum, Go version and architecture, isolated per org by the
server, and written only from the default branch or a tag. 'false'
turns it off.
required: false
default: "false"
default: "true"
type: string
spec-ref:
# THE SECOND WAY INTO `move`, AND ON HANZO GIT THE ONLY ONE.
@@ -389,7 +386,7 @@ jobs:
# Naming the list here also makes the release order self-announcing: a
# pipeline that has grown a tool refuses at step one, and says which ref
# it read and what was missing from it.
need="certclaims conflictmarkers gover ignoretracked imgplat imgtags imgver modsize publishable site vendormark"
need="certclaims conflictmarkers gocache gover ignoretracked imgplat imgtags imgver modsize publishable site vendormark"
miss='<no host answered>'
# A sha cannot be a --branch, so it arrives by fetch rather than clone.
# Both are wrapped so a host that does not carry the ref is a return
@@ -1183,7 +1180,19 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: ${{ steps.gomod.outputs.file }}
cache: ${{ inputs.go-cache == 'true' }}
# GitHub's cache service is a store off our infrastructure, and the
# forge has none. The cache is bin/gocache's, in the next step.
cache: false
- name: Restore the Go cache (this org's store)
# Before the first gate, so `go vet` and `go test` start from this org's
# modules and compiled dependencies instead of the network. A miss, a
# refusal or an outage is a cold build and never a red one; the script
# says which on the log.
id: gocache
if: steps.gomod.outputs.file != '' && env.HANZO_CI_ROLE == 'test' && inputs.go-cache != 'false'
run: |
"$CI_HOME/bin/gocache" restore "$(dirname '${{ steps.gomod.outputs.file }}')"
- name: Provision C toolchain (cgo test gates)
# CGO_ENABLED=1 gates (e.g. go-sqlite3, which bundles the sqlite
@@ -1836,6 +1845,15 @@ jobs:
printf '%s\t%s\t%s\n' "$n" "$claims" "$name" >> "$receipt"
done
- name: Save the Go cache (this org's store)
# After a green gate, so what is saved is what a passing build compiled.
# bin/gocache writes only from the default branch or a tag and only a key
# the store does not hold: a pull request reads this org's cache and
# never writes it, because Go trusts a cache without re-hashing it.
if: steps.gomod.outputs.file != '' && env.HANZO_CI_ROLE == 'test' && inputs.go-cache != 'false' && steps.gocache.outputs.hit != 'exact'
run: |
"$CI_HOME/bin/gocache" save "$(dirname '${{ steps.gomod.outputs.file }}')"
- name: Tests not run (declared by caller)
# The complement of the step above, and the reason opting out cannot be
# quiet. `tests: 'false'` asserts THIS EXACT COMMIT was gated before this
Executable
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# gocache — this org's Go module and build cache, carried between runners.
#
# gocache restore <module-dir> # prints hit=exact|partial|miss to $GITHUB_OUTPUT
# gocache save <module-dir> # writes on a trusted ref, on a miss, and nowhere else
#
# A runner is a pod that lives for one job, so a cache on its disk dies with it.
# Measured across 188 job logs: not one Go cache was restored, and 142 Go-gate
# minutes were 31 downloading modules, 69 compiling and 41 testing. A second
# pass over a warm cache compiled hanzoai/commerce's vet in 15s instead of 198s.
#
# WHERE IT LIVES: the org's own bucket in cloud's object store (/v1/s3), reached
# with the IAM identity the KMS step already minted (HANZO_API_TOKEN). cloud
# derives the bucket's physical name from that identity's org, so one org can
# never read or write another's cache: the tenancy boundary is the server's, not
# a prefix chosen here. CI holds no bucket credential. Every object moves over a
# URL cloud presigns for that one key, for five minutes.
#
# THE KEY: <owner/repo>/<go version>-<arch>/<sha256 of go.sum>. Modules are a
# function of go.sum, and a build cache is only valid for the toolchain and
# architecture that wrote it. A key that misses restores the newest key the repo
# saved (`latest`), so a go.sum bump still reuses every module that did not move.
#
# ONLY A TRUSTED REF WRITES. Go trusts GOMODCACHE and GOCACHE without re-hashing
# them, so a cache is code. A pull request reads and never writes; the default
# branch and tags write, and only on a miss, so a key names one archive for good.
#
# PARTS: the store's public name sits behind Cloudflare, which refuses a request
# body over 100 MB, so an archive travels as 90 MiB parts listed in an index.
#
# BEST EFFORT: every failure is a notice and exit 0. A cache that cannot be read
# is a cold build, never a red one.
#
# ENV: HANZO_API_TOKEN the org's IAM bearer (the KMS step exports it)
# HANZO_API https://api.hanzo.ai
# GOCACHE_BUCKET ci-cache
# GOCACHE_PART 94371840 (90 MiB), the size of one uploaded part
set -uo pipefail
cmd="${1:-}"; dir="${2:-.}"
base="${HANZO_API:-https://api.hanzo.ai}"
bucket="${GOCACHE_BUCKET:-ci-cache}"
part="${GOCACHE_PART:-94371840}"
out="${GITHUB_OUTPUT:-/dev/null}"
say() { echo "gocache: $*"; }
skip() { echo "::notice::gocache: $*"; echo "hit=miss" >> "$out"; exit 0; }
case "$cmd" in restore|save) ;; *) echo "usage: gocache restore|save <module-dir>" >&2; exit 2 ;; esac
[ -n "${HANZO_API_TOKEN:-}" ] || skip "no HANZO_API_TOKEN (the KMS login did not run), so there is no org store to use"
for t in go zstd tar curl jq sha256sum; do
command -v "$t" >/dev/null 2>&1 || skip "$t is not on this runner"
done
[ -f "$dir/go.sum" ] || skip "$dir has no go.sum"
repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY}"
mod=$(go env GOMODCACHE); build=$(go env GOCACHE)
prefix="$repo/$(go env GOVERSION)-$(go env GOARCH)"
sum=$(sha256sum "$dir/go.sum" | cut -c1-32)
tmp="${RUNNER_TEMP:-$(mktemp -d)}/gocache"; rm -rf "$tmp"; mkdir -p "$tmp"
# api <METHOD> <path> [json] -> body on stdout when 2xx. A lost answer, a 429 or
# a 5xx is asked again; anything else is the store answering.
# Parts move four at a time, so each call answers into a file of its own.
api() {
local code w f
f=$(mktemp -p "$tmp" api.XXXXXX)
for w in 0 2 5; do
[ "$w" = 0 ] || sleep "$w"
code=$(curl -sS -m 30 -o "$f" -w '%{http_code}' -X "$1" "$base$2" \
-H "Authorization: Bearer $HANZO_API_TOKEN" -H 'Content-Type: application/json' \
${3:+-d "$3"}) || code=000
case "$code" in 2*) cat "$f"; rm -f "$f"; return 0 ;; 000|429|5*) continue ;; *) break ;; esac
done
echo "HTTP $code $(head -c 200 "$f")" >&2
rm -f "$f"
return 1
}
# get <key> <file>: the object's bytes, or non-zero when it is not there.
get() {
local url
url=$(api GET "/v1/s3/buckets/$bucket/objects/$1" | jq -r '.url // empty') && [ -n "$url" ] || return 1
curl -fsS -m 600 -o "$2" "$url"
}
# put <key> <file>: presigned just before it is spent, since it lives five minutes.
put() {
local url
url=$(api POST "/v1/s3/buckets/$bucket/objects" "$(jq -nc --arg k "$1" '{key:$k}')" | jq -r '.url // empty') \
&& [ -n "$url" ] || return 1
curl -fsS -m 600 -o /dev/null -T "$2" "$url"
}
# fetch <sum>: download and verify the archive an index names; leaves the parts in $tmp.
fetch() {
local idx="$prefix/$1.idx" hash n i
get "$idx" "$tmp/idx" || return 1
read -r hash n < "$tmp/idx"
case "$n" in ''|*[!0-9]*) return 1 ;; esac
for i in $(seq 0 $((n - 1))); do printf '%03d\n' "$i"; done \
| xargs -P 4 -I{} bash -c 'get "$0.{}" "$1.{}"' "$prefix/$1.tar.zst" "$tmp/part" || return 1
[ "$(cat "$tmp"/part.* | sha256sum | cut -d' ' -f1)" = "$hash" ] || { say "archive $1 does not match its index"; return 1; }
}
if [ "$cmd" = restore ]; then
export -f get api; export base bucket tmp HANZO_API_TOKEN
t0=$(date +%s)
hit=exact
if ! fetch "$sum"; then
hit=partial
rm -f "$tmp"/part.*
get "$prefix/latest" "$tmp/latest" && last=$(tr -dc 'a-f0-9' < "$tmp/latest") \
&& [ -n "$last" ] && [ "$last" != "$sum" ] && fetch "$last" || {
say "nothing saved for $prefix yet"; echo "hit=miss" >> "$out"; exit 0; }
fi
mkdir -p "$mod" "$build"
cat "$tmp"/part.* | zstd -dq -T0 | tar -x --no-same-owner -C "$tmp" || skip "the archive did not unpack"
# Moved rather than copied: the pod is fresh, so both targets are empty and the
# rename is instant on one filesystem.
for d in mod build; do
t=$mod; [ "$d" = build ] && t=$build
rmdir "$t" 2>/dev/null && mv "$tmp/$d" "$t" || { cp -a "$tmp/$d/." "$t/"; chmod -R u+w "$tmp/$d"; }
done
bytes=$(cat "$tmp"/part.* | wc -c); rm -rf "$tmp"
say "$hit hit for $prefix: $((bytes / 1048576)) MiB in $(( $(date +%s) - t0 ))s"
echo "hit=$hit" >> "$out"
exit 0
fi
# save: a trusted ref, and only when the exact key is not already there.
case "${GITHUB_EVENT_NAME:-}" in pull_request*) say "a pull request reads the cache and never writes it"; exit 0 ;; esac
default=$(jq -r '.repository.default_branch // empty' "${GITHUB_EVENT_PATH:-/dev/null}" 2>/dev/null || true)
if [ "${GITHUB_REF_TYPE:-}" != tag ] && { [ -z "$default" ] || [ "${GITHUB_REF_NAME:-}" != "$default" ]; }; then
say "${GITHUB_REF_NAME:-this ref} is not the default branch or a tag, so it does not write"; exit 0
fi
get "$prefix/$sum.idx" "$tmp/have" 2>/dev/null && { say "$prefix/$sum is already saved"; exit 0; }
[ -d "$mod" ] || { say "no module cache to save"; exit 0; }
mkdir -p "$build"
t0=$(date +%s)
api POST /v1/s3/buckets "{\"name\":\"$bucket\"}" >/dev/null 2>&1 || true # 201 made, 409 already there
ln -s "$mod" "$tmp/mod"; ln -s "$build" "$tmp/build"
tar -c -h -C "$tmp" mod build 2>/dev/null | zstd -q -T0 | split -b "$part" -d -a 3 - "$tmp/part." \
|| { say "could not archive the cache"; exit 0; }
n=$(ls "$tmp"/part.* | wc -l)
hash=$(cat "$tmp"/part.* | sha256sum | cut -d' ' -f1)
bytes=$(cat "$tmp"/part.* | wc -c)
export -f put api; export base bucket tmp HANZO_API_TOKEN
ls "$tmp"/part.* | xargs -P 4 -I{} bash -c 'put "$0.${1##*.}" "$1"' "$prefix/$sum.tar.zst" {} \
|| { say "a part did not upload; nothing is indexed, so nothing half-written is ever read"; exit 0; }
# The index goes last, so a reader never finds a key whose parts are not all there.
printf '%s %s\n' "$hash" "$n" > "$tmp/idx"; printf '%s\n' "$sum" > "$tmp/latest"
put "$prefix/$sum.idx" "$tmp/idx" && put "$prefix/latest" "$tmp/latest" || { say "the index did not upload"; exit 0; }
say "saved $prefix/$sum: $((bytes / 1048576)) MiB in $n part(s), $(( $(date +%s) - t0 ))s"
rm -rf "$tmp"
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Tests for bin/gocache. Offline: stub `curl` plays cloud's /v1/s3 (presign) and
# the store behind it, stub `go` names the caches, stub `zstd` is `cat`. What is
# tested is the protocol — who may write, what a reader trusts, and that a
# restored cache is byte-for-byte the saved one — not the network.
# Run: bash bin/gocache_test.sh
set -uo pipefail
cd "$(dirname "$0")/.."
GOCACHE_BIN="$PWD/bin/gocache"
fail=0
T=$(mktemp -d); trap 'chmod -R u+w "$T"; rm -rf "$T"' EXIT
mkdir -p "$T/stub" "$T/store" "$T/repo"
cat > "$T/stub/curl" <<'EOF'
#!/usr/bin/env bash
# The API answers presigned store:// URLs; the store is a directory.
o= w= m=GET d= up= url= auth=
while [ $# -gt 0 ]; do
case "$1" in
-o) o=$2; shift ;; -w) w=$2; shift ;; -X) m=$2; shift ;; -d) d=$2; shift ;;
-T) up=$2; shift ;; -H) case "$2" in Authorization:*) auth=$2 ;; esac; shift ;;
-m) shift ;; -*) ;; *) url=$1 ;;
esac
shift
done
code() { [ -n "$w" ] && printf '%s' "$1"; }
case "$url" in
store://*)
k=${url#store://}
if [ -n "$up" ]; then mkdir -p "$STORE/$(dirname "$k")"; cp "$up" "$STORE/$k"; echo "$k" >> "$STORE.puts"; exit 0; fi
[ -f "$STORE/$k" ] || exit 22
cp "$STORE/$k" "$o"; exit 0 ;;
http://api.test/v1/s3/buckets)
[ "$auth" = "Authorization: Bearer tok" ] || { code 401; exit 0; }
code 201; exit 0 ;;
http://api.test/v1/s3/buckets/ci-cache/objects)
[ "$auth" = "Authorization: Bearer tok" ] || { code 401; exit 0; }
k=$(printf '%s' "$d" | jq -r .key)
printf '{"url":"store://%s","method":"PUT"}' "$k" > "$o"; code 200; exit 0 ;;
http://api.test/v1/s3/buckets/ci-cache/objects/*)
[ "$auth" = "Authorization: Bearer tok" ] || { code 401; exit 0; }
printf '{"url":"store://%s","method":"GET"}' "${url#http://api.test/v1/s3/buckets/ci-cache/objects/}" > "$o"; code 200; exit 0 ;;
esac
code 404
EOF
cat > "$T/stub/go" <<'EOF'
#!/usr/bin/env bash
case "$2" in
GOMODCACHE) echo "$HOME/go/pkg/mod" ;; GOCACHE) echo "$HOME/.cache/go-build" ;;
GOVERSION) echo go1.27.1 ;; GOARCH) echo amd64 ;;
esac
EOF
printf '#!/bin/sh\nexec cat\n' > "$T/stub/zstd"
chmod +x "$T/stub/"*
export PATH="$T/stub:$PATH" STORE="$T/store" HANZO_API=http://api.test HANZO_API_TOKEN=tok
export GITHUB_REPOSITORY=hanzoai/gateway GOCACHE_PART=4096
printf '{"repository":{"default_branch":"main"}}' > "$T/event.json"
printf 'example.com/a v1.0.0 h1:x=\n' > "$T/repo/go.sum"
# A pod: a fresh HOME and temp dir, as every runner job gets.
pod() { chmod -R u+w "$T/home" 2>/dev/null; rm -rf "$T/home" "$T/tmp"; mkdir -p "$T/home" "$T/tmp"; export HOME="$T/home" RUNNER_TEMP="$T/tmp"; }
# Go's module cache is read-only, which is what a real save and restore meet.
warm() {
mkdir -p "$HOME/go/pkg/mod/example.com/[email protected]" "$HOME/.cache/go-build/ab"
head -c 20000 /dev/urandom > "$HOME/go/pkg/mod/example.com/[email protected]/a.go"
head -c 9000 /dev/urandom > "$HOME/.cache/go-build/ab/abcd-d"
chmod -R a-w "$HOME/go/pkg/mod"
}
tree() { (cd "$HOME" && find go .cache/go-build -type f -exec sha256sum {} + 2>/dev/null | sort); }
run() { # run <event> <ref> <reftype> <cmd>
GITHUB_EVENT_NAME=$1 GITHUB_REF_NAME=$2 GITHUB_REF_TYPE=$3 GITHUB_EVENT_PATH="$T/event.json" \
GITHUB_OUTPUT="$T/out" bash "$GOCACHE_BIN" "$4" "$T/repo" > "$T/log" 2>&1
}
ok() { printf 'ok %s\n' "$1"; }
no() { printf 'FAIL %s\n' "$1"; sed 's/^/ /' "$T/log"; fail=1; }
hit() { tail -1 "$T/out" 2>/dev/null | sed 's/^hit=//'; }
puts() { wc -l < "$STORE.puts" 2>/dev/null || echo 0; }
key=hanzoai/gateway/go1.27.1-amd64
pod; : > "$T/out"; run push main branch restore
[ $? = 0 ] && [ "$(hit)" = miss ] && ok "an empty store is a miss, and exit 0" || no "an empty store is a miss, and exit 0"
pod; warm; run pull_request main branch save
[ ! -e "$STORE.puts" ] && ok "a pull request never writes" || no "a pull request never writes"
pod; warm; run push feature branch save
[ ! -e "$STORE.puts" ] && ok "a branch that is not the default never writes" || no "a branch that is not the default never writes"
pod; warm; want=$(tree); run push main branch save
n=$(ls "$STORE/$key/" 2>/dev/null | grep -c '\.tar\.zst\.')
[ -f "$STORE/$key/$(sha256sum "$T/repo/go.sum" | cut -c1-32).idx" ] && [ -f "$STORE/$key/latest" ] && [ "$n" -gt 1 ] \
&& ok "the default branch writes $n parts, then the index, then latest" || no "the default branch writes parts, index and latest"
[ "$(tail -2 "$STORE.puts" | head -1)" = "$key/$(sha256sum "$T/repo/go.sum" | cut -c1-32).idx" ] \
&& ok "the index is written after every part" || no "the index is written after every part"
before=$(puts); pod; warm; run push main branch save
[ "$(puts)" = "$before" ] && ok "a key already saved is not written again" || no "a key already saved is not written again"
pod; run push main branch restore
[ "$(hit)" = exact ] && [ "$(tree)" = "$want" ] && ok "a fresh pod restores the exact bytes that were saved" \
|| no "a fresh pod restores the exact bytes that were saved"
pod; : > "$T/out"; HANZO_API_TOKEN=wrong run push main branch restore
[ $? = 0 ] && [ "$(hit)" = miss ] && ok "a refused credential is a cold build, not a red one" || no "a refused credential is a cold build, not a red one"
printf 'example.com/a v1.0.1 h1:y=\n' >> "$T/repo/go.sum"
pod; run pull_request feature branch restore
[ "$(hit)" = partial ] && [ "$(tree)" = "$want" ] && ok "a go.sum the store has not seen restores the repo's latest" \
|| no "a go.sum the store has not seen restores the repo's latest"
old=$(cat "$STORE/$key/latest"); printf 'x' >> "$STORE/$key/$old.tar.zst.000"
pod; : > "$T/out"; run push main branch restore
[ $? = 0 ] && [ "$(hit)" = miss ] && [ -z "$(tree)" ] && ok "an archive that does not match its index is never unpacked" \
|| no "an archive that does not match its index is never unpacked"
pod; : > "$T/out"; HANZO_API_TOKEN= run push main branch restore
[ $? = 0 ] && [ "$(hit)" = miss ] && ok "no identity is a cold build" || no "no identity is a cold build"
# The workflow wires it: restore before the gate, save after it, both reading
# the module the Go provisioning found.
WF=.github/workflows/build.yml
grep -q '"$CI_HOME/bin/gocache" restore' "$WF" && grep -q '"$CI_HOME/bin/gocache" save' "$WF" \
&& ok "build.yml restores and saves through bin/gocache" || { printf 'FAIL build.yml restores and saves through bin/gocache\n'; fail=1; }
grep -q 'cache: false' "$WF" && ! grep -q 'cache: ${{ inputs.go-cache' "$WF" \
&& ok "setup-go's own cache (GitHub's store) is off" || { printf "FAIL setup-go's own cache (GitHub's store) is off\n"; fail=1; }
exit $fail
+9
View File
@@ -422,6 +422,15 @@ test:
# would have worked, and those are the ones people learn to route around.
# Offline and deterministic: SITE_PLAN prints the manifest, no network.
run: bash bin/site_test.sh
- name: gocache
# bin/gocache carries every Go repo's module and build cache between
# runners, and Go trusts what it restores without re-hashing it. So the suite
# pins who may write (the default branch and tags, never a pull request),
# that an index is written only after all of its parts, that an archive not
# matching its index is never unpacked, and that a refused or absent
# identity is a cold build rather than a red one. Offline: a stub curl plays
# cloud's presigning /v1/s3 and the store behind it.
run: bash bin/gocache_test.sh
- name: conflictmarkers
# bin/conflictmarkers runs against every repo in the fleet, so a false
# positive is as expensive as a miss: it blocks a build that would have