Skip to main content
HomeBlogWays to speed up con...

Software Engineering · 8 min. read

Speeding Up CI on GitHub Actions: Shards, Merge Queues and Early Cancellation

A slow pipeline costs minutes on every change. The methods we use on our own platform's CI: fast paths for docs-only changes, sharded jobs, the merge queue and early cancellation, without weakening the checks.

Donatas Tranauskis Published
Continuous IntegrationGitHub ActionsMerge Queue

The short answer

Most slow CI pipelines run too much, too often, one job after another. The fix is rarely a faster machine. It is running only what a change needs, splitting long stages so they run side by side, saving the heaviest checks for the moment before merge, and stopping a run as soon as it can no longer pass.

These are the methods we apply to the continuous integration (CI) of our own platform, Bones, on GitHub Actions. Its test suite includes mutation testing, which is thorough and slow, so wasted CI time adds up quickly.

One rule runs through all of them: a faster pipeline must never be a weaker one. Every shortcut below keeps the same quality bar, and a check that was skipped or deferred never reports as passed.

Where CI time goes

Before changing anything, look at where the time actually goes. In our experience it is usually one of four things:

  • Running everything for every change. A typo fix in a README pays for the same full run as a change to the core.
  • One long job. A single stage that takes most of the run, and holds everything else up.
  • Running the heaviest checks on every push. Each push to a pull request repeats work that only matters once, just before merge.
  • Runs that keep going after they have failed. One required job fails, but sibling jobs carry on for a long time before the result is reported.

Each of these has its own fix.

1. Run less for small changes

A change that only touches documentation doesn't need the full test suite. We run only the fast checks for it, both on the pull request and in the merge queue.

The rule has to be strict to be trusted:

  • every changed file must match the documentation-only list;
  • a mixed change, or any file the rule doesn't recognise, runs everything;
  • the required check reports "skipped", not "passed".

The same idea works for other narrow changes. For example, a change to a lock file alone doesn't need every test group, only the ones that depend on it.

2. Split long stages into shards

GitHub-hosted runners stop any job that runs longer than the job execution time limit. One of our mutation stages was heading towards that limit, so we split it into shards: smaller jobs that each test part of the code, running at the same time through a job matrix.

Sharding is easy to get subtly wrong. Three rules keep it honest:

  • One shard map is the source of truth. The CI matrix is built from the map, so the two can't drift apart.
  • A partition check guards the map. CI fails if a file is in no shard, in two shards, or listed but missing.
  • Every shard meets the same quality bar. No shard can hide a weak result behind another shard's good score.

Each shard also gets its own timeout, so a stuck shard ends early instead of using up the whole job limit. When some shards run much longer than others, we rebalance the map using the measured time per file.

3. Run independent stages in parallel

Stages that don't depend on each other shouldn't wait for each other. Our kernel's mutation stages used to run one after another, and they were the longest part of a merge-queue run. They now run as separate parallel jobs, and the largest of them is split again into its own matrix of shards.

Parallel jobs use the same amount of compute as before. What changes is the wall-clock wait before a change can merge.

4. Move the heaviest checks to the merge queue

A merge queue tests each change against the latest main branch just before it merges. That makes it the right place for the most expensive checks.

On pull requests we now defer every mutation stage to the merge queue. Developers get faster feedback on each push, and the full checks still run once, at the point that matters. The gate fails closed:

  • on a pull request, deferred stages are reported as "deferred", never as passed;
  • if a deferred stage runs on a pull request, or a required stage doesn't run in the merge queue, the gate fails.

Order matters too. Our committed-secret scan runs first, and the expensive jobs wait for it, so a change that fails a cheap check doesn't start the costly ones.

5. Stop a run as soon as it is doomed

In the merge queue a change either passes everything or doesn't merge. Once one required job has failed, everything else in that run is wasted time.

We use two mechanisms:

  • Fail-fast on shard matrices in the merge queue. One failed shard ends the matrix. On pull requests fail-fast stays off, so every shard reports and developers see all the failures at once.
  • A "fail-fast companion" job. When a long required job fails in the merge queue, it cancels the rest of the run instead of letting sibling jobs carry on for hours.

We also cancel superseded runs. With concurrency groups, a new push cancels the older run for the same branch that is still in progress.

6. Never let a skip look like a pass

Every method above skips, defers or cancels something. That is where pipelines quietly get weaker. A skipped job can look green in a list of checks, and a cancelled run can leave the final gate unrun.

So we make the CI gate state exactly what happened. It says "skipped", "deferred" or "failed", never a pass it didn't earn. The gate still runs when the rest of the run was cancelled, and a test pins that behaviour so a later change can't break it unnoticed.

What to run locally before you push

The cheapest CI run is the one you don't need. Before pushing, it is worth running the checks that fail most often and take seconds: the code formatter, static analysis and the tests closest to what you changed.

Local runs don't replace CI. Your machine isn't a clean environment, and only CI tests the change against the latest main branch. But they catch the obvious failures before they cost a full run, and before they hold up anyone else in the merge queue.

Where to start on your own pipeline

  1. Find the longest job in a typical run. That is your first candidate for shards.
  2. List the changes that shouldn't need a full run, such as documentation, and give them a strict fast path.
  3. Move the heaviest checks to the merge queue if your team uses one.
  4. Turn on cancellation for superseded runs and for doomed merge-queue runs.
  5. Check that your final gate reports skips and cancellations honestly.

We do this kind of work on client code as part of AI-assisted software engineering, and we can cover it with your team in a workshop. For why this kind of upkeep matters over time, see the cost of technical debt.

FAQ

Questions about this article

Short answers to what readers ask about this topic. Anything else, just ask.

Ask a question

What is CI sharding?

Splitting one long test stage into several smaller jobs that run at the same time, each covering part of the code. On GitHub Actions this is usually done with a job matrix.

Does a merge queue slow developers down?

It moves the heaviest checks from every pull request push to one run just before merging. Pull requests get quicker feedback, and main is still only updated after the full checks pass.

Is it safe to skip tests for documentation-only changes?

Only if the rule is strict: every changed file must be documentation, anything mixed or unrecognised runs everything, and the check reports "skipped" rather than "passed".

How do you cancel a CI run that is going to fail?

Turn on fail-fast for matrix jobs where the result must be all-or-nothing, and add a step that cancels the whole run when a long required job fails. Make sure the final gate still runs and reports the failure.