TL;DR: Every infrastructure repo runs the same CI: lint the OpenTofu, scan it, validate it, plan, apply. Copy-paste that .gitlab-ci.yml between repos and you have the same drift problem in your pipeline that you would have anywhere else. The cicd repo is the fix: a library of reusable GitLab CI/CD components that repos include: and pin to a version. It is the library-first instinct, applied to the pipeline.
The .gitlab-ci.yml you keep copying
The infrastructure repos in this series all run the same CI gate jobs: format and validate the OpenTofu, lint it, scan it for security issues and secrets, and on the deploy side, plan and apply.
The first repo, you write that .gitlab-ci.yml by hand. The second repo needs the same jobs, so you copy it. The third repo, you copy it again. Now there are three copies of the same pipeline, and they do what copies always do. They drift. A fix you make in one repo’s CI does not reach the other two. A tightened scan rule lands in the repo you were working in and nowhere else. It is the copy-paste problem, exactly as it shows up in application code, just written in YAML and therefore easier to pretend is not code.
GitLab has a feature for exactly this
GitLab CI/CD Components are the answer to that problem. A component is a reusable, versioned piece of pipeline that you publish, and other projects pull in with an include: pinned to a version:
include:
- component: gitlab.com/phpboyscout/cicd/[email protected]
That is a library import, for pipeline. The component has a defined interface, a version, and a home in GitLab’s CI/CD Catalog. A consuming repo includes it instead of carrying its own copy, and when the component improves, the consumer moves a version pin rather than re-copying YAML.
Why a monorepo of components
The cicd repo holds all of the components together: tofu-lint, tofu-security, tofu-validate, tofu-plan, tofu-apply, and more. One project, not one project per component.
That is a deliberate call, and the reason is how GitLab versions things. A version is a tag, and a tag belongs to a project. A component’s version is its project’s tag. So a monorepo of components, versioned together as one tag stream, is the natural unit: a consumer pins @v0.5.0 and gets a known-good set of components that were tested together, rather than juggling a separate version for each one.
Authoring discipline
A component is a file under templates/, and it opens with a spec: inputs: block: the typed inputs, their defaults, the component’s public interface.
The discipline that keeps the library usable is that a component must be consumer-agnostic. It never hardcodes a token, and it never names a particular consumer’s variable. Inputs have sensible defaults, and a consuming repo overrides them. A component that reaches out and assumes something about the repo including it is a component that works in one repo and surprises the next. An authoring guide in the repo keeps that consistent across everyone who adds a component.
The self-test you cannot fully write
The cicd repo tests its own components with a self-test pipeline. It is worth knowing where that self-test stops.
When a repo tests its own components by running them in child pipelines, GitLab masks $CI_PIPELINE_SOURCE as parent_pipeline. A component’s rules:, which often branch on the pipeline source to behave differently for a merge request than for a branch or a tag, therefore cannot be exercised honestly by the self-test: the source they would branch on has been flattened. The self-test covers what it can, and the component rules: are, in the end, validated by real consumers using them for real. That is a genuine limit, and naming it is better than pretending the self-test proves more than it does.
The same instinct, again
This blog keeps circling the same instinct. go-tool-base exists because the same CLI scaffolding kept getting rewritten, so it was extracted into a library. cicd is that instinct pointed at the pipeline: the same gate jobs kept getting copied between repos, so they were extracted into a versioned, included library.
Stop copy-pasting. Publish, version, include. It is true for CLI code, and it turns out to be just as true for the YAML that builds and ships it.
The gist
Every infrastructure repo needs the same CI, and copying the .gitlab-ci.yml between them produces copies that drift apart. GitLab CI/CD Components fix it: reusable, versioned pipeline that a repo include:s and pins, instead of carrying its own copy.
cicd is a monorepo of those components, versioned together as one tag stream, because GitLab tags a project and a component’s version is its project’s tag. Components are authored consumer-agnostic, with typed spec: inputs: and no hardcoded assumptions, and their rules: are validated by real use because the self-test cannot see the pipeline source. It is the library-first instinct, applied to CI: publish it once, include it everywhere, fix it in one place.