← All notes
Engineering note7 min read

One small change. The whole pipeline.

How a change classifier selected only the build and test jobs each edit needed, cutting build execution for isolated changes by 65–90% while keeping a full route for uncertain changes.

A one-setting change could take over an hour to reach the development environment.

Open a PR. Get it reviewed. Wait for the checks. Merge it. Wait again.

Sometimes the whole process took around an hour and twenty minutes.

We had a large test suite, and running it took time. That part made sense.

What bothered me was how little the work changed when the PR changed.

Adding one portal URL setting to two Functions configuration files triggered unrelated builds, tests, database setup, and deployment work.

The change was small.

The pipeline still did almost everything.

The old pipeline ran broad work even for documentation-only changes.

After the change, PR build execution for that settings change averaged 4m 19s, against a historical full-route average of 28m 29s.

That is build execution only. The details and caveats are below.

What did this change actually affect?

I own the infrastructure setup, so this was a delay I kept coming back to.

The useful question became:

Which parts of this pipeline does this change need?

An API change, a Functions app settings edit, a change data capture (CDC) connector change, and a functional test edit do not all need the same work.

But our pipeline treated them much the same, so unrelated jobs ran for each one.

That cost execution time. It also occupied agents that other builds needed, a pressure I had already explored in CI is not a monitoring system.

I added a change classifier to the Azure DevOps build pipeline. It reads the changed file paths and selects the relevant build and test jobs.

The background services run on Azure Functions. The CDC connector uses Debezium.

The rules select work by component:

ChangeSelected work
API filesAPI route
Background service (Functions) filesBackground service route
CDC connector filesCDC connector route
Functional tests onlyTest-only route
Shared code affecting both componentsAPI and background service routes
Pipeline definition, unclassified file (including docs/ and the root README), or deleted fileFull route

Mixed changes select every affected route. The classifier does not stop at the first matching component.

The classifier reads file paths, not the contents of an edit.

A comment in an API source file selects the API route. A comment in the pipeline definition selects the full route.

The current classifier still sends unclassified documentation files through its conservative full-route fallback.

The full-route fallback matters. A short build is useful only if we can explain why the work it skipped was unrelated.

Changed since which build?

Selecting jobs from changed files sounds straightforward.

But first, the pipeline needs to decide what to compare.

For PRs, Azure Pipelines checks out a synthetic merge commit: the proposed result of merging the PR into its target branch. The classifier compares that commit with its first parent, which represents the target branch version.

That gives it the changes the proposed merge introduces.

For main builds, comparing only with the previous run could leave a gap.

Suppose one change reaches main, but its build fails. Another change arrives, and the next build selects work only from that newer change.

The earlier change still needs a successful build.

Our classifier compares main with the last fully successful main build.

That keeps changes from failed or partially successful runs inside the comparison. They remain in scope until a fully successful run establishes a new baseline.

If that comparison is unavailable or unclear, the pipeline runs the full route.

There is no useful saving in skipping work because the pipeline could not determine what changed.

The release pipeline had to understand the result

Selecting build jobs was only part of the change.

The build also needed to publish packages selectively.

A functional test edit should not produce application packages just to satisfy deployment steps that expect them.

So I changed package publication, then updated the separate release pipeline to check which packages exist. It skips deployment steps for packages the build did not publish.

The build decides what it needs to produce.

The release pipeline deploys the packages that are present.

There is a limitation here.

The release check only knows whether a package exists. It cannot distinguish an intentional skip from a package that should have been produced but is missing.

A green build was only part of the evidence

I tested the routes with focused PRs.

I wanted to check both sides of the selection: the required jobs ran, and unrelated jobs stayed skipped.

One functional-test-only main run was a useful example.

The local and development functional tests passed.

Unit and integration test jobs were skipped. API and background service packages were not produced. Migration generation and deployment work were skipped too.

That was the behavior I wanted to verify.

A passing result alone would not have shown whether the pipeline still did all the unnecessary work.

What changed in the timings?

I checked successful full-route build records to establish a baseline:

  • 14 PR runs: average execution time of 28m 29s.
  • 11 main runs: average execution time of 49m 02s.

Then I compared the focused selective runs with those averages.

ChangeSelective PR executionReduction vs full-route PR averageSelective main executionReduction vs full-route main average
Functions app settings4m 19s85%4m 57s90%
Functional tests only9m 37s66%12m 34s74%
CDC connector10m 05s65%N/AN/A

The Functions app settings and functional test PR figures each average two runs; the CDC connector PR figure is one run. The CDC change's main run included other changes, so there is no isolated main result.

The full-route PR and main averages sum to 77m 31s of build execution. For the Functions app settings change, the selective PR average and main result sum to 9m 16s.

Those sums help explain the original wait, but they are not a measured end-to-end journey for one change.

These comparisons use historical full-route averages, not controlled before-and-after measurements of identical changes. All figures exclude agent queue time; one selective PR still waited about six minutes for an agent. They measure the build pipeline only. Review time and the separate release pipeline are outside these measurements.

What I took away from it

I started with a slow feedback loop.

The improvement came from making the pipeline more precise about what each change required.

That meant selecting jobs, carrying earlier changes forward after failed builds, publishing only the relevant packages, and making release steps skip packages the build did not produce.

The full route is still there when it is needed, or when the classifier cannot make a safe decision.

But a small, isolated change can now take a shorter route for a reason we can inspect.

Before asking how to make every job faster, I needed to ask why each job was running.