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.

Last reviewed 9 November 2026.

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:

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:

Check before you swap: which operating systems and architectures the runner supports (Graphide CI runs x64 Linux jobs; macOS, Windows and Arm jobs stay where they are), whether it needs a GitHub organization rather than a personal account, and whether your organization’s runner groups allow public repositories to use outside runners.

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:

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 test

Know 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

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:

  1. A failing test always fails the job. Selection decides which tests run, never how a result is read.
  2. 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.
  3. 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.
  4. Every left-out test has a reason you can read. “Nothing you changed reaches it” is checkable; “skipped” is not.
  5. 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.

Graphide reports that on its own CI, over 295,411 test runs its selection left out, 0 would have failed (one-sided 95% bound 0.001%), and that it checks its run catches every failure your own run catches before your jobs move. That is Graphide’s measurement on its own workloads; see which tests run, and why.

6. Measure against the baseline

A speed-up you cannot state against your baseline is a guess. Measure each change the same way:

  1. 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.
  2. Same window and scope. The same workflow, branches and number of working days as your baseline.
  3. 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).
  4. 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.