Every guide on shared CI tells you how to write a reusable template. Almost none tell you how to change one you already have. The hard part was never the YAML. It is that a single edit ships to two disjoint sets of repos, on two different clocks, and your CI platform shows you neither set.
Someone on your platform team made a small, careful change to the shared pipeline.
Call the project platform/ci. It started as one file and grew into a little library: a build template, a deploy template, a security-scanning include, some shared rules. Most of the org consumes it. Nobody knows exactly how much of the org, because nothing in the product counts for you. The change itself was trivial and correct. A job called test was doing two things, so they split it and renamed the original to unit-test, the way you would tidy any file you owned. They ran it against their own repo, the pipeline went green, they merged.
Eight minutes later, the first Slack message. Then four more. Services that nobody on the platform team had ever opened were failing at the pipeline-creation step, before a single job ran, because their .gitlab-ci.yml does extends: .test and there is no longer a job called test to extend from. GitLab’s own template guide spells the mechanic out plainly: rename a job that downstream pipelines depend on and the consumer’s config immediately fails to lint, because the job it references no longer exists. The on-call engineer traced it back, the platform team added test as a thin alias for unit-test, shipped again, and the bleeding stopped. Forty minutes, start to finish. Everyone moved on.
For five months, nothing.
Then a payments service that had not been actively developed in over a year picked up an unrelated dependency bump, which meant someone finally moved its pinned ref: v1.4.0 forward to pull the fix. It inherited the eighteen months of template changes it had slept through, including the test rename, including a few others, all at once. It broke in staging on a Tuesday, and the engineer who got the page had no reason to connect it to a change the platform team had made and forgotten about two quarters earlier. The blame landed on the dependency bump, because that was the visible change. The actual cause had been sitting in platform/ci since spring.
The part I keep coming back to is that the same one-line edit broke two completely different sets of repositories, at two different times, for two opposite reasons. One set broke instantly because it was tracking the template’s branch. The other broke months later because it was not. And on the morning of the merge, the platform team could see neither set. They found out who the first group was from the Slack messages. They will never find out who the second group is, because the second group breaks alone, later, attributed to something else.
A shared CI change is two deployments wearing one merge
We talk about changing a shared CI template as one action. It is two, and they have almost nothing in common except the commit that triggers them.
The first is a deployment to every consumer that tracks a moving reference. For these repos, your merge is not a proposal they get to evaluate. It is a push straight to their next pipeline run. No rollout, no canary, no opt-in. If the change is breaking, they break at merge time, in their own projects, at whatever moment they next happen to run CI. The blast radius is the whole tracking population and it detonates the instant you click merge.
The second is a deployment to every consumer pinned to a fixed reference, and it is the strange one, because it has not happened yet and might not happen for a year. These repos are frozen on the version they pinned. Your change is real, it is merged, it is live for everyone else, and it is completely invisible to them. They will receive it at some unknowable future point, whenever someone bumps the pin, usually for an unrelated reason, usually long after anyone remembers what changed. When they receive it, they get the entire accumulated diff in one jump, decoupled from any context that would help them debug it.
So one change produces an instant, loud failure in one group and a deferred, silent, context-free failure in another. Worst of all, you do not know which of your consumers are in which group. The pin or its absence is the single most important fact about how a consumer will react to your change, and it is a fact you do not hold. This is the conflation the whole problem sits on. “Changing the template” sounds like one job with one audience. It is two deployments to two audiences on two clocks, and the only thing standing between you and a clean change is a census you were never given.
The half that breaks while you watch
The instant half is the one people have at least felt, because it shows up in the chat channel the same afternoon. The mechanics differ by platform, and the mechanics matter, because they decide how much of your org is in the instant bucket by default.
GitLab: the unpinned include detonates at merge
In GitLab CI, the ref: on a project include is optional. When you leave it out, the include resolves to the HEAD of the template project’s default branch, at pipeline creation time. That is documented behaviour in the CI/CD YAML reference, not an accident, and in practice most includes in most orgs are written without a ref because that is the shortest thing that works:
# In some-service/.gitlab-ci.yml
include:
- project: 'platform/ci'
file: '/templates/build.yml'
# no ref: tracks platform/ci's default branch HEAD
Every repo that wrote it this way is riding your main. A merge to platform/ci is not a release to them. It is an instant, org-wide deployment of CI configuration to every consumer that did not pin. GitLab says as much in its own docs: including another project’s configuration is, from a security standpoint, like pulling a third-party dependency, and no pipeline or notification fires when that other project’s files change. The dependency is real and it is silent. The default configuration is the dangerous one.
This is not a theoretical risk that only bites small teams. GitLab has lived it at the scale of the largest CI template library in existence, and the GitLab CI template edition of the Find Every Consumer series walks through the receipts: a default-branch rename that broke templates with hardcoded refs, and a GitLab engineer candidly describing blast-radius monitoring that amounts to waiting for customers to complain. If the maintainers of GitLab’s own templates operate partially blind here, the platform/ci repo in your org is not an outlier.
GitHub: a moving ref ships on the next run
GitHub Actions makes the same trap available with slightly different ergonomics. A reusable workflow or composite action is called by uses:, and unlike a GitLab include, uses: always requires a reference. So every caller pins to something. The catch is that “something” is very often a branch:
jobs:
build:
uses: my-org/ci/.github/workflows/build.yml@main # moving: next run picks up your change
# vs @v1 -> a tag, only as frozen as the maintainer's discipline
# vs @9a8b7c6... -> a full SHA, the only genuinely immutable pin
@main behaves exactly like the unpinned GitLab include. The caller picks up your change on its next run, with no opt-in. A tag like @v1 looks safer, and is the advice everyone gives, but a git tag is mutable. It is only frozen if the maintainers never re-point it, which is why GitHub’s own supply-chain hardening guidance recommends pinning third-party actions to a full-length commit SHA rather than a tag. The implication for you, the template maintainer, is the same as on GitLab: some unknown fraction of your callers are tracking a moving target and will take your next change immediately, and you cannot tell which from the inside.
The transitive case sharpens it on both platforms. Template repos include other template repos. A reusable workflow uses: a composite action that uses: another. If you change a base template that a wrapper template includes, the repos that break are the ones that included the wrapper, which may never have heard of the base. They depend on you through a layer they did not write and cannot see. Finding the direct callers is a string search. Finding the transitive ones is a graph traversal, which is the whole reason the per-ecosystem editions of this series exist: the reusable GitHub Actions workflow edition covers the call-graph mechanics in full, and they do not reduce to grep.
The half you never hear from
The pinned consumers are the half nobody plans for, because they make no noise. They are green in CI, building against the version they froze, contributing nothing to your sense of who depends on you. Every signal you have about your consumer base comes from the consumers who are active and tracking. The pinned, dormant ones are statistically invisible right up until the moment they wake up and break.
And when they break, they break in the worst possible way for debugging. They receive your change divorced from its context. The engineer on that payments service did not get “the test job was renamed three months ago,” with a changelog and a migration note attached. They got a pipeline that suddenly would not start, bundled with a dependency bump and whatever else had accumulated on platform/ci since they last pinned. The cause was a quarter old and wearing someone else’s clothes.
This is the precise failure mode the internal library deprecation post is built around, and it is worth being explicit that CI is the same problem in a harsher form. There, a service that pinned an old version of a shared package and went quiet never saw the deprecation warning, because the warning lived in a version it was not pulling. The quiet, pinned consumer is the one that breaks, in both cases, because quiet is exactly what “we pinned it and stopped thinking about it” looks like from the outside. The difference, and the reason CI is worse, is what you have to reach them with.
CI is the one shared dependency with no signal layer
Here is the part that makes a CI template change genuinely harder than a library deprecation, rather than just an instance of it.
When you deprecate a shared library, the playbook hands you real signalling machinery. It is conditional and often silent, and the deprecation post takes that machinery apart in detail, but it exists. You can add a @deprecated annotation that an active consumer’s editor will flag. You can raise a DeprecationWarning that a test suite can surface. You can cut a semver major so the removal lands on a boundary tooling is built to respect. npm will even print npm WARN deprecated on install. None of it reaches a pinned, dormant consumer reliably, which is the deprecation post’s whole argument, but for the consumers who are paying attention, there is a channel.
A shared CI template has none of this. There is no @deprecated for a job name. There is no warning you can attach to an extends target that fires when a downstream pipeline relies on it. There is no semver boundary, because most include:project and @main consumers are not pinning versions at all, and the loosely typed surface a template actually exposes, the job names, the variables, the extends targets, has no declared interface to mark up in the first place. GitLab’s newer spec:inputs gives components something like a typed contract, which is real progress, but it covers a sliver of the installed base. The fleet of include:project templates that nearly every self-managed estate runs on communicates entirely through job names and variables that no compiler checks.
Strip it down and the signalling options for a CI template change are exactly two. The instant consumers find out when their pipeline turns red, which is to say after the change has already broken them, which is not a signal so much as a casualty report. The pinned consumers find out never, until they bump. The one human channel left is an announcement in a chat channel, and the deprecation post already covers why that reaches everyone except the people you most need it to reach: the team that pinned your template eighteen months ago and reorganised twice is, by definition, the team not reading your announcement.
So for a library, signalling is the easy half of the job and the census is the hard half. For a shared CI template, the signalling half very nearly does not exist. There is no annotation, no warning, no version boundary, no typed interface, nothing between your merge and someone else’s red pipeline. Which leaves the census not as the harder half of the work. It leaves the census as essentially all of it.
Neither platform shows you the roster
Once it is clear that the signal will not assemble the consumer list for you, you go to build it by hand, and every route gets you partway to the same wall. I want to be fair to each, because some of them are genuinely useful for part of the problem.
Code search is the obvious first move and it works for a one-off. Search your group for the template path and you get matches. What you do not get is the ref, the distinction between a real include: and a comment that mentions the path, or the second hop where a wrapper template hides the forty repos behind it. On GitLab, the good version of code search is the Premium advanced search, which on self-managed you have to stand up and operate yourself. On GitHub, search will not surface reusable-workflow callers across private repos as a clean roster either. Matches are a starting point. They are not a system, and they are certainly not a list with refs and owners attached.
The platforms’ native dependency views are scoped to the wrong place. GitHub has a Dependents tab built for almost exactly this question, and it only computes dependents for public repositories. Your shared CI lives in a private org, which is the one case it does not cover. GitLab deserves real credit here, because it has started to answer the question: the GitLab 19.0 CI/CD Catalog components analytics view shows usage counts and, on Ultimate, which projects used a component recently and on which version. But look closely at what that covers. It covers include:component, resources published to the Catalog, on a thirty-day usage window derived from pipelines that actually ran, with the per-project drill-down gated to Ultimate. The include:project template fleets that nearly every enterprise estate actually runs on, including the platform/ci repo in the scene above, are out of scope. The gap is closing for components on the newest tier. For everyone else it is wide open.
Then you write the script, and several teams have, which is the strongest possible evidence that the question matters. Enumerate every project, fetch every CI config, parse the YAML, extract the includes and uses: calls, resolve the refs, handle the five different GitLab include forms and the remote includes a literal search misses, reconstruct the nested chains, page through the API, respect the rate limits, run it on a schedule, store the result. That is a real system with a real backlog, and the corner cases are now yours to maintain. The full enumeration mechanics, per platform, are exactly what the GitLab CI template and GitHub Actions workflow editions of this series document, so I will not re-derive them here. The point for this post is one level up: whichever route you take, you are trying to reconstruct, by hand, a list the platform could have kept and did not.
The list is a graph query, not a search
Strip the problem back to what you actually need before you touch a shared template, and it is small and specific. Every repository whose CI configuration references this template, directly or transitively. The reference each one declares, and crucially whether it is a moving ref or a pinned one, because that single fact sorts every consumer into the instant bucket or the silent bucket. The team that owns each repo, so you know who to route a migration to. And the order, so you change the base template before the wrappers that include it.
That is a query against a graph of dependency edges, and the only honest way to build the graph is to parse it. Read the include: entries in every .gitlab-ci.yml, resolving project, remote and component forms back to the repos they point at and ignoring local and GitLab-shipped template: includes, which are noise in a consumer map. Read the uses: in every .github/workflows/*.yml, following reusable workflows and composite actions. Read the template files that template repos themselves carry, so the nested chain from a base template to its end consumers is in the graph and not just the wrapper that includes it directly. Record the ref each consumer declares, or its absence, as a first-class fact. Parsed, not inferred. Not counted from thirty days of pipeline runs, not read off a wiki page someone last edited before the reorg, not pieced together from a chat thread. Read from the CI config that already declares the edge, because that file is the source of truth and it is also exactly where any migration is going to land.
The pin-or-no-pin column is the one that turns this from a list into a plan. With it, “who breaks at merge and who has time” stops being a guess. You can see the repos riding an unpinned include or a @main and know they take the change instantly, so they are the ones to fix or warn first. You can see the repos frozen on an old tag and know they are stranded, carrying a debt that comes due whenever they next bump, so they are the ones to migrate deliberately before they wake up and break alone. Two buckets, two plans, derived from a column the platform never gave you.
Why the template calcifies
There is a second cost to not being able to see the roster, and it is quieter than the incident, which is why it is worse over time.
When you cannot enumerate who consumes a shared template, the rational thing to do is to never restructure it. Think about what a clean refactor of platform/ci would ask of you. To rename that job properly, to collapse two templates into one, to drop a variable nobody should be using any more, you have to be willing to say “this will not break anyone,” and if you are wrong, it breaks at merge time, org-wide, in other people’s projects, with your name on the commit. “I am fairly sure nothing extends from this” is not a sentence you want to be holding when forty pipelines fail to start. So the safe move, the locally rational move, is to never make the structural change. Add a new job beside the old one instead of renaming it. Bolt a new variable on rather than reworking the interface. Leave the dead path in, just in case.
This is how shared CI templates turn into append-only sprawl. Not because nobody wants to clean them up. Because removal and restructuring are unprovable-safe, and the cost of being wrong is paid in production at an inconvenient hour by someone else. The template accretes jobs, flags, and compatibility shims, each one cheap to add and impossible to remove, until it is a file nobody fully understands and everybody is slightly afraid of. The same dynamic that leaves undead modules in a codebase leaves undead jobs in your pipeline library, for the same reason: you cannot prove the removal is safe, so you never attempt it.
A trustworthy consumer census changes the calculus entirely. When “who consumes this template, at which ref, owned by whom” is one query, restructuring stops being a gamble and becomes a scheduled piece of work. You see the four repos that extend the job you want to rename, you route the change, you watch the number go to zero, and then you rename it for real. The census is not only the thing you reach for the morning a pipeline falls over. It is the thing that lets a shared template get smaller instead of only ever larger.
Two rosters, one merge button
So, stripped down. Changing a shared CI template is two deployments wearing one merge, and the renamed job showed which audience each one reaches.
One deployment is instant and loud. It ships to every consumer tracking your branch, an unpinned GitLab include or a GitHub @main, the moment you merge, and you hear about it the same afternoon when their pipelines turn red. The other is deferred and silent. It waits, fully merged and completely invisible, on every consumer pinned to a tag, until some unrelated bump pulls them forward into the accumulated change and breaks them alone, months later, blamed on something else. The signalling machinery that would warn either group barely exists for CI: no annotation, no warning, no version boundary, no typed interface, nothing between your edit and someone else’s failed pipeline.
Which means the roster is the whole game. Not the harder half of the job, the way it is for a library. The entire job. And the roster for both buckets, the instant one and the silent one, with the pin or its absence beside every consumer, was sitting in the CI config across your org the whole time. The template that fifty projects include without a ref is the highest-leverage, least-observed dependency you own. Parse who depends on it before you press merge, because the platform will not, and the alternative is finding out from the people you should have told.
This is the query Riftmap is built to answer. Point it at your GitHub or GitLab organisation with one read-only token and it parses the CI edges across every repo: include:project, include:remote and include:component on GitLab, reusable-workflow and composite-action uses: on GitHub, plus the multi-project triggers and cross-project needs: a string search misses, while deliberately skipping the local and platform-shipped includes that are noise. It parses the template repos’ own files too, so the transitive chain from a base template to its end consumers is in the graph. Each edge carries the ref the consumer declares, or its absence, plus the file and line where it lives. Before you merge that job rename, you open the graph, click the template, and read the consumer list, sorted into the ones riding your branch and the ones frozen on a tag. You know who breaks now, who breaks later, and who to tell, instead of finding out who you should have told.
Riftmap maps cross-repo dependencies across your entire GitLab or GitHub organisation: Terraform, Docker, CI templates and components, Helm, npm, Go, Python, and more. One read-only token. No YAML to maintain. The free tier is here.
Appendix: the argument in short
Claim. Changing a shared CI template is treated as a single task and is actually two deployments. One ships instantly to consumers tracking a moving ref (an unpinned GitLab include:project, a GitHub @main) and breaks them at merge time. The other waits, silent and invisible, on consumers pinned to a tag, and breaks them alone whenever they next bump the pin, decoupled from any context. CI is worse than a library deprecation because it has essentially no signalling layer: no @deprecated, no DeprecationWarning, no semver boundary, no typed interface for job names and variables. That leaves the consumer census as not the hard half of the job but the whole of it, and the census can only be built by parsing the include: and uses: edges every repo’s CI config already declares, with the ref or its absence recorded against each consumer.
Consumer census (CI edition). The set of repositories whose CI configuration references a given shared template, directly or transitively, with the reference each one declares (and specifically whether it is moving or pinned) and the team that owns it, derived by parsing .gitlab-ci.yml and .github/workflows/*.yml across an organisation rather than from usage telemetry, a wiki, or an announcement.
FAQ.
- If I change a shared CI template, which pipelines break? Two separate groups, on two timelines. Consumers tracking a moving ref (an unpinned GitLab
include:project, which resolves to the template project’s default-branch HEAD, or a GitHubuses: ...@main) take the change on their next pipeline run and break immediately. Consumers pinned to a tag or SHA do not see the change until someone bumps the pin, then receive the whole accumulated diff at once. Neither GitLab nor GitHub gives the maintainer the list of which consumers are in which group. - How do I safely change a shared CI template used across many repos? Build the consumer census first. Parse the
include:anduses:edges across every repo, record the ref each consumer declares (or its absence), and sort consumers into the moving-ref group that breaks at merge and the pinned group that is stranded. Change base templates before the wrappers that include them. The signal will not do this for you, because a CI template has no deprecation-warning mechanism. - Why does a GitLab CI
includewithout arefget template changes instantly? Because an omittedref:on a project include resolves to the HEAD of the template project’s default branch at pipeline creation time, which is documented behaviour. A merge to that branch is therefore an immediate, org-wide deployment to every consumer that did not pin, with no notification fired. - Can GitHub’s Dependents view or GitLab’s Catalog analytics tell me who consumes my CI template? Partially, and not for the common case. GitHub’s dependency graph computes dependents for public repositories only, so a private org’s shared workflow is not covered. GitLab’s CI/CD Catalog components analytics covers published Catalog components on a thirty-day usage window, with per-project detail on Ultimate, but not the
include:projecttemplate fleets most self-managed estates actually run on.
Related reading.
- How to find every consumer of your GitLab CI template — the full GitLab-side enumeration mechanics this post sits on top of: the five include forms, nested chains, triggers and cross-project needs.
- How to find every consumer of your reusable GitHub Actions workflow — the same enumeration, for reusable workflows and composite actions.
- You deprecated the internal library. The repos still using it never saw the warning — the sibling post. Same census-versus-signal split, but for libraries, which at least have a signalling layer; CI is the limit case where that layer is gone.
- A CVE just hit your base image. Your scanner won’t tell you which repos to fix — the reactive twin: the same cross-repo-versus-single-artifact gap, forced by an external shock instead of a planned edit.
- Your senior engineer just left. Your bus factor was measuring the wrong thing — what happens when the person who held the consumer map in their head walks out.
- GitLab Orbit maps your whole SDLC. It still can’t tell you what an infrastructure change will break — why a CI
includeedge is an artifact edge a symbol graph cannot see. - The catalog maintenance trap — why the hand-maintained “consumers of this template” wiki page is wrong by the time you read it.
- What is cross-repo dependency mapping? — the glossary definition of the parsed graph this post keeps pointing at.