DevOps · Release Engineering
Fixed and Independent Versioning in a Monorepo
How a repository containing many packages decides whether they share one version or several, what the coupling does to upgrades, and how change detection assigns a bump to each package.
A repository holding many publishable packages has to answer a question that a single-package repository never faces: when one package changes, what happens to the version numbers of the others. No option is free. Fixed versioning buys coordination at the cost of meaningless version numbers; independent versioning buys precise numbers at the cost of a dependency graph that has to be re-solved and re-published piecewise.
The interesting part is not the policy choice but the change-detection machinery underneath, because the policy only applies once the tooling knows which packages a commit actually affected.
What fixed versioning actually couples
Under fixed (sometimes called locked or unified) versioning, every package in the repository shares one version number. A release increments the number once, and all packages are published at it, whether or not anything in them changed.
The properties that follow are mechanical. A dependency between two packages in the repository can be expressed as an exact version, because the two are always at the same version: internal-utils@1.42.0 depends on internal-core@1.42.0 by construction. The graph never has a partial upgrade, and two packages that must be compatible cannot drift apart. Downstream consumers of several packages from the repository upgrade all of them together, and there is exactly one release note per release rather than one per package.
The cost is that the version number stops being a statement about the package and becomes a statement about the repository. A patch bump on a package nobody changed is indistinguishable from a patch bump on the package that actually changed. Consumers who pin loosely and upgrade automatically absorb a new version of everything on every release, which magnifies the blast radius of each release. And the version number no longer satisfies the rule that a minor bump means compatible additions to that package’s surface, because it may carry an incompatible change to a different package.
The compensations are procedural: a single release tag identifies the whole set, reproducing an environment means resolving one number, and the number of distinct states the repository can be in after any release is one. For projects whose packages are consumed together, that coordination is the point.
Independent versioning and the propagation problem
Under independent versioning, each package has its own version and its own bump. A change to one package increments that package’s number, and dependents receive a bump only if the change affects them.
The immediate problem is propagation. If internal-core gains a feature that internal-utils uses, internal-utils now requires the new core, so its own dependency range must advance, which is a change to internal-utils, which may require its own bump. This cascade is a graph traversal: a change at a node propagates to every package whose published artifact references that node’s version.
The depth of the cascade is bounded by the dependency graph’s depth, and the width by its fan-in. In a repository with a deep layering the cascade can touch most of the packages anyway, in which case independent versioning has produced fixed versioning’s release scope with independent versioning’s bookkeeping. In a shallow repository with a few independent clusters, the cascade is short and the numbers stay meaningful.
A second problem is publication order. Independent publishing requires every package’s dependents to be published after it, in topological order, and a failure partway through leaves the registry in a state where some packages reference a version of a dependency that has not been published. Registries that validate dependency existence at publish time reject the attempt; registries that do not, accept a broken set. Either way, the release is partially applied and needs a resume mechanism keyed on which coordinates already exist.
Detecting what changed
Both strategies rest on the same primitive: given a commit range, decide which packages are affected. The naive version, listing every package directory touched by the diff, is wrong in both directions.
It is too narrow because a package can be affected by a change outside its directory: shared build configuration, a root-level dependency manifest, or a code-generation step whose output is checked in. It is too broad because a change to a package’s documentation or tests does not alter its published artifact, and under independent versioning a docs-only change should not produce a release.
The usual implementation builds the package graph from the manifests, including edges that cross directory boundaries, then walks the reverse graph from the set of changed paths. Each package’s own rules decide which of its paths are publishable inputs. A package whose only changes are in test files is marked affected for CI purposes and unaffected for release purposes, which is why the two decisions are usually separate predicates rather than one.
Git-level ambiguity complicates the boundary. A commit range computed as <last tag>..HEAD on a mainline that has received merges may include commits whose changes were already in the tag through another path, or exclude changes that arrived by cherry-pick. A merge-based detection that inspects the tree difference between two commits avoids the history questions entirely: compare the trees at the previous release and at the candidate, and the set of changed paths is exact. That requires the previous release point to be a real commit rather than a version number, which is why the release tag has to be placed on the commit that was built, as discussed in release trains, cut points, and hotfix branches.
Aggregating bumps across the graph
Once affected packages are known, each needs a bump level. The input is a set of declared change classes per commit, in the form described in deriving a changelog from commit messages. Aggregation takes the maximum over the commits touching a package: one breaking commit in a package outweighs any number of fixes.
The propagation rule then has two distinct modes, and confusing them is a common source of wrong versions.
When a package’s own code changes, its bump comes from the declared class of the change. When a package’s dependency advances, the bump is determined by whether the new dependency version is compatible with the old constraint. If internal-utils requires core ^1.4.0 and core releases 1.4.2, the constraint is still satisfied and no new utils version is strictly needed; the published artifact materialises the dependency at build time only if the ecosystem inlines versions. If core releases 1.5.0 and the constraint is ^1.4.0, it is still satisfied. If core releases 2.0.0, the constraint is broken and utils needs at minimum a patch of its own to widen it, which for a dependent’s consumers is a change in the transitive surface of utils even though utils’s own API is untouched.
That last case is where the two strategies behave most differently. Fixed versioning sidesteps it by construction, since the internal constraints are exact and move in lockstep. Independent versioning has to decide, per ecosystem, whether widening a range is a patch or a minor, and the answer must be consistent across the repository or consumers cannot reason about it.
Reproducing a single package’s history
A practical consequence of fixed versioning that is easy to overlook: checking out version 1.42.0 of any package gives the whole repository at that tag only if the tag exists, and the repository is large. Extracting one package from a monorepo at an old version requires either a full checkout or a registry install of the published artifact. Neither gives a browsable history of that package alone.
Independent versioning does not solve this either, since the tag naming one package’s version still refers to a whole-repository commit. Splitting the repository per package, or maintaining a mirror at publish time, is the only way to give a single package an independent history — at which point the shared tooling advantage of the monorepo disappears and the reason for having the monorepo has to be weighed again.
The decision therefore tends to settle on the ratio of shared to independent change. Packages released and consumed as a set do better with one number; packages with genuinely separate consumer bases do better with their own, provided the graph is shallow enough that the cascade stays short and the publication order stays manageable.
References
- Semantic Versioning 2.0.0 — SemVer project
- Lerna documentation — Lerna project
- Nx documentation — release and versioning — Nx project
- Bazel documentation — Build encyclopedia — Bazel project