I asked platform engineers how they work out who is downstream of a shared change before it ships. Then I scanned two public organisations. In one, 149 of 149 consumers of a shared module were pinned, across six versions.
The best answer I got was not a problem
I have spent a chunk of this year sending cold emails to platform engineers with one question in them. How do you work out which repositories a shared change lands in, before it ships.
The best answers I got were not problems. They were mechanisms.
Semver as a contract, so a major bump is itself the signal. Exact version pinning everywhere, so nothing moves in anybody’s build until a human moves it. Generated configuration committed to git, so a generator’s blast radius stops being a surprise at apply time and becomes an ordinary reviewable diff. A terraform plan posted into the pull request, so the resource-level consequence is on screen before merge. Renovate on the consumer side, opening the bump PR so the loop closes without anyone chasing it.
Every one of those is a good answer to a real question, and read together they describe teams that have thought harder about change than most. I want to say that plainly before I say anything else, because the rest of this post draws a line, and a line is worthless if the ground has not been conceded first.
What I noticed, reading them back, is that not one of them answered the question I asked. I asked which repositories a change lands in. Every answer told me what happens once it lands.
Pinning is a consent control, not a blast-radius control
Pinning a dependency version is a consent control, not a blast-radius control: it decides whether a change can reach a consumer without that consumer’s agreement, and it tells the person making the change nothing about who is on the other end.
That distinction is the whole post, and it hides inside a single word. When a team says a shared change is handled, “handled” is doing two jobs at once.
The first job is the consumer’s question. Will this change reach me without my consent. Pinning answers that completely. Not partially, not probabilistically. If the reference is fixed, nothing the producer publishes enters that build until somebody in that repository edits the string.
The second job is the producer’s question. Who is on the other end of the thing I am about to change. That question belongs to whoever owns the shared artifact. The pin, the committed diff, the plan in the PR, the Renovate bump: all of them act on a change that already exists, at deploy time, in somebody’s repository. The producer’s question fires at design time, in somebody’s head, when they are deciding whether to make the change at all and what shape to make it.
One word, two questions, and every mechanism in that list answers the same one.
What the defence actually gets right
Pinning does not slow involuntary propagation down. It removes it.
That is worth stating precisely, because a post that softened it would deserve to lose the reader. A consumer on an exact reference is not less likely to be surprised. They cannot be surprised. GitHub is unambiguous about the mechanism in its own security guidance: “Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release”, and the risk it removes is that “a tag can be moved or deleted if a bad actor gains access to the repository storing the action” (GitHub, secure use reference). Since 15 August 2025 organisations can enforce it as policy, with any workflow using an unpinned action failing outright.
Semver is a genuinely good contract, and it is precise about what it is. The spec requires that “Major version X (X.y.z | X > 0) MUST be incremented if any backward incompatible changes are introduced to the public API”, and that “Software using Semantic Versioning MUST declare a public API” (Semantic Versioning 2.0.0). release-please and Conventional Commits turn that into automation: a feat! or fix! prefix computes a major bump without anybody arguing about it in a release meeting.
Committing generated configuration to git is one of the best answers I have heard to this class of problem, and it is underrated. It converts a generator’s reach into a diff a reviewer can read, moving the surprise from apply time to review time, which is where surprises belong.
Renovate and Dependabot close the last gap in that story, which is pin rot. They discover the package files in a repository on their own, open the pull request, and keep a fixed reference from silently ageing into a liability. They are both excellent at the job they are built for, and the job they are built for is the consumer’s half. Neither documents a feature that tells a publisher who consumes what they ship, and neither claims to.
Between them, these mechanisms answer the consumer’s question completely. I do not have a hole to point at in any of them.
The question that survives all of it
The producer’s question, which is who is on the other end of the thing I am about to change, survives semver, pinning, the committed diff and the Renovate bump alike, because it fires before there is a change for any of them to operate on.
The decision at that moment is not “how do I ship this safely”. It is “should I make this change, in this shape, given who is on the other end”. Rename the variable, or add a new one and deprecate the old. Split the module, or absorb the complexity. Ship the breaking version now, or carry the compatibility shim for another two quarters. Every one of those is a pricing decision, and you cannot price a change without knowing who pays for it.
Google states this better than I can, in print, as the opening move of deprecation work: “Much of the initial work of deprecation is determining who is using the old system—and in which unanticipated ways” (Software Engineering at Google, ch. 15). And Google solved it. The large-scale-changes chapter names the tool: “Google’s use of the semantic indexing tool Kythe provides a complete map of the links between parts of our codebase, allowing us to ask questions such as ‘Where are the callers of this function?’”
I want to say the limitation of that out loud rather than let it sit implied, because a reader could fairly take it as evidence the problem is already handled. Kythe is an Apache-2.0 toolkit and has been downloadable for years. What Google has is not the software. It is the operationalised result: extractors wired into their own build system, an index kept current as the code moves. Standing that up is an engineering programme, not an install. A monorepo index is also answering a structurally easier question than a polyrepo one, because everything it indexes is already checked out in one place.
The shape of the blindness is older still. Martin Fowler named it for API contracts in 2006: providers “cannot anticipate or gain insight into the ways in which consumers realise this contract”, lacking “any programmatic insights into the ways in which a community of services has adopted a contract”. We are not first to see this, and there is no credit in pretending otherwise. What is new is the layer. Not function callers, not service contracts, but Terraform source blocks, Dockerfile FROM lines, GitHub Actions container: fields and Kubernetes manifest image: fields, across repositories that were never checked out together.
The closest thing to a production answer outside Google is AWS’s own feedback loop for shared libraries, which names the producer’s motivation directly: “For producers of shared libraries, it’s important to get quick feedback in case of an involuntarily added breaking change.” What a major vendor’s engineering team built for that, out of EventBridge, Lambda and CodeBuild, still fires after publish, when a downstream consumer’s own build has already failed, rather than upstream of the change.
A mechanism is not an outcome
Several of the strongest answers platform engineers gave me contained a piece of manual work that nobody flagged as work. The mechanism was never the part that stayed with me. That was.
Before a breaking change lands, somebody goes and looks. They enumerate the consumers by hand. Every time. It was never presented as a gap, because it does not feel like one. It feels like diligence, and it is diligence. It is also a list that gets rebuilt from scratch on every release, is written down nowhere, and lives in whoever currently holds the shape of the estate in their head.
That is what tribal knowledge looks like at the artifact layer. Not a missing document. A recurring act of memory.
Where pinning structurally cannot reach
There are four places where a producer still cannot see who consumes their artifact no matter how well every consumer pins, and not one of them is a failure of discipline.
Nobody tells the producer who pinned
Pinning is a consumer-side decision, taken repo by repo, by whoever wrote the line. The producer gets no vote and no notification. A :latest in somebody else’s Dockerfile is a fact about their repository, and nothing in the pin’s mechanism, or in the absence of one, reports back to the repository that produces the artifact. So “we pin everything” is not a fact a producer holds. It is a claim about the repositories they can currently see, and the only thing that turns that claim into knowledge is somebody going and looking. Nothing in the pinning mechanism makes anybody go and look.
The pinned are not in one place
Perfect pinning discipline does not produce one version. It produces a distribution. A pin is a moment, not a coordination: each consumer freezes at whatever was current the day somebody wrote the line, and nothing in the mechanism ever moves them forward again. That is the point of the mechanism, and it works. It also means the better an organisation’s pinning discipline, the more distinct versions of a shared artifact are live at once. “Everybody pins” and “everybody is on the same version” are closer to opposites than to synonyms, and the producer is simultaneously the only party for whom the spread matters and the only party with no way to see it from inside the mechanism that produced it.
The artifacts nobody can pin
Some artifacts have no pin on offer at all. A GitHub Actions runs-on: binds a job to a label, not to a machine or an image build, and the image behind that label moves on its own clock. Somebody asked. actions/runner-images#13034, opened 17 September 2025, requests a runner image pinnable to a specific dated build. Closed as not planned.
The runner fleet is the cleanest case that pinning is a privilege of certain artifact types rather than a universal control. Nothing I have built reads runs-on: either, so this is a problem I am describing rather than solving.
The edge that crosses ecosystems
One artifact is reached through several different grammars, and search is per-grammar. The same base image can be a Dockerfile FROM line in one repository, a job-level container: field in a workflow in another, and an image: field in a raw Kubernetes container manifest in a third. A producer who greps for one has answered a question about one ecosystem.
What this looks like in real organisations
On 25 August 2026 I scanned two public GitHub organisations, cloudposse and prometheus, and asked the same two questions of the most-consumed shared artifacts in each: how many consumers sit on a fixed reference, and how many distinct versions the fixed ones sit at.
None of this rests on what anyone told me. A mechanism answer names the tools a team owns, not the incidents it has survived, and it is the answer I would give a stranger who emailed me out of the blue. Conversations tell you what a team believes about its estate. Only a scan tells you what the estate is. So everything below is measured, public, dated, and reproducible by anyone who wants to check it.
One methodology note first, because the reclassification decides the headline and it was applied evenly in both directions. A Terraform source written with a tags/ prefix is an exact pin and is counted as fixed, even though the prefix makes it look like a ref:
# cloudposse/terraform-aws-iam-assumed-roles, main.tf line 2
source = "git::https://github.com/cloudposse/terraform-null-label.git?ref=tags/0.3.3"
And a Dockerfile FROM with no tag at all is counted as floating, even though nothing in it says latest, because Docker resolves it to :latest at pull time regardless. Same run, same rules, both organisations.
One organisation pins perfectly and is still spread across six versions
cloudposse/terraform-null-label is the most-consumed Terraform module in that organisation, declared through a git-sourced Terraform source block by 149 other repositories. 149 of those 149 consumers are on a fixed reference. Zero float. Zero use a range.
Those 149 sit on six distinct versions. 141 are on 0.25.0, the current release, which is 94.6% of them. The rest are 0.3.3 on three repositories, 0.3.1 on two, and one each on 0.3.7, 0.3.5 and 0.5.3. Eight consumers, 5.4%, sit on five versions from early in the module’s release history, and every single one of them is pinned as tightly as it is possible to pin a Terraform module.
This is the entire argument in one artifact. The pinning defence worked. It worked perfectly, across 149 repositories, without one exception. And the producer’s question is wide open by measurement rather than by assertion: if you own that module and you are deciding whether to change it, you are deciding for consumers standing at six different points in its history, and nothing in the pinning mechanism told you that.
It is not a one-module fluke either. Across twelve cloudposse modules and 239 individual consumer relationships in the same run, a genuine floating or range constraint appears exactly zero times. On every module in that sample, the organisation’s own “we pin everything” turns out to be true, and it is true because somebody went and measured it, not because anybody asserted it.
The 149 and the six-way spread are both already published, in how to find every consumer of your Terraform module, from a 7 August 2026 scan of the same organisation. What is new here is the pinned-versus-floating axis.
The same run, in another organisation, produced the mirror image
ghcr.io/prometheus/busybox is a base image the Prometheus project produces itself, referenced by 19 repositories across the prometheus organisation. 19 of those 19 are unpinned. Eighteen reference it at the floating :latest tag. The nineteenth, prometheus/compliance, carries no tag at all, which Docker resolves to :latest at pull time regardless.
A tool can report that as nineteen consumers on latest, and that phrasing reads like a clean bill of health. It is the opposite of one. It means every consumer is on the floating tag, which is not the same thing as verified current: nobody has said which version they want, so nobody knows which version they have. And the producer still has to go and find all nineteen of them.
Cut those same nineteen along a different axis, by file type rather than by tag, and they split too. Eighteen are Dockerfile FROM lines. The nineteenth is an image: field inside a raw Kubernetes container manifest in prometheus/test-infra. One artifact, two ecosystems:
# prometheus/pushgateway, Dockerfile line 3
FROM quay.io/prometheus/busybox-${OS}-${ARCH}:latest
# prometheus/test-infra, a Kubernetes container spec
image: quay.io/prometheus/busybox:latest
Now look at what those two lines do to a search. Both say quay.io, while the artifact’s canonical name is ghcr.io/prometheus/busybox, because the project migrated registries and the manifests still carry the old domain. And the Dockerfile does not contain the image name as a literal string at all: it is assembled from ${OS} and ${ARCH} at build time. So the same edge crosses two registry domains as well as two manifest languages, a grep for the canonical name matches none of the nineteen, and a resolver that unifies both constructs and both domains onto one artifact identity finds all of them.
Set the two poles beside each other and the thing they share is not a pinning state. Pinned, the change reaches nobody until somebody lets it in, and you have to go and find six versions’ worth of consumers to price it. Unpinned, it reaches all nineteen at once and without their consent, and you have to go and find them to warn them. The pinning state changes the shape of the producer’s risk. It does not change the need for the list, and neither state supplies one.
The version spread and the bus factor are the same problem
A version spread is only a problem because somebody has to hold it, and that somebody is often one person. cloudposse/terraform-aws-s3-log-storage has 7 dependent repositories in the same organisation, spread across five distinct versions: 1.4.1, 1.4.2, 1.4.3, 1.4.5 on two repositories and 2.0.0 on two. Over the trailing twelve months, that module is entirely the work of one author. Bus factor of one, 100% of the commit share.
Seven repositories depend on what one person wrote, from five different points in its history, and the only place that consumer list has ever existed as a list is in somebody’s head. That is the dependency that used to live only in the head of the engineer who just left, with a number attached to it. I wrote up the whole organisation elsewhere, and how it writes its version pins, and neither of those measurements is recorded anywhere in the organisation itself.
The partial answers that already exist
Partial answers to the producer’s question, who consumes the artifact I am about to change, already exist in several corners. A careful reader will have been listing them for several sections now.
A disciplined review-gate culture routes around the question rather than answering it, and that is a genuine alternative rather than a dodge. If every version bump is a reviewed pull request on the consumer side, and Renovate makes that cheap and default, a producer can reasonably argue they do not need the list in advance: each consumer’s own review gate catches the problem when it arrives. That works, and works well, when consumers are few and internal and their reviewers know the module. Where it stops is not “you might miss something”. It is that distributing the question sets the producer’s price of the change at zero. Each consumer pays an unbudgeted cost at a time they did not choose, and the person who decided the change was worth that cost could not see it. Fine when the total is small. A poor way to decide whether to split a module that 149 repositories consume.
GitHub’s dependency graph enumerates consumers, for public repositories. In GitHub’s own words: “For public repositories, the dependency graph lists dependents. These are other public repositories that depend on the repository or on packages that it publishes.” The same page draws the boundary just as plainly: “This information is not reported for private repositories” (about the dependency graph). Two things worth keeping straight. This is a dependency-graph feature, not a Dependabot one, and mixing them up is the kind of error this audience catches instantly. And the one producer-side enumeration feature the GitHub ecosystem ships requires the artifact to be public, which is exactly the case an internal shared module is not.
HCP Terraform Explorer and Scalr’s modules report both answer the question inside their own execution boundary. Explorer is organisation-scoped, and it got materially better this year: as of 14 April 2026, “Explorer now surfaces remote modules sourced from GitHub, Bitbucket, S3, GCS, and other VCS or HTTP hosts alongside registry modules.” Scalr’s report “shows you the modules and their versions from the remote source (module registry or git) that are being used across all of your workspaces in the account” (Scalr docs). Neither vendor publishes this as a limitation, and I am not going to put the words in their mouths: as far as their own documentation describes it, what each knows is the set of workspaces registered with it, and the relationship each models is workspace-to-module rather than repo-to-module.
Backstage can show the relationship, for whatever a human wrote down. The software catalog has consumesApi/apiConsumedBy and dependsOn/dependencyOf relations, generated from fields like spec.consumesApis and spec.dependsOn a team puts into its own catalog-info.yaml. That is a real answer to a real need. It is also registered rather than parsed, and it is accurate as of the last person who remembered to edit the file.
Terraform 1.15 shipped a producer-authored breaking-change signal, and it fires at the consumer. Since 29 April 2026, “You can set a deprecated attribute on variable and output blocks to indicate that they are deprecated. This will produce warnings when passing in a value for a deprecated variable or when referencing a deprecated output.” HashiCorp is explicit about when: “a warning diagnostic will be issued during validation” (HashiCorp blog). The module author writes the message. Each caller sees it at their own next validate or plan. It never comes back to the author as a list.
So the honest shape is not a vacuum. Partial, scoped answers exist in several corners: public-only, execution-scoped, registered by hand, consumer-triggered, or distributed onto the consumers themselves. A general one for a polyrepo GitHub or GitLab estate, parsed from the manifests those repositories already execute, org-wide and across ecosystems, does not appear to.
Who genuinely does not need this
Three kinds of team can correctly decide that producer-side consumer mapping does not apply to them.
The small team where the module author personally knows every consumer. Not as a metaphor for discipline. Literally: they can name all six repositories without looking. My own scan found this shape. cloudposse/terraform-aws-route53-alias has four consumers and all four sit on 0.13.0. cloudposse/terraform-external-module-artifact has four consumers and all four sit on 0.8.0. Genuinely uniform, both of them, at a size where uniform is not hard. Below roughly fifty repositories, grep and memory genuinely work.
The fully-consolidated estate. If every workspace that runs your Terraform is registered in one HCP Terraform organisation, or runs through one Scalr account, the tools above answer the producer’s question and you do not need me for it. That is not a hedge before a pivot. The only question left is whether that boundary is the same shape as your estate, and it stops being the same shape the moment there is an Atlantis holdout, a team running raw CLI against an S3 backend, a second VCS, or an artifact that is not a Terraform module.
The monorepo. Google’s answer works, and it is a monorepo answer. If your code is in one repository with one build graph, the build graph already knows the callers and you should use it.
If you are not one of those three, the useful next move is to measure rather than reason about it. There are public graphs of a few real organisations in the showcase, built from public repositories, which is a cheaper way to see what a real estate’s version spread looks like than pointing anything at your own first. If you would rather have this measurement on your own estate than on somebody else’s, hello@riftmap.dev reaches me directly.
Riftmap
Riftmap is the productised version of the measurement in this post, and two limits bound what that means. Riftmap can tell you a consumer sits on a floating reference. It cannot tell you that the reference moved three days ago. It tracks resolution confidence, not freshness: each scan re-resolves from scratch and the previous scan’s state is not kept to diff against, so there is no signal at all for whether a :latest shifted under you last Tuesday. And it does not read runs-on: at all. The runner-fleet gap in this post is one I share, not one I close.
What it does do is parse the manifests an organisation already executes, across a whole GitHub or GitLab org from one read-only token, and store the version constraint on the edge itself rather than only the fact of the edge. So “who consumes this, and at what version” is a query instead of a research project. Terraform source, Dockerfile FROM, GitHub Actions uses: and container:, Kubernetes manifest image:, and the rest of the parser set. Parsed, not inferred, and no catalog to maintain.
Two different questions
Pinning answers whether a change reaches you. It never answers who a change reaches.
The 149 repositories in that scan did everything right. Every one of them made a deliberate, explicit, exactly-fixed choice about a shared module, and not one of them can be moved by anybody but themselves. That is what a consent control looks like when it is working, and it is genuinely worth having. It is also precisely why, on the day somebody decides to change that module, the list of who they are about to change it for exists nowhere but in somebody’s head. And people leave.
The second question does have answers now: for a public repository, for a monorepo, for an estate that fits entirely inside one vendor’s execution boundary. What none of the three gives you is a general answer parsed from the manifests those repositories already execute. Most polyrepo estates are not any of those three shapes. Consent is not a map.
Questions engineers actually ask
If we pin all our dependency versions, do we still need cross-repo dependency mapping?
Yes, because the two things answer different questions. Pinning is a consumer-side consent control: it guarantees that no upstream change reaches your build until somebody in your repository edits the reference. It tells the person publishing that change nothing about who consumes it, or at what version. In a 25 August 2026 scan of the public cloudposse organisation, all 149 consumers of terraform-null-label were on a fixed reference and they still sat on six distinct versions. Perfect pinning discipline alongside an unanswered producer-side question is the normal case, not a contradiction.
How do I find every consumer of a shared module before I ship a breaking change?
Parse the manifests rather than searching for the string. Every consumer declares the dependency in a file its own toolchain already executes: a Terraform source block, a Dockerfile FROM line, a GitHub Actions container: or uses: field, a Kubernetes manifest image: field. Reading those across a whole organisation gives you the consumer list and the version each one holds, and it gets that list out of one person’s head: in the same 25 August 2026 scan, cloudposse/terraform-aws-s3-log-storage had 7 dependent repositories on five distinct versions and a single human author across the trailing year. Grep misses the consumers that reach the artifact through a different registry domain or assemble the name from build-time variables, and GitHub’s Dependents tab covers public repositories only.
Does Renovate tell you what a breaking change will cost your consumers? No, and it does not claim to. Renovate and Dependabot are consumer-side tools: they discover the package files in a repository and open pull requests to update the dependencies that repository consumes. Neither documents a producer-side view enumerating who consumes a package or module you publish. GitHub’s dependency graph does have a Dependents tab that enumerates consumers, but it belongs to the dependency graph rather than to Dependabot, and GitHub’s own documentation states that this information is not reported for private repositories.
What does pinning a version not protect you from?
Pinning protects you completely from anything the producer you pinned publishes arriving without your consent, and not at all from being the person making the change. Pin a Terraform module to 0.25.0 and you have said nothing about what 0.25.0’s own source blocks resolve to, which is the transitive-exposure and stale-vulnerability half of this question. The other half is the producer’s: pinning does not tell them who consumes their artifact, at which versions, or through which ecosystems. It also does not cover artifacts with no pin on offer: a GitHub Actions runs-on: binds to a label rather than to an image build, GitHub updates the tooling in its own runner images weekly, and the request to pin a runner image to a specific build (actions/runner-images#13034) was closed as not planned.
About Riftmap
Riftmap maps cross-repo dependencies across your entire GitLab or GitHub organisation — Terraform, Docker, CI templates, Helm, and more. One read-only token. No YAML to maintain.