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:

  1. Push to main: every job, 35m 35s.
  2. A manual dispatch for the race and PostgreSQL integration tests: 32m 59s, including a 20-minute timeout and a re-run.
  3. 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.

CI for one portal fixBefore and after
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:

json
{
  "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:

yaml
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:

yaml
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.sh

The 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:

yaml
- uses: actions/setup-go@<sha> # v7.0.0
  with:
    go-version-file: go.mod
    cache: false

The 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:

bash
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

RunWhatElapsed
39186521120push to main, every job35m 35s
39189753000manual race and PostgreSQL run32m 59s
39192328603release branch, every job again16m 57s
Before85m 31s
39194529504push to main, race and PostgreSQL included9m 23s
Timeline of GitHub Actions run 39194529504 for commit 7b7ce40, 9m 23s in total. A same-scale strip compares it with the three earlier runs, 85m 31s. Jobs: changed components 8 s, format check 36 s, shell checks 8 s, Go checks 34 s, Go race checks 8 s, PostgreSQL integration 7m 16s, Node checks 3m 55s, Darwin and Linux client publish about 1m 40s each, release-manifest fields 4 s, CI gate 8 s. Set up Go takes 1 to 13 s in each job that uses it.

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:

yaml
runs-on: rzv-ubuntu-24.04-x64-2vcpu-8gb

Runzivo 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.