Batching pull requests to cut CI cost Jump to heading
Every pipeline run pays a fixed cost before it tests anything: provisioning a runner, checking out the repository, restoring or rebuilding dependencies, starting services. On a mature project that overhead is frequently larger than the tests themselves. Batching queue entries spreads it across several merges β but a red batch has to be bisected, so the saving is bounded by how often batches fail. This recipe finds the size that is actually cheapest for your project, extending merge queues and required checks.
When to use this approach Jump to heading
- Your pipeline spends more than a third of its wall-clock time before the first test runs.
- More than about ten pull requests merge per day, so entries are usually waiting together.
- Your entry failure rate is low β below roughly one in ten, measured rather than assumed.
- CI spend or runner contention is a real constraint, not a theoretical one.
- If the fixed cost is small because of good caching, the gain is small too; start with caching dependencies keyed on the lockfile.
Step 1 β Separate fixed cost from test cost Jump to heading
Batching only amortises the fixed part, so measure it before predicting any saving.
# Time the phases separately in a scratch run
time git clone --filter=blob:none --no-checkout "$REPO" /tmp/bench && \
time npm ci --prefer-offline && \
time npm test # Or read it from a recent run's step timings
gh run view --log | awk '/##\[group\]/{name=$0} /real|elapsed/{print name, $0}' What this gives you: two numbers, F (fixed) and T (tests). A batch of n costs F + nT instead of n(F + T), so the saving per merge is F(nβ1)/n β which flattens quickly. With a two-minute fixed cost and a three-minute suite, a batch of three already recovers two thirds of what batching can ever save, and a batch of ten recovers barely more.
Step 2 β Measure the entry failure rate Jump to heading
The cost of a red batch is the re-runs needed to find the culprit. That cost scales with batch size, so the failure rate decides the ceiling.
# Failures among recent queue runs
gh run list --event merge_group --limit 200 --json conclusion \
--jq '{total: length, failed: ([.[] | select(.conclusion=="failure")] | length)}' A rate under 5% supports batches of five to eight comfortably. Between 5% and 15%, three is a sensible ceiling. Above 15%, batching is a way of spending more machine time than you save, and the real fix is upstream β usually flakiness, which is dealt with in handling flaky tests in a merge queue.
Step 3 β Configure the batch and the bisection behaviour Jump to heading
# Queue settings worth being explicit about, whatever implements them:
# max entries per batch β start at 3, raise on evidence
# minimum wait β a short delay lets entries accumulate
# failure handling β bisect and eject, never fail the whole batch
# timeout β shorter than the pipeline's worst case, not its median # Verification: confirm multiple entries share one candidate run
gh run list --event merge_group --limit 5 --json headBranch,databaseId
git ls-remote origin 'refs/heads/gh-readonly-queue/*' | wc -l A minimum wait is the part teams skip. Without it, the first approved entry starts a run alone and the two approvals thirty seconds later form a second batch, so the configured size is never reached.
Step 4 β Make bisection cheap when a batch fails Jump to heading
Bisection re-tests subsets of the batch. You can shrink the number of runs by attributing the failure before splitting: a failing test that only touches one entryβs files is almost certainly that entryβs fault.
# Which entry touched the code the failing test covers?
failing_test=$(xmllint --xpath 'string(//testcase[failure]/@file)' reports/junit.xml)
for sha in $(git log --format=%H origin/main..HEAD); do
git show --name-only --format= "$sha" | grep -q "$(dirname "$failing_test")" \
&& echo "candidate: $sha"
done # Verification: a deliberately broken entry is ejected on the first split
git commit --allow-empty -m "test: force a queue failure" && echo "watch the ejection" Step 5 β Recheck the numbers after the pipeline changes Jump to heading
Batch size is derived from two measurements, and both drift. A new caching layer cuts the fixed cost and reduces the benefit of batching; a new integration suite raises the failure rate and lowers the safe ceiling.
# A monthly one-liner worth keeping in a scheduled job
gh run list --event merge_group --limit 300 --json conclusion,createdAt,updatedAt \
--jq '{failures: ([.[] | select(.conclusion=="failure")] | length), sampled: length}' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does batching make merges take longer? Jump to heading
Slightly, and mostly during the accumulation window. The queue wait is dominated by pipeline duration, not by batch size, so a ninety-second window is invisible next to a six-minute run. If the wait is painful, the pipeline is the thing to fix.
What if one entry in a batch is a revert? Jump to heading
Treat it like any other entry, but consider a bypass path for incident reverts specifically. During an outage the value of merging immediately outweighs the value of the batch, and a documented break-glass route is better than someone discovering one under pressure.
Can we batch across different pipelines? Jump to heading
Only if every required check runs on the candidate tree. A batch validated by a subset of the checks is not validated, and the gap will be found by the change that needed the missing one. Keep one candidate run that covers the whole required list.
Related Jump to heading
- Merge Queues & Required Checks β the parent topic and the guarantee batching must not break.
- Handling Flaky Tests in a Merge Queue β the failure rate that caps your batch size.
- Splitting a Long Pipeline Into Parallel Jobs β cutting the duration that makes queue latency hurt.