Guide
Making GitHub Actions faster without switching
Most slow GitHub Actions pipelines do not need a new CI platform. They need faster machines, caches that are warm when the job starts, tests split across runners, and fewer tests run per change, without changing a single pass/fail verdict. Measure first, then change one thing at a time.
1. Measure your baseline
The number that matters is push-to-verdict time: from the moment a change is pushed to the moment its last required check reports pass or fail. That is what a developer, or a coding agent, waits on. Split it in two:
- Queue time: from the run being created to each job starting. High queue time means you are short of runners or concurrency, not slow tests.
- Run time: from a job starting to it finishing. This is where machines, caches, parallelism and test selection help.
The GitHub CLI gives you both without any new tooling:
# The last 200 runs of one workflow, with when each was created and last updated
gh run list --workflow ci.yml --limit 200 \
--json databaseId,createdAt,updatedAt,conclusion,event,headBranch > runs.json
# Per-job start and finish times for one run (queue time = started_at - created_at)
gh api repos/OWNER/REPO/actions/runs/RUN_ID/jobs \
--jq '.jobs[] | {name, created_at, started_at, completed_at, conclusion}'Take a fixed window (two working weeks is a reasonable default), the workflow that gates merges, and pull requests plus the default branch. Write down the median (p50) and the slow end (p90) of push-to-verdict, the number of runs, the share that failed, and the minutes billed. That is your baseline. Every claim about “faster” below is only true against it.
2. Swap the runners
A GitHub Actions job runs wherever its runs-on line points. Changing that line is the cheapest speed-up there is, because nothing else in the workflow changes: same steps, same actions, same secrets, and GitHub still decides pass or fail from the job’s own steps. Your options:
- GitHub’s larger runners (more cores and memory, billed per minute at higher rates; see GitHub’s runner pricing).
- Runner layers that run your existing jobs on their machines: Graphide CI (
runs-on: graphide), Blacksmith and Depot. They differ in what they do beyond faster hardware: see the full ranking. - Self-hosted runners: free to attach, but you own the machines, their security and their upkeep.
3. Warm dependency and build caches
Most CI jobs spend their first minutes downloading the same dependencies and rebuilding the same outputs as the last run. Two kinds of cache fix that:
- Dependency caches: npm, pnpm, pip, Go modules, Maven, Gradle. The
setup-*actions have acacheoption keyed on your lockfile. - Build caches: compiler and bundler outputs (Go’s build cache,
.next/cache, Gradle’s build cache), remote caches for Turborepo, Nx or Bazel, and Docker layer caches (Buildx can usetype=gha).
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm # restores ~/.npm keyed on package-lock.json
- uses: actions/cache@v4
with:
path: .next/cache # a build cache: here, Next.js
key: next-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
next-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
- run: npm ci
- run: npm testKnow the limits. GitHub’s cache includes 10 GB per repository (GitHub’s billing docs), it is restored over the network on every job, and a key that changes on every commit never hits. Runner layers move the cache next to the machine: Graphide CI has dependencies and build caches on the runner before the job starts, with no cache steps to write; Blacksmith and Depot keep caches colocated with their runners and sell persistent Docker layer caching.
4. Run tests in parallel
Splitting a suite across machines cuts wall-clock time roughly in proportion to the number of shards, minus each shard’s set-up time. In GitHub Actions that is a matrix:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4 # vitest takes --shard the same way- Jest and Vitest take
--shard=i/n; pytest spreads tests across CPU cores withpytest-xdist(-n auto);go testruns packages in parallel by default. - Balance shards by past test timings, not file counts, or the slowest shard sets the pace.
- Each shard repeats checkout, install and build: without warm caches, parallelism multiplies set-up time and billed minutes.
- Use
fail-fast: falseso one failing shard does not hide failures in the others.
Graphide CI splits the tests a change reaches across as many runners as it takes, so you do not maintain a shard count; CircleCI and Buildkite split by timing on their own platforms.
5. Select tests by evidence, and keep the verdict
The largest saving is not running a test faster but not running a test the change cannot affect. Done carelessly, that is how a broken change gets a green check. Done with evidence, it keeps exactly the same pass/fail verdict. Hold any approach, built or bought, to these rules:
- A failing test always fails the job. Selection decides which tests run, never how a result is read.
- When it cannot tell, run more. If reachability cannot be established, run the affected tests rather than assume they are irrelevant; when anything goes wrong, run everything.
- Shared inputs run everything they touch. A lockfile, a fixture, CI config or a change outside any function runs the tests it could reach, whole.
- Every left-out test has a reason you can read. “Nothing you changed reaches it” is checkable; “skipped” is not.
- Prove it on your history. Run selection and the full suite on the same commits for a window and count how often they disagree. The target is zero.
Path filters (on.push.paths) are the crude version: they skip whole workflows by folder and miss a shared module changing under a service. Package-level “affected” commands in Nx, Turborepo and Bazel are better for monorepos. Function-level selection goes furthest: Graphide CI traces which tests call into what changed, directly or through other code, runs those, and lists every test it left out with its reason. CircleCI’s Smarter Testing does test selection on CircleCI.
6. Measure against the baseline
A speed-up you cannot state against your baseline is a guess. Measure each change the same way:
- Same commits. Run the new set-up beside the old one on the same pushes (the old one stays the required check), or re-run a fixed set of recent commits on both.
- Same window and scope. The same workflow, branches and number of working days as your baseline.
- Report four numbers: p50 and p90 push-to-verdict, minutes billed, and verdict agreement (the share of commits where both gave the same pass/fail).
- Investigate every disagreement before you switch: a flaky test, an environment difference, or a real miss. Only the last is a reason to stop, and it is a reason to stop.
Write the result as a sentence that can be checked: the workflow, the window, the number of runs, before and after. “p50 push-to-verdict on ci.yml, pull requests and main, over ten working days and N runs, went from A to B minutes, with every verdict the same” is a result. “10x faster” is not.
Doing all four at once: Graphide CI
Graphide CI, #1 in our ranking, is a runner layer for existing GitHub Actions jobs, not a new CI platform. Your workflows stay on GitHub Actions. Its runners start with warm caches, split the tests a change reaches across runners, and run only the tests that change can reach, with the rules above: a failing test fails the job, and when it cannot tell, it runs more.
- Moving a repository: install the Graphide GitHub App on your organization, press Switch to Graphide CI, and Graphide opens one pull request that changes each x64 Linux job’s
runs-ontographideand nothing else. Jobs it cannot run stay where they are and the pull request lists them. - Test selection: the test step runs through
gr ci run -- <your test command>. It narrowsgo test,vitest,jestandnode --test; tests of other runners run whole. - Limits: x64 Linux jobs only, repositories of GitHub organizations only, and no self-hosted option.
- What to expect: Graphide’s pricing page cites one customer suite going from 70 to 5 minutes against GitHub-hosted runners. Your result depends on your suite; measure it against your baseline as above.
- Cost: $0.003 a vCPU-minute ($0.006 a minute on a standard Linux runner), billed for the time a job would take without its speed-ups, $20 monthly minimum, with a cap you set; see Graphide’s pricing.
How Graphide CI worksSetup for coding agentsMigrating CI without breaking your verdict