Stop Running a Three-Legged Race with Your CI/CD Pipeline

Stop Running a Three-Legged Race with Your CI/CD Pipeline

You've seen a three-legged race. Two people, each perfectly capable of running on their own, strap their legs together and try to sprint. They stumble. They lurch. They slow to the speed of whoever is having the worse day. One trips, and both hit the ground.

Nobody looks at a three-legged race and thinks, "That's how fast humans should move." But this is exactly what many teams do with their CI/CD pipelines and application code — bind them together so tightly that neither one can move without dragging the other along.

The runners aren't the problem. The rope is.

The Rope Between Your Pipeline and Your Code

Coupling, in the software sense, is the degree to which two components depend on each other. When coupling is high, a change in one forces a change in the other. When it's low, each component can evolve independently.

Most developers understand coupling in application code. You wouldn't hardcode a database connection string into every service method. You wouldn't make your authentication module reach into your billing module's internals. These are basics.

But somehow, when it comes to CI/CD, we abandon the principle entirely.

The pipeline YAML knows which directories your source code lives in. It knows the names of your build targets. It hardcodes paths to test fixtures. It assumes your app is structured a certain way — and when you restructure, the pipeline breaks.

Meanwhile, the application has its own dependencies running in the other direction. Tests that only pass inside the CI environment. Code that checks for a CI=true variable to change its behavior. Build artifacts that depend on values the pipeline injects at compile time. Feature flags driven by which deployment stage you're in rather than runtime configuration.

Two runners, roped together. One stumbles, both fall.

But Here's Where the Analogy Starts to Limp

The three-legged race is a useful image, but it's also a generous one. A three-legged race is symmetrical — both runners are equally constrained. In practice, it's lopsided. The application code drives most of the changes. The pipeline reacts and breaks. It's less like two people tied together and more like one person dragging a suitcase with a wheel that sticks.

A three-legged race is also temporary. You untie after the event and walk away. Nobody walks away from their pipeline. If anything, the binding gets tighter over time — one more hardcoded path, one more environment variable, one more "quick fix" that welds the two systems a little closer together.

And in a three-legged race, the rope is obvious. You can see it. You chose to put it there. Software coupling is invisible. You don't discover it until you try to change something and the whole build collapses, and then someone says, "Oh yeah, don't rename that folder — it'll break the deploy."

So let's switch metaphors.

Vines on a Building

Ivy on a brick facade looks great at first. A little character. A little life on an otherwise plain wall. Nobody worries about it.

But ivy doesn't stop growing. It works its way into mortar joints. It creeps under window frames and behind gutters. It pries into every gap it can find. And it does this slowly enough that you don't notice — not until you need to repaint, or replace a window, or patch the roof. That's when you discover that the vines have become structural. You can't change the building without dealing with them first. And pulling them off damages the surface they grew on.

This is how CI/CD coupling actually works. Nobody sits down and decides to tightly couple their pipeline to their application. It happens one commit at a time.

A hardcoded path in the pipeline YAML because the developer needed the build to pass now. An environment variable the app reads that only exists in CI because it was easier than wiring up proper configuration. A test that relies on pipeline timing instead of explicit setup. A deployment step that assumes the app's internal folder structure will never change.

Each one is a small vine. Individually harmless. Collectively, they make the building impossible to renovate.

The worst part is that the vines look fine from the outside. The pipeline is green. Deployments go out. Nobody complains — until someone needs to upgrade a framework, split a monolith, or swap a build tool. Then every vine becomes a wall.

What Coupling Actually Looks Like

It's worth naming the specific ways these vines grow, because they're easy to overlook when you're the one planting them.

Your pipeline is coupled to your app when:

  • Pipeline configuration hardcodes file paths, module names, or build commands that reflect the app's internal structure
  • Renaming a directory breaks the build
  • Environment variables are scattered across pipeline config and app config with no clear ownership boundary
  • The pipeline has implicit dependencies that the app doesn't declare — "it works on my machine" is the symptom
  • Deployment steps assume a specific architecture that may not hold next quarter

Your app is coupled to your pipeline when:

  • Application code behaves differently based on CI-specific environment variables
  • Tests pass in the pipeline but fail locally, or vice versa
  • Build artifacts depend on values the pipeline injects at compile time
  • Feature flags are driven by deployment stage instead of runtime configuration

If you recognize three or more of these, you're not running a race. You're maintaining a vine-covered building and hoping nobody needs to repaint.

The Abstraction That Made It Worse

There's a special kind of coupling that deserves its own section because it disguises itself as good engineering.

Someone looks across a dozen repos and notices that every GitHub Actions workflow has the same twenty lines: check out the code, set up the runtime, run tests, build an image, push it to a registry. "This is duplication," they say. "We should extract a shared action."

So they do. They create a reusable composite action (or a reusable workflow) in a central repo. Every application repo points to it. The duplication is gone. The code is DRY. It feels like progress.

And then it starts to rot.

The first repo needs a slightly different test command. So the shared action gets an input parameter with a default. The second repo needs to skip the image push for a library that doesn't get deployed. Another input, another if block. A third repo uses a different package manager. Another parameter. By the time a dozen repos depend on it, the shared action has fifteen inputs, eight conditional branches, and a README that nobody reads because it changes every week.

What happened? The repos weren't duplicating because they shared a concern. They were duplicating because they each had similar but independent concerns. The YAML looked the same, but the reasons behind it were different. Collapsing them into a shared abstraction created coupling where there was none.

Now every repo is lashed to the actions repo's release cadence. A "fix" for one consumer breaks three others that were working fine. Teams can't customize their build without forking the shared action or lobbying for yet another input parameter. The shared action has become the vine connecting every building on the block — pull it from one wall and the whole row shakes.

The warning signs are specific:

  • Pinning to @main — every consumer is coupled to the latest commit in a different repo. One bad merge breaks every downstream build simultaneously.
  • Fifteen inputs with defaults — the interface surface has grown to accommodate every consumer, which means the abstraction doesn't fit any of them.
  • Escape hatches — the moment your shared action needs an "override this step" input for one repo, you've admitted the abstraction is wrong. But instead of removing it, you bolt on another parameter.
  • Fear of touching it — "Don't update the shared action, it'll break everything" is the same three-legged-race fear, just spread across more legs.

The irony is that this coupling was created in the name of reducing duplication — a principle borrowed from application code. But in application code, you extract a shared function when multiple callers genuinely share the same behavior. When they don't — when each caller has subtly different needs — the abstraction becomes what Sandi Metz calls "the wrong abstraction." It's worse than the duplication it replaced, because now you have complexity and coupling.

Sometimes twelve repos with similar YAML is just twelve repos with similar YAML. The duplication is a coincidence, not a signal. And the right response to coincidental duplication is to leave it alone.

Cohesion: The Other Half of the Story

Coupling gets most of the attention, but its counterpart — cohesion — is what actually makes the difference. If coupling is the vines binding two things together, cohesion is the internal coordination that makes each thing effective on its own.

A cohesive application module owns its concern fully. Billing logic lives in the billing module. A change to how invoices are calculated doesn't ripple into authentication or user management. The module declares what it needs — dependencies, configuration, interfaces — but it doesn't dictate how it gets built, tested, or deployed.

A cohesive pipeline is organized around its job: build, test, deploy. Each stage is self-contained. The test stage doesn't depend on internal details of the build stage. The pipeline knows what to build, but not how the application works internally. It doesn't need to.

The goal is systems that are strong on the inside and loosely connected on the outside. High cohesion within each component. Low coupling between them.

The app says, "Here's how to build me." The pipeline says, "I'll build whatever you hand me, test it, and ship it." They meet at a clean interface — not a tangled knot of hardcoded paths and shared variables.

The Switching Yard

So what does decoupled actually look like in practice? For that, there's an analogy that doesn't require any metaphorical stretching at all: the railroad.

A freight train is a chain of coupled cars. They all move at the same speed, in the same direction, on the same track. Adding a car slows the whole train. A breakdown in one car stops everything behind it. Sound familiar? A monorepo with eight services running through a single pipeline is exactly this — a mile-long train that can't be split.

But trains were never designed to stay coupled forever. At a switching yard, cars are decoupled and routed independently. Each one goes to a different destination, on its own track, at its own speed. The switching yard doesn't need to know what's inside each car. It just needs a standard coupling mechanism and a destination label.

This is the model. Your application provides a standard interface — a Dockerfile, a Makefile, a build script. The pipeline doesn't know or care about the app's internal structure. It knows the interface. It builds, tests, and ships whatever comes through, routing each artifact to the right destination.

Each service can be decoupled and deployed on its own schedule. The pipeline and the app meet at a defined contract. Not a rope. Not a vine. A coupling mechanism designed to connect and disconnect cleanly.

Building the Switching Yard

Getting from a vine-covered building to a switching yard isn't a weekend project, but the principles are straightforward.

Define a contract between your app and your pipeline. The app provides a build interface — a Dockerfile, a build command, a test command. The pipeline consumes that interface. Neither side reaches into the other's internals. If you can describe the boundary in one sentence ("the app exposes a Dockerfile, the pipeline builds and pushes it"), the contract is probably clean.

Externalize configuration. The app reads configuration at runtime. The pipeline injects it at deploy time. Neither one hardcodes the other's values. If a variable exists in both your pipeline YAML and your app's config file, you've planted a vine.

Make builds reproducible locally. If a developer can't run the same build that the pipeline runs, coupling is hiding somewhere. The pipeline shouldn't have secret knowledge about how to build the app. Everything it knows should be derivable from the repo.

Treat pipeline-as-code with the same design principles you use for application code. Single responsibility. Encapsulation. Testability. If your pipeline config has grown longer than some of your application modules, it's taken on responsibilities that don't belong to it.

Minimize the interface surface. The fewer things the pipeline needs to know about the app — and the fewer things the app needs to know about the pipeline — the less they can break each other. Every shared assumption is a potential vine.

A Diagnostic Checklist

How do you know if you're running a three-legged race? Here are the signs:

  • Changing a pipeline file requires testing the entire application
  • Changing the app's structure requires updating pipeline configuration
  • Only one person on the team understands how the build works
  • You can't run the build locally the way CI runs it
  • Pipeline config files are longer than some of your application modules
  • The phrase "don't touch that, it'll break the build" is said without irony
  • You've been meaning to upgrade that framework for six months but the pipeline is "too fragile right now"

If you're nodding along, you've got vines on the building and a train you can't split.

Run Your Own Race

In a three-legged race, winning means coordinating despite the constraint. You practice your stride, you learn to compensate, you get pretty good at hobbling.

In software, winning means removing the constraint entirely.

Don't learn to hobble faster. Build a switching yard. Keep the vines off the walls. Let your pipeline and your application code each run on their own legs, at their own pace, toward the same destination.

The rope was never supposed to be there.