Featured image of post The fastest job is the one you never run

The fastest job is the one you never run

Half my CI jobs ran for no reason on every merge request. Skipping them with rules:changes, and why that is trickier than the manual suggests.

I went looking for where my CI was burning its time, and the answer was almost embarrassing: half the jobs were running for no reason at all. It sent me back to the old line that the fastest routine is the one you don’t need to run at all. Every gate job ran on every merge request, whether it had anything to do with the change or not.

The example that made me wince was the docs-site build firing on a Go-only change. Someone edits a .go file in go-tool-base, opens an MR, and the pipeline dutifully rebuilds the documentation site, which shares not one atom with the thing that changed. Multiply that across a library of gate components, lint, test, security, docs, infrastructure validation, all firing on every MR regardless of relevance, and it adds up. It adds up especially on my setup, which is a single self-hosted runner with a concurrency of three. A wasted job there isn’t an abstract inefficiency, it’s one of three slots, taken, while something that genuinely needed to run waits behind it.

GitLab has the tool for this, rules:changes: a job only runs when files it actually cares about have changed. Point the Go lint job at **/*.go and a docs-only MR sails past it. Obvious enough. The reason this is a post and not a one-line tip is that rules:changes has a sharp reliability problem that, if you don’t know about it, turns the optimisation into an intermittent disaster.

The problem is that changes is only trustworthy when there’s a well-defined diff to compare against, and that depends entirely on the kind of pipeline you’re in. On a merge request pipeline, it compares against the MR’s diff, the change versus the target’s merge-base, which is exactly the question you want answered, and it’s reliable. On an ordinary branch push it compares the before and after of the push, which falls apart the moment you hit a new branch, a multi-commit push, or a force-push, where it tends to silently evaluate as always-true. And on a tag or a scheduled pipeline there’s nothing to diff against at all, so it’s simply always-true, every time.

That matrix is the design. If changes lies by being always-true, a job you meant to sometimes-skip just always runs, which is wasteful but safe. The genuinely dangerous version would be changes wrongly evaluating to false and skipping a job that needed to run, and the way you avoid that is to only trust it where it’s reliable. So change-detection is scoped to merge-request pipelines, which is where my gate jobs already live anyway. Everywhere else, tags, schedules, the odd branch build, the jobs just run, unconditionally, because “run a job you didn’t strictly need” is a fine failure mode and “skip a job you did need” is not.

Then there are the exemptions, which are the part you have to think hardest about, because they’re where a tidy optimisation can turn into a hole. Secret-scanning does not get change-detection. Ever. The entire reason you scan for leaked credentials is that a secret can turn up in any file, including ones your changes rules would consider irrelevant, so “nothing important changed” is precisely the wrong reason to skip it. It runs on everything, always. And there’s a coupling that crosses a language boundary: the Svelte UI gets compiled and go:embed-ed into the Go binary, which means a change to the Svelte source has to rebuild the Go binary even though no .go file was touched. Naive per-language path rules would miss that entirely and ship a binary with a stale UI baked in. The change-detection rules have to know that the embed makes the frontend a dependency of the backend build, regardless of what the file extensions say.

I rolled it out one component at a time rather than flipping the whole library at once, starting with a single low-stakes pilot and widening once it behaved. Change-detection is the kind of thing that’s invisible when it works and very visible when it skips something it shouldn’t have, so easing into it beats a big-bang where a subtle mistake skips a security gate across the whole estate on day one.

You only get to skip a job when you genuinely know what changed and it’s harmless to be wrong about it. On the same runner I keep lean, the wins were real and immediate, and every one came down to answering one question for certain: did this need to run? When I couldn’t, I let it run. Skipping a job you needed is a far worse day than running one you didn’t.

Built with Hugo · Theme Stack designed by Jimmy