How to Version Your Pipeline Logic Without the Fragility of GitLab Includes
I remember the morning I logged in to find 400 failed pipeline alerts because a "minor" tweak to a global lint.yml file broke every project in the organization. It was a classic case of shared-logic-turned-landmine, and it’s a rite of passage for anyone managing CI/CD at scale. We’ve been treating pipeline logic as a set of static files to be included, rather than a versioned product to be consumed.
If your GitLab CI/CD setup relies on include: project pointing to a main branch, you aren't just sharing code—you're sharing a failure point.
The Brittle Reality of include
The traditional way to reuse GitLab CI/CD logic looks like this:
include:
- project: 'my-org/infrastructure/templates'
ref: 'main'
file: '/jobs/docker-build.yml'This is fine for two repos. It’s a nightmare for twenty. When you update docker-build.yml in the template repo, every single project using it inherits that change immediately. There is no "opting in" to the new version. If you introduce a breaking change or a new required variable, you’ve just nuked everyone’s workflow.
Sure, you could use git SHAs or tags in the ref field, but managing that across dozens of repositories is manual labor that nobody actually does. We end up sticking to main and crossing our fingers.
Enter GitLab CI/CD Components
GitLab recently introduced Components (and the CI/CD Catalog). Think of a component as a "library" for your pipelines. Instead of just pulling in a raw YAML file, you’re interacting with a versioned, tested, and encapsulated unit of work.
The biggest shift here isn't just the versioning—it's the contract. Components use a spec header to define exactly what inputs they expect.
A Real-World Component Example
Let's say you have a standard Go build job. Instead of a messy YAML file, you create a component repo. Your templates/build.yml looks like this:
spec:
inputs:
stage:
default: build
go_version:
default: "1.21"
binary_name:
description: "The name of the resulting binary"
---
build_job:
stage: $[[ inputs.stage ]]
image: golang:$[[ inputs.go_version ]]
script:
- go build -o $[[ inputs.binary_name ]] ./main.go
artifacts:
paths:
- $[[ inputs.binary_name ]]Notice the --- separator. Everything above it is the metadata (the contract), and everything below it is the actual job logic. Using [[ inputs.variable ]] syntax makes it clear that this isn't just a global variable—it's a parameter passed directly to this logic.
Why This Actually Solves the Fragility
The magic happens when you release this component. You don't just push to main. You tag a release (e.g., 1.0.0).
In your application repo, you call it like this:
include:
- component: gitlab.com/my-org/go-templates/[email protected]
inputs:
binary_name: "api-server"
go_version: "1.22"Because you've pinned it to @1.0.0, you can rewrite the entire component in the source repo, and the application repo won't care. It’s shielded. When the developer is ready to upgrade to 1.1.0 or 2.0.0, they do it on their own timeline, testing it in a feature branch first.
The Secret Sauce: Testing Your Logic
One reason our old include files were so fragile is that we rarely tested them in isolation. With the Component model, the template repository is a project itself. You can (and should) have a .gitlab-ci.yml *inside* your component repo that runs a "dummy" pipeline using the component itself.
# Inside the component repo's .gitlab-ci.yml
include:
- local: /templates/build.yml
inputs:
binary_name: "test-binary"
test_component_works:
stage: test
script:
- ls test-binaryThis means you can verify that your logic works *before* you tag a release. No more using your production pipelines as a testing ground for YAML changes.
Things to Watch Out For
1. Input Strictness: If you define an input without a default value, it is required. If a consumer doesn't provide it, the pipeline will fail to lint and won't even start. This is a double-edged sword: it enforces good configuration, but it can be a shock to developers used to "loose" YAML.
2. The Catalog: If you’re in a self-managed environment, you need to explicitly designate your component project as a "Catalog resource" in the project settings to make it searchable and properly versioned.
3. Namespace nesting: GitLab expects a specific directory structure. Your templates must live in a templates/ folder within the repository for the component syntax to resolve them correctly.
Stop the Bleeding
If you're still copy-pasting include blocks and hoping for the best, start by moving one heavily used job into a Component. Define the inputs, tag a 1.0.0 release, and move one project over to it.
The goal isn't just to make the YAML look cleaner—it's to stop the "pipeline broken" Slack messages from being your primary feedback loop. Treat your CI/CD logic like the software it is: versioned, documented, and tested.