In October I merged a small fix to the customer portal. It touched two files in the portal and no Go code. Before it could ship, CI ran three times:
- Push to
main: every job, 35m 35s. - A manual dispatch for the race and PostgreSQL integration tests: 32m 59s, including a 20-minute timeout and a re-run.
- Push to the release branch: every job again, 16m 57s.
That is 85.5 min of CI for a change the Go jobs had nothing to check.
- Total CI time, one portal-only change
- Down 89%: 85m 31s to 9m 23s
- Set up Go, per job
- Down from 8 to 11 min to 1 to 13 s
- Go, race and PostgreSQL jobs
- Skipped for a portal-only change
- Time jobs spent waiting to start
- About 1.7 min of the 9 min run
The slowest step in the first run was Set up Go, which took 8 to 11 min in the Go, Node and Linux client jobs.
Here are the five fixes. Each works on GitHub-hosted and self-hosted runners. Tick them off as you go.
The five fixes
1. Run only what changed
I keep one map from paths to components. CI and the release script both read it. Simplified:
{
"components": {
"portal": { "paths": ["apps/portal/**", "packages/**", "pnpm-lock.yaml"] },
"core": { "paths": ["internal/**", "go.mod", "migrations/**"] },
"ci": { "forceAll": true, "paths": [".github/**", "scripts/ci/**"] }
},
"ignored": ["docs/**", "**/*.md"]
}A first changes job diffs the push against the previous commit and writes one output per component. A file that matches no component runs everything, and so does a change to CI itself. Each job then checks its output:
go-fast:
needs: changes
if: needs.changes.outputs.core == 'true'Why not the built-in paths filter? If a workflow is skipped by path filtering, its checks stay "Pending", and a pull request that requires them can't merge (GitHub docs). Job-level if: avoids that, but a skipped job reports "Success" (GitHub docs). So if the changes job itself fails, every job after it skips and looks green.
The fix is one gate job that always runs, needs every other job, and fails on anything other than success or skipped. Make it the only required check. Simplified:
ci-gate:
name: CI gate
if: always()
needs: [changes, format, go-fast, node-fast]
steps:
- env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: ./scripts/ci/02-ci-gate.shThe script loops over NEEDS_JSON with jq and exits non-zero on failure or cancelled. Logic like this belongs in a script you can run locally, not in YAML (rule 1 of the CI playbook).
2. Test once
The race and PostgreSQL tests used to wait for a manual dispatch, so every change needed a second run. Then the release branch ran everything again on the same commit.
Now the expensive suites run automatically on every push to main that changes a Go component. The next step is to promote the commit that already passed on main instead of testing it again on a release branch.
3. Keep caches on the runner
A self-hosted runner's disk survives between jobs. That is a cache you already have.
I had actions/setup-go with caching on. Each job downloaded a 4.6 GB archive and unpacked it into a home directory that many runner processes on the same machine shared. In each of those jobs, tar printed "Cannot open: File exists" 22,585 times. In the trusted run, the PostgreSQL job hit its 20-minute timeout inside Set up Go.
I turned the action caches off, so jobs use the Go and pnpm caches already on the runner's data disk:
- uses: actions/setup-go@<sha> # v7.0.0
with:
go-version-file: go.mod
cache: falseThe Node and client jobs also dropped cache: pnpm from actions/setup-node. Set up Go now takes 1 to 13 s. I have since given each runner its own cache directories too, so parallel jobs never unpack into the same place.
On GitHub-hosted runners every job starts clean, so actions/cache or the cache input of setup-* actions is the right tool. Key it on the lockfile. GitHub removes entries not used for 7 days (dependency caching reference).
4. Let tool caches do their job
Go caches passing test results. A result is reused when the test binary, the flags, and the files and environment variables the test reads are unchanged. The output then says (cached) (go test docs).
My first run after the change touched no Go code. All 35 race-test packages with tests came from the cache, and the job took 8 s.
-count=1 turns this off. Use it only where a test depends on something Go can't see, like the PostgreSQL tests.
5. Measure before and after
This lists the slowest steps of a run:
gh run view <run-id> --json jobs --jq '[.jobs[] | .name as $job | .steps[]
| {job: $job, step: .name, s: ((.completedAt | fromdate) - (.startedAt | fromdate))}]
| sort_by(-.s) | .[:3][] | [.s, .job, .step] | @tsv'On my first run it printed Set up Go at 642, 537 and 485 seconds. That pointed at the cache, not the tests.
My numbers
| Run | What | Elapsed |
|---|---|---|
| 39186521120 | push to main, every job | 35m 35s |
| 39189753000 | manual race and PostgreSQL run | 32m 59s |
| 39192328603 | release branch, every job again | 16m 57s |
| Before | 85m 31s | |
| 39194529504 | push to main, race and PostgreSQL included | 9m 23s |

The run after the change edited CI files, so it ran every job. A portal-only change now skips the Go, race and PostgreSQL jobs. About 1.7 min of the 9 were jobs waiting to start.
One piece is left: releases still re-run CI on the release branch. I will retire that run and build only changed images.
If you'd rather not run runners at all
Runzivo runs managed Linux x64 runners for GitHub Actions. Each job gets a fresh Firecracker microVM that is destroyed when the job ends. To try it, change one label:
runs-on: rzv-ubuntu-24.04-x64-2vcpu-8gbRunzivo is in Early Access, with a free monthly allowance per workspace (getting started). Because each job starts clean, use fix 3's advice for hosted runners there. The numbers above came from my CI changes, not from switching runners.