Post
Optimising CI/CD for agentic workloads: 13 days red to 74 greens in a row
I build a multi-tenant platform in what is effectively a software factory: a fleet of coding agents writes most of the code, runs the checks, opens the merge requests and lands them. That changes what CI is for. A person pushes a few times a day and waits for the result. Agents push dozens of times a day, run in parallel on the same machines, never mind waiting, and believe whatever the pipeline tells them. In June that broke our CI: for 13 days, not one pipeline on main went green. A month later it had passed 74 times in a row while running five times as many jobs.
CI cost came up on X this week, I offered to write up what we did, and someone said yes please. So here it is, written so a person can skim it and an agent can follow it. Each fix below has the problem, the change, the number it moved, the config, and the thing that bit us.
The whole thing in 30 seconds
- Agents push far more often than people, run in parallel, never mind waiting, and trust whatever the pipeline reports. Everything below follows from that.
- Our Python test suite ran as one process for 76 minutes. Splitting it twelve ways cut the slowest piece to 18.
- A lock that let only one heavy job run at a time was the real speed limit. Giving every job its own private stack let us delete it.
- Installing dependencies took about 8 minutes per job. Baking them into the CI image took it to about 30 seconds.
- A local Docker Hub mirror ended our rate-limit failures. A LAN package cache made apt faster and — when I finally timed it — npm slower. Measure each cache on its own.
- The real gate runs on the machine that pushes, against the exact commit being pushed, before CI sees it.
- Merges are fast-forward only, so the commit CI tested is exactly the commit that lands.
- Extra runners did almost nothing until all of the above was true.
§Why agents change the maths
Most CI advice assumes a human at the keyboard. An agent fleet breaks those assumptions in specific ways, and each one maps to a fix below.
| Agents… | So the pipeline has to… | Fix |
|---|---|---|
| push far more often, many at once | finish before the next push arrives, and share nothing between runs | 2, 3 |
| write tests as fast as they write code — our suite’s runtime went from 14 to 76 minutes in six days | scale with the test suite, not just the codebase | 2 |
| don’t mind waiting | run the heavy gate locally and asynchronously, before CI | 1 |
| keep editing files while a check runs | test a frozen snapshot of the exact commit, never the working tree | 1 |
| run many gates on one machine at the same time | isolate databases, ports and disk per run, and prove “in use” with a lock rather than a guess | 3, 6 |
| create a lot of jobs, and every job pays setup | move setup out of the job and into the image | 4, 5 |
| take a green tick at face value | never report green when nothing was tested, and fail loudly and specifically | 1, 7 |
| merge their own work | merge only the exact commit that was tested | 7 |
§Explain it like I’m ten
Imagine a school where every piece of homework has to pass a check before it counts — and the class is a hundred very fast students who never get tired, never stop handing work in, and believe whatever mark they get back.
- One kid was doing all the homework, alone. That was our test suite: one process, one CPU core busy, the rest of the machine idle. We split the homework across twelve kids.
- There was one bathroom key for the whole school. That was a lock in the CI config that let only one big job run at a time. We gave every class its own bathroom, then threw the key away.
- Every kid walked to the shop each morning to buy pencils. That was every job downloading its dependencies. Now the pencil case is packed the night before.
- The shop was far away and sometimes shut. That was the public package registries. We opened a small shop next door. It was quicker for some things and, when we finally timed it, slower for others.
- Kids handed in homework without reading it back. Most of our red builds were typos a quick check catches in seconds. Now everyone checks their own work before handing it in.
- Sometimes the question changed while you were writing. Your answer was right for yesterday’s question. Now you re-read the question just before you hand it in.
§Before and after
Numbers are from our GitLab API — 453 pipelines on main between 11 June and 24 July 2026 — and the commit history. One self-hosted GitLab Community Edition instance, a small runner fleet. Your numbers will differ; the shape of the problems is what transfers.
| Bottleneck (mid–late June) | The week after the fixes | End of July | |
|---|---|---|---|
Green pipelines on main |
none for 13 days (56 in a row failed or were cancelled) | all of them | 74 in a row |
| Slowest Python test job | 76 min, one process — up from 14 min six days earlier | 18 min (12 shards) | 30 min (see “What we still haven’t fixed”) |
main pipeline wall-clock |
50–230 min, most never finished | median 36 min | median 63 min |
| Jobs per pipeline | 10–28 | 32–34 | 50 |
| Dependency setup per job | ~8 min | ~30 s | ~30 s |
| Wait for a runner | — | seconds | 4 s median |
The pipeline got longer again as it grew from 33 jobs to 50, and one of the reasons is a fix we identified and never made. But it has not stopped going green.
§The seven fixes
§1. Check your own homework: the gate runs on the machine that pushes
Kid version: read your answer back before you hand it in.
Why it matters more with agents. Agents push far more often than people and keep editing while a check runs, so every push has to be checked against a frozen copy of what was pushed. They also don’t mind a 90-minute check, which a person would never tolerate on every push.
Problem. Across the 184 failed pipelines on main, 299 jobs failed in a way that blocked the pipeline. 252 of them were lint — 139 Python, 113 TypeScript. One was the CI infrastructure. We were using CI as a spellchecker, and every typo cost a whole pipeline.
Fix. A git pre-push hook became the real gate, and CI became a second opinion that runs afterwards. The hook checks the exact commit being pushed, not whatever happens to be in the working tree. It checks that commit out into a detached worktree, builds a fresh virtualenv and pnpm install inside it, clones a throwaway test database (fix 6), and runs lint, type checks, the TypeScript tests, a 90-second boot smoke test and the full Python suite with an 80% coverage floor.
Result. The last 30 pipelines on main all passed, and it went green 74 times in a row. The local run is slow — about 90 minutes on a laptop — but agents don’t sit and watch it. They push and move on to the next task.
# pre-push hook, simplified from ours — validate what's being pushed, not the working tree
validated=0
while read -r local_ref local_sha remote_ref remote_sha; do
[[ "$local_sha" =~ ^0+$ ]] && continue # branch deletion: nothing to test
snap="$(mktemp -d)"
git worktree add --detach "$snap" "$local_sha" # an exact copy of the pushed commit
( cd "$snap" && ./ci/run-gates.sh ) # own venv, own node_modules, own DB
rc=$?
git worktree remove --force "$snap"
[ "$rc" -eq 0 ] || exit "$rc"
validated=$((validated + 1))
done
# See fix 7 for why this line exists.
[ "$validated" -gt 0 ] || echo "pre-push: WARNING — validated 0 refs, nothing was tested" >&2
Watch out.
- Validate the pushed SHA, never the working tree. Agents keep editing files while the hook runs.
- git runs the hook straight from the working tree, so an agent editing the hook mid-run can break the run in progress. Ours copies itself to a temp directory and re-executes that copy first.
- A narrow mode is useful — ours,
FAST_GATE=1, runs only the tests of the package that changed — but keep it opt-in. A package-scoped check once passed a change that only the whole-workspace run caught.
§2. Split the homework: twelve test shards
Kid version: twelve kids doing one page each finish long before one kid doing twelve pages.
Why it matters more with agents. Agents write tests as fast as they write code. A single-process test job that is fine for a human team can grow fivefold in a week.
Problem. The Python test job was a single pytest process. Between 21 and 27 June it grew from 14 minutes to 76 as the agents added tests. It took the same 75 minutes on a 4-core VM and on a 16-vCPU runner. That is the tell: if more cores don’t change the time, you’re limited by the process, not the hardware.
Fix. pytest-split across twelve parallel CI jobs. I chose it over pytest-xdist because each CI job gets its own Postgres service container, so twelve jobs means twelve isolated databases for free. Twelve xdist workers inside one job would share one. Each shard writes its own coverage file, and one job combines them and enforces the floor.
Result. The slowest test job went from 76 minutes to 18 on the day it landed. The first green pipeline on main in 13 days came that afternoon.
test-py:
stage: test
parallel: 12
services:
- name: postgres:18 # one database per shard, for free
alias: postgres
variables:
COVERAGE_FILE: ".coverage.${CI_NODE_INDEX}" # unique per shard
script:
- uv sync --frozen --offline
- uv run pytest -q --splits "$CI_NODE_TOTAL" --group "$CI_NODE_INDEX" --cov=yourpkg --cov-report=
artifacts:
when: always
paths: [".coverage.*"]
coverage-combine:
stage: test
needs: [test-py] # waits for all 12 shards, pulls their files
script:
- uv run coverage combine
- uv run coverage report --fail-under=80
Watch out.
- Without a committed
.test_durationsfile,pytest-splitdivides tests by count, not by time, and your slow database tests can all land in one shard. Generate the file withpytest --store-durations, commit it, and regenerate it when the suite grows. We didn’t; see the end. - Put the coverage floor on the combine job, not on the shards. No single shard sees the whole picture.
§3. One bathroom per class: delete the lock
Kid version: with one bathroom key for the whole school there’s always a queue, however many bathrooms you build.
Why it matters more with agents. An agent fleet means many pipelines running at once. Anything that forces them to take turns converts your throughput into a queue.
Problem. Letting a runner take three jobs at a time instead of one broke things immediately. Two heavy jobs on one host both started a Docker Compose stack that published fixed ports for Postgres, the API and the web app, and they collided. The quick fix was a project-wide resource_group, which lets only one heavy job run at a time. A review a few days later estimated it was serialising 17 or 18 heavy jobs — between 36 and 75 minutes of a 60–100 minute pipeline. Nine new job slots on three new runner VMs sat idle behind it.
Fix. Make collisions impossible instead of forbidding overlap. Every e2e job gets its own Compose project name and publishes no host ports; the tests reach the stack over the Compose network. Then the lock can go.
# .gitlab-ci.yml
e2e:
variables:
COMPOSE_PROJECT_NAME: "e2e-${CI_PIPELINE_IID}-${CI_JOB_ID}" # unique per pipeline, and per retry
script:
- docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d
# docker-compose.ci.yml — applied in CI only; a local `docker compose up` keeps its ports
services:
postgres:
ports: !reset []
api:
image: "${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHORT_SHA}" # built once per pipeline
build: !reset null
ports: !reset []
web:
image: "${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHORT_SHA}"
build: !reset null
ports: !reset []
Result. The lock came out on 27 June. Three changes landed within two days of each other — the sharding, removing the lock, and moving a 29-minute video-walkthrough stage off the main path — and the median main pipeline went from failing at 50–230 minutes to green at 36. I can’t cleanly separate how much each one contributed. A day later we also stopped rebuilding the app from source in every job that needed it (it had been happening about seven times per pipeline) and started building the images once and pulling them.
Watch out.
ports: !reset []needs Docker Compose 2.24 or newer.- Pin the images to the commit SHA, so a retried job can’t pick up another pipeline’s build.
§4. Pack the backpack the night before: bake dependencies into the CI image
Kid version: don’t walk to the shop every morning. Pack the pencil case the night before.
Why it matters more with agents. Setup is paid per job, and agents make a lot of jobs — fifty per pipeline, many pipelines an hour. Minutes per job become hours per day.
Problem. Every job spent about eight minutes installing apt packages, Node, uv, Python packages, pnpm packages and a Playwright browser before running a single test — in a pipeline of up to fifty jobs.
Fix. One CI base image with the toolchain installed and the uv cache and pnpm store already filled. Jobs relink from those caches with the network off. There is no GitLab cache: block anywhere in our pipeline.
FROM mcr.microsoft.com/playwright:v1.60.0-noble # browsers already inside
COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /uvx /usr/local/bin/
ENV UV_CACHE_DIR=/opt/uv-cache PNPM_STORE_DIR=/opt/pnpm-store
WORKDIR /opt/deps
COPY pyproject.toml uv.lock ./
COPY packages ./packages # workspace members, needed for editable installs
RUN uv sync --frozen # fills the uv cache
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile # fills the pnpm store
# every job that uses the image
before_script:
- uv sync --frozen --offline
- pnpm install --frozen-lockfile --offline
The image tag is the cache key. The image is rebuilt only when a lockfile or its Dockerfile changes; that branch’s jobs switch to the new tag and everyone else keeps using :main.
build-ci-base-image:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: &deps [ci/Dockerfile, uv.lock, pnpm-lock.yaml, pyproject.toml, package.json]
# ...docker build ... -t "$CI_REGISTRY_IMAGE/ci-base:$CI_COMMIT_REF_SLUG"
.uses-ci-base:
image: "$CI_BASE_IMAGE" # defaults to $CI_REGISTRY_IMAGE/ci-base:main
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: *deps
variables:
CI_BASE_IMAGE: "$CI_REGISTRY_IMAGE/ci-base:$CI_COMMIT_REF_SLUG"
Result. Per-job setup went from about eight minutes to about thirty seconds. The one test suite that ran on both sides of the change took a median 12.0 minutes before and 2.1 after (over 8 and 6 pipelines).
Watch out.
- From a branch, push the branch’s own tag, never
:main, or one branch overwrites everyone’s image. - Rebuilding a 4.2 GB image per branch filled one runner’s disk to 99%, corrupted its overlay layers, and failed three pipelines before anyone noticed. Prune old branch tags.
--cache-froma plain image does nothing under BuildKit. Our image build pulls a:buildcachetag and passes--cache-from, but that image was never built with cache metadata (--build-arg BUILDKIT_INLINE_CACHE=1, ordocker buildx build --cache-to type=registry,...), so BuildKit silently ignores it. Across 86 builds of our CI image, not one dependency layer came from the cache — and the pull alone took a median of about six minutes a build.- Retry only infrastructure failures:
retry: {max: 2, when: runner_system_failure}. Retrying a test failure just hides a flaky test.
§5. A shop next door: a LAN package cache VM
Kid version: when the shop is far away and sometimes shut, open a little one next door that keeps a copy of everything anyone has bought.
Why it matters more with agents. More pushes mean more downloads, which is how you meet rate limits and other people’s outages sooner than a human team would.
Problem. The builds that still download — the CI image itself and the app images — pulled from the public internet every time. That was slow, rate-limited, and at the mercy of other people’s outages. Docker Hub was rate-limiting our anonymous pulls with HTTP 429s, and in July the public npm registry returned HTTP 522 errors that failed our builds before they compiled anything.
Fix. Two layers.
- A small pull-through Docker registry on each runner, so base images come from local disk:
dockerd --registry-mirror=http://host.docker.internal:<port>. - One small VM on the LAN (4 vCPU, 8 GB RAM, a 300 GB cache disk) running four pull-through package caches, deployed and version-pinned with Ansible:
| Cache | Tool | What it caches |
|---|---|---|
| Python packages | devpi | PyPI |
| JavaScript packages | Verdaccio | npm and pnpm |
| Browser binaries | nginx proxy_cache |
Playwright downloads |
| OS packages | apt-cacher-ng | Debian and Ubuntu .debs |
Everything listens on the LAN only. The cached data is disposable, so the recovery plan for any cache problem is “wipe it and let CI refill it”.
The part that took longest was the wiring. Our image builds run docker build inside Docker-in-Docker, and a build there doesn’t inherit the runner’s extra_hosts or environment. So the cache has to be handed to the build explicitly, as build arguments, and used only by the step that needs it:
build-ci-base-image:
script:
- docker build
--add-host pkg-cache.lan:<cache-vm-ip>
--build-arg APT_PROXY=http://pkg-cache.lan:3142
--build-arg NPM_REGISTRY=http://pkg-cache.lan:4873/
-f ci/Dockerfile -t "$CI_REGISTRY_IMAGE/ci-base:$CI_COMMIT_REF_SLUG" .
ARG APT_PROXY=
# Use the cache for this RUN only, then delete the setting: nothing internal is baked into
# the image, and an empty arg (a build on a laptop) falls back to the public mirrors.
RUN if [ -n "$APT_PROXY" ]; then echo "Acquire::http::Proxy \"$APT_PROXY\";" > /etc/apt/apt.conf.d/01lan; fi \
&& apt-get update && apt-get install -y --no-install-recommends postgresql-client build-essential \
&& rm -f /etc/apt/apt.conf.d/01lan && rm -rf /var/lib/apt/lists/*
ARG NPM_REGISTRY=
RUN if [ -n "$NPM_REGISTRY" ]; then echo "registry=$NPM_REGISTRY" > .npmrc; fi \
&& pnpm install --frozen-lockfile \
&& rm -f .npmrc
Result — mixed, and not what I expected. I was sure the package cache had been a big speed win. Measuring it for this note, step by step across 1,161 image builds either side of the switch, said otherwise:
| Step, per build | Direct from the internet | Through the LAN cache | Change |
|---|---|---|---|
| apt install (API image) | 52 s median, worst 189 s | 28 s median, worst 63 s | 46% faster, and far steadier |
| apt install (CI image) | 48 s | 42 s | 13% faster |
| pnpm install (web image, same 189 packages) | 33 s | 64 s | 92% slower |
| pnpm install (CI image) | 115 s | 147 s | 28% slower |
| Docker Hub rate-limit failures | 7 | 0 | from the per-runner Docker mirror, not the VM |
The apt cache paid for itself. The npm cache cost about thirty seconds a build. The reliability win came from the Docker mirror, which serves base images at about 940 MB/s on a hit. I haven’t root-caused the npm slowdown; my leading suspect is Verdaccio re-checking package metadata with the public registry on a short timer (its maxage setting), which would put a network round trip back in front of every package. And across whole jobs, the gains and losses roughly cancel out, because what dominates our image builds is something no package cache touches (see the --cache-from note in fix 4).
Watch out. Every one of these cost us a failed build.
- A cold cache can be slower than no cache at all. Pull-through caches fetch packages one at a time the first time they’re asked. A cold devpi made a 107-package install 2.5× slower than going straight to PyPI. Warm the cache before pointing CI at it —
pip download -r requirements.txt --index-url <cache-url>— and re-warm it after dependency bumps. - Lockfiles can go around your mirror.
uv.lockrecords absolutefiles.pythonhosted.orgURLs, souv sync --frozennever asks the index you configure. We left Python packages going direct rather than bake a LAN hostname into a committed lockfile. - Environment variables get ignored. pnpm 11 ignored
npm_config_registryand went to the public registry anyway. Writing a temporary.npmrcworked. - The bootstrap goes around the mirror too.
corepack prepare pnpmdownloads pnpm itself from the public npm registry before any.npmrcexists. That’s why July’s npm outage still failed our builds. Point corepack at the mirror as well (COREPACK_NPM_REGISTRY). - Caches can wedge when upstream changes. apt-cacher-ng started returning 503s after the PostgreSQL apt repository republished a larger index file. It failed eight of our twelve Postgres image builds that day; deleting that one cached directory fixed it.
- nginx resolves a
proxy_passhostname once, at startup. A DNS blip during an unattended upgrade killed our Playwright cache for six days. Add aresolverdirective andRestart=on-failure. - Check for AES-NI. Our runner VMs didn’t expose it, which capped TLS at about 44 MB/s. The Docker mirror only listens on the host’s loopback bridge, so it runs plain HTTP.
- Lockfile integrity hashes still apply to anything the cache serves, so a pull-through cache can’t quietly hand you a modified package.
§6. Everyone gets their own sandbox: throwaway test databases
Kid version: if two kids share one sandbox, sooner or later one knocks over the other’s castle.
Why it matters more with agents. A person runs one check at a time. Agents run several on one machine at once, so anything shared — a database, a port, a cleanup job — becomes a race.
Problem. Local gates shared one test database and trampled each other — 42 phantom failures in one afternoon.
Fix, part one. Every gate clones its own database from a template, which takes seconds: CREATE DATABASE gate_<sha> TEMPLATE app_test. The template is rebuilt only when the migrations on main change. A guard in conftest.py refuses to run the tests against any database whose name doesn’t end in _test or _test_<suffix>, so a misconfigured gate can’t touch a real one.
Fix, part two — the one that took three attempts. To stop abandoned clones piling up, a cleanup step dropped any clone that had no open connections and was more than ten minutes old, and it ran at the start of every gate. Gates started dying late with InvalidCatalogNameError: one passed 1,684 tests over two hours and fifteen minutes, then hit 20 of those errors. We first blamed a container restart — a real bug, but a different one (docker compose up was recreating the shared Postgres container whenever configs drifted; --no-recreate fixed it). Then we raised the age limit to two hours. Gates kept dying. The actual cause: pytest doesn’t hold a database connection continuously, so a gate starting up could look at a sibling’s database mid-run, see zero connections and an old creation time, and drop it.
The fix was to stop guessing whether a clone is in use and ask its owner instead. Each gate holds an exclusive flock on its clone’s lock file for the whole run:
# in the gate: hold the lock until the hook exits (the kernel releases it even on a crash)
exec {GATE_LOCK_FD}>"$(git rev-parse --git-common-dir)/gate-${DB_NAME}.lock"
flock -x "$GATE_LOCK_FD"
# in the cleanup: only drop a clone if we can take its lock ourselves
import contextlib, fcntl, os
@contextlib.contextmanager
def orphan_lock(lock_path: str):
"""Yield True if nobody holds the lock (the clone is an orphan).
We keep holding it while we drop, so no new gate can claim the clone mid-drop."""
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
yield False # a live gate holds it: leave the clone alone
return
yield True
finally:
os.close(fd) # closing the file releases the lock
for db in list_gate_clones():
with orphan_lock(lock_file_for(db)) as is_orphan:
if is_orphan:
drop_database(db)
The lock file lives in the git common directory, so every worktree of the repo sees the same one.
Result. The same commit that failed at 2h15m under contention passed in 1h12m run alone, and the error hasn’t come back.
Watch out. “No connections right now” is not the same as “nobody is using it”. Liveness should be something the owner positively holds. A flock is released by the kernel when its process dies, so a crashed gate can’t leave a stale lock behind, and there’s no timeout to tune.
§7. Re-read the question before you hand it in: test what will actually merge
Kid version: your answer was right for yesterday’s question. Check the question hasn’t changed before you hand it in.
Why it matters more with agents. With many agents landing work, main moves constantly, and agents act on whatever the tools report. A gate that says green having tested nothing, or a merge of a commit nobody tested, gets acted on without a second look.
Problem. If main moves while a branch is in review, a green branch pipeline describes a commit that will never exist. GitLab’s answer, merged-results pipelines, is a paid-tier feature, and we run the free Community Edition.
Fix. Two project settings: fast-forward merges only, and no merging unless the pipeline passed. A branch then has to be rebased onto the current main before it can merge, so the commit CI tested is exactly the commit that lands.
curl --request PUT --header "PRIVATE-TOKEN: $TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID" \
--data "merge_method=ff&only_allow_merge_if_pipeline_succeeds=true"
# merge only the exact commit you checked; GitLab refuses if the branch has moved since
curl --request PUT --header "PRIVATE-TOKEN: $TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/merge?sha=$FULL_40_CHAR_SHA"
Watch out.
- A clean rebase can still revert something. One branch had been cut before a fix to a shared test script. Its diff touched only one YAML file, but its tree still held the old script, so merging it would have quietly undone the fix. No conflict, nothing in the diff. Rebase right before the final push, not whenever the branch happened to be cut.
- A rebased push can skip your gate. After a rebase, a plain
git pushis non-fast-forward. Our hook received an empty list of refs, printednothing to validate, and exited successfully — a green gate that had tested nothing. Push rebased branches withgit push --force-with-lease=<branch>:<expected-sha>so the hook sees the ref, and have the hook complain loudly when it validated zero refs (fix 1). - The merge API needs the full 40-character SHA. A short one fails with a misleading “SHA does not match HEAD”.
§What we still haven’t fixed — and how the data found it
Pulling the numbers for this note turned up problems nobody had looked at, which is exactly the “measure in a loop” part of this. I measured once, in June, and didn’t look again.
- The shards are lopsided. When we first split the tests, the worst shard took 14 minutes, and the agent that measured it named the cause the same day: no
.test_durationsfile, so the split was by count. The fix was proposed and never committed. A month later the suite had grown from 1,407 tests to about 2,000 (by the local gate’s count), and the average shard takes 12 minutes and the slowest takes 30 — the same shard, number 9, in every recent pipeline. That one shard is half the pipeline’s wall-clock time. Projection, not yet measured: balancing it would take a typical pipeline from about 60 minutes to about 42. - Stages wait for each other when they don’t need to. Only one job in our pipeline uses
needs:. The conformance suites and the e2e run wait for the slowest test shard, even though all they need are the built images. Projection: the images-then-e2e path is about 20 minutes. The catch is that the strict order is partly deliberate — a failing unit test currently stops the pipeline before it spends runner time on e2e. - The spare desktop never ran a job. It’s registered as a runner but only accepts jobs tagged
cpu-intensive, and nothing in our pipeline has that tag. - The CI image’s layer cache has never worked. Every build pulls a roughly 4 GB cache image for about six minutes and then rebuilds every layer anyway (fix 4). Projection: building it with cache metadata should save most of those six minutes, and more whenever the early layers haven’t changed.
- The npm cache is slower than going direct. Tune it or stop routing pnpm through it, and measure again either way.
- corepack still bypasses the npm mirror (fix 5).
§Doing it yourself: a checklist
For a person or an agent — ideally before the second agent starts pushing. In this order, because each step removes the bottleneck that would hide the next one. Each has a check that proves it worked.
- Measure. Pull your last few weeks of pipelines from the API: wall-clock, time queued, per-job duration, and which jobs fail most. Check: you can name your slowest job and your most common failure.
- Find out whether the slow job is process-bound. Run it on a machine with four times the cores. Check: if the time barely changes, split it; don’t buy hardware.
- Shard it (
parallel:pluspytest-splitor your test runner’s equivalent), commit a durations file, and combine coverage in one job. Check: the slowest shard is within about 1.5× of the average. - Remove shared state between jobs — unique Compose project names, no published ports, images pinned to the commit — then delete any
resource_grouplocks. Check: two pipelines’ e2e jobs can run on the same runner at the same time. - Bake dependencies into the CI image, keyed by lockfile, and relink offline. Check: per-job setup is under a minute, no job reaches a public registry, and image rebuilds show
CACHEDsteps. - Put package caches on the LAN, warm them before use, and pass them into
docker buildexplicitly. Check: time each cached step against going direct, per package manager, and keep only the caches that win. Ours won for apt and lost for npm. - Move the gate to the machine that pushes. Validate the pushed SHA in a detached worktree with its own dependencies. Check: lint failures stop showing up in CI.
- Give every local run its own database, protected by a lock its owner holds. Check: two gates running at once both pass.
- Fast-forward-only merges plus “pipeline must succeed”. Check: the SHA CI tested is the SHA on
main. - Only now, add runners — and only if jobs are actually waiting for them. Check: if the time queued is seconds, more runners won’t help.
- Re-measure every few weeks. Check: the numbers from step 1 still hold. Ours didn’t.