What fixed and independent monorepo versioning mean

In a monorepo, fixed versioning (also called lockstep) means every package shares one version number. When anything is released, every package is published at the new version, whether or not its code changed. Independent versioning gives each package its own version, which is bumped only when that package, or something it depends on, actually changes.

Semantic Versioning requires software to "declare a public API" and to signal changes to that API through MAJOR, MINOR and PATCH increments. In a monorepo, each publishable package has its own public API. A logging client and a database driver live in the same repository, but they are separate contracts with separate consumers.

Fixed versioning blurs that distinction. Suppose the database driver needs a breaking change. Under lockstep, the logging client also moves to a new MAJOR version, even though its API did not change. Its consumers now see a major bump, have to read changelogs to find out nothing broke, and learn to ignore the signal. The version number then describes the release train, not the package.

Teams still choose fixed versioning when packages are designed to be used together and consumers benefit from a single compatibility answer. A framework and its plugins are a typical case. Independent versioning suits packages that are consumed separately, where each version number should mean something on its own.

Aspect Fixed (lockstep) Independent
Version scope One version for all packages One version per package
What triggers a bump Any release A change in that package or its dependencies
SemVer meaning per package Weak, because bumps can be unrelated Preserved
Compatibility question "Use matching versions" Requires checking dependency ranges
Best fit Tightly coupled packages released together Loosely coupled packages with distinct consumers

Who should use which model: decide by consumers, not repo layout

Directory structure tells you nothing about how packages should be versioned. Ask who installs each package, and what that person assumes when they see a version number. Write the answer down for every publishable package before picking a model.

Fixed versioning fits when external users install packages together and expect them to match. An SDK split into a core client, transport adapters and plugins is a typical case. Users want 2.4.0 of one package to work with 2.4.0 of the others, and they should not need to read a compatibility matrix to get that.

Independent versioning fits when packages have different consumers, different release cadences or different stability levels. A mature logging library and an experimental 0.x queue client should not move in step. Each version number should say something about that package's own public API, which is what Semantic Versioning asks for.

Signal from consumers Points toward
Packages installed as a set, versions expected to match Fixed
Different teams or users depend on different packages Independent
Mixed stability (1.x alongside 0.x) Independent
One release cadence for everything Fixed

Avoid independent versioning when packages have no real API boundaries between them. If every change touches three of them, you get coordinated releases with extra bookkeeping. Also avoid it if your tooling cannot walk the dependency graph and bump dependents when a dependency changes. Avoid fixed versioning when a breaking change in one package forces a major bump on unrelated packages. Their consumers then see version churn that signals breakage that never happened.

Make this decision carefully the first time. Switching models later changes tag formats and changelog structure, and it breaks what consumers have learned to expect from your version numbers.

How independent versioning handles internal dependencies

In independent mode, each package's version describes that package alone. Internal dependencies are where this breaks down. Suppose api-client depends on core, and you release core with a fix. Whether api-client also needs a release depends on the range it declares for core. That range also determines which of two problems you get, both named in the Semantic Versioning specification.

Tight ranges lead to version lock. If api-client pins core exactly, every core release forces a new api-client release just to update the pin. That forces a release of everything that depends on api-client, and so on up the graph. A one-line fix in a leaf package can produce a dozen releases, each with a changelog entry that only says "updated dependencies."

Loose ranges lead to version promiscuity. If api-client accepts any core within a major version, nothing cascades. You are now trusting that every future core minor and patch release stays compatible. If core ships a behavior change labeled as a patch, consumers can install a combination nobody tested, and the lockfile hides it until a fresh install picks it up.

Neither setting solves the problem. A range only chooses between paying in release churn and paying in untested combinations.

Both choices leave a compatibility matrix: the set of version pairs that actually work together. Consumers need it to upgrade safely, and support engineers need it to reproduce bug reports. It is rarely written down. It exists only implicitly in CI runs and lockfiles, it changes with every release, and nobody owns it once the person who made each decision has moved on.

# packages/api-client/package.json, option A: tight range (version lock)
# Exact pin: every core release needs a new api-client release to move the pin,
# and then everything that depends on api-client must release too.
"dependencies": { "@example/core": "2.3.1" }

# packages/api-client/package.json, option B: loose range (version promiscuity)
# Caret range: accepts any 2.x.y >= 2.3.1, so nothing cascades.
# Any future core 2.x release can be installed next to this api-client untested.
"dependencies": { "@example/core": "^2.3.1" }

# What consumers and support engineers actually need to know: the implicit
# compatibility matrix. Illustrative only; no project publishes this by default.
#
#   api-client \ core   2.3.1      2.4.0      2.4.1
#   1.8.0               tested     tested     untested  (fresh install resolves here)
#   1.9.0               n/a        tested     tested
#
# Option A collapses each row to one cell but multiplies releases.
# Option B keeps releases flat but fills rows with "untested" cells.
# Either way, this table lives only in CI history and lockfiles.

The same internal dependency declared with an exact pin and a caret range, along with the undocumented compatibility matrix that either choice leaves behind.

How release automation detects what changed

Release automation cannot rely on that implicit knowledge. It has to rebuild the graph on every run from the manifests checked into the repo, then use it to decide what ships.

Step one: map changed files to packages

The diff since the last release tag gives a list of file paths. Each path maps to the package whose directory contains it. Files outside any package, such as a root lockfile or a shared build config, need an explicit rule: either mark every package as affected or none. Leaving this undefined causes drift that nobody notices.

Step two: walk the dependency graph in reverse

Take a repo where arrows point from a dependency to its dependents:

db-client -> auth-lib -> api-gateway db-client -> billing-sdk auth-lib -> admin-cli

A fix in db-client touches only one directory. The affected set, however, is db-client, auth-lib, billing-sdk, api-gateway, and admin-cli. api-gateway and admin-cli are affected because the change reaches them through auth-lib. The automation finds this set with a breadth-first walk over reverse edges, starting from every directly changed package.

Why directory-only detection fails

If the tool bumps only db-client, then auth-lib stays at its published version. That published version still declares a range on the old db-client. If the change was breaking, auth-lib now resolves a db-client it was never tested against. The reverse failure also occurs. api-gateway gets published with source that relies on unreleased code in auth-lib. Its tests passed in the monorepo, but it breaks for every consumer who installs it from the registry.

 changed file: packages/db-client/src/query.ts
        |
        |  step 1: map path to owning package
        v
 +-------------+
 |  db-client  |------------------------+       hop 0: CHANGED
 +-------------+                        |
        |                               |
        |  step 2: BFS over reverse     |
        |  edges (dependency ->         |
        |  dependent)                   |
        v                               v
 +-------------+                 +-------------+
 |  auth-lib   |                 | billing-sdk |  hop 1: AFFECTED
 +-------------+                 +-------------+
        |
        +-------------------------+
        |                         |
        v                         v
 +-------------+           +-------------+
 | api-gateway |           |  admin-cli  |        hop 2: AFFECTED
 +-------------+           +-------------+

 graph-aware set: db-client, auth-lib, billing-sdk, api-gateway, admin-cli
 directory-only:  db-client
                  (misses 4 dependents; they keep stale published
                   versions or ship against unpublished code)

A single changed file maps to db-client, and a breadth-first walk over reverse dependency edges adds billing-sdk and auth-lib at hop 1 and api-gateway and admin-cli at hop 2, all of which directory-only detection misses.

Implementing change intent, changelogs and tags

That failure is the argument for capturing change intent when the change is made, not at release time. The author knows a change is breaking at the moment they write it. Six weeks later, the release manager is guessing.

There are two common ways to record intent:

  • Structured commit messages. Put the package and bump level in the commit header, for example fix(auth-lib): reject expired refresh tokens for a patch, or feat(api-gateway)!: drop v1 routes for a major. Automation maps fix to patch, feat to minor and ! to major. This depends on squash-merge discipline. One messy commit history can silently downgrade a breaking change.
  • Per-change files. Each PR adds a small file that names the affected packages, the bump level for each (auth-lib: minor, api-gateway: patch) and a short summary written for consumers. Reviewers see the file in the diff, and CI can fail any PR that touches a publishable package without one.

Changelogs

With fixed versioning, one repo-wide changelog grouped by release is enough, because every package shares the version. With independent versioning, each package needs its own changelog. Automation should also write entries for bumps caused only by dependency updates, such as "Updated dependency auth-lib to 2.3.0". Without that line, consumers see a new version with no listed changes and can't tell why it exists.

Tags

Fixed versioning uses one tag per release (v4.2.0). Independent versioning needs one tag per package version, such as auth-lib@2.3.0, so automation can find each package's last release.

All of this output is only as good as its inputs. Vague summaries and wrong bump levels end up in published changelogs exactly as written.

# 1) Structured commit messages (squash-merge PR titles)
fix(auth-lib): reject expired refresh tokens       # auth-lib: patch
feat(auth-lib): add token introspection            # auth-lib: minor
feat(api-gateway)!: drop v1 routes                 # api-gateway: major

# 2) Per-change file added in the PR, e.g. .changes/quiet-otters-fly.md
#    Reviewers see it in the diff. CI fails publishable-package PRs without one.
---
auth-lib: minor
api-gateway: patch
---
Add token introspection to auth-lib. api-gateway now uses it
to validate session tokens instead of decoding them locally.

# 3) Generated output from the file above (independent versioning)
# packages/auth-lib/CHANGELOG.md
## 2.3.0
### Minor changes
- Add token introspection to auth-lib. ...

# packages/web-app/CHANGELOG.md  (bumped only because it depends on auth-lib)
## 1.8.4
### Patch changes
- Updated dependency `auth-lib` to 2.3.0

# Tags: one per package version, so automation can find each last release
auth-lib@2.3.0
api-gateway@5.0.1
web-app@1.8.4

# With fixed versioning instead: one root CHANGELOG.md section
# "## 4.2.0" listing all packages, and a single tag: v4.2.0

Change intent recorded as commit headers or a per-change file, and the per-package changelog entries (including a dependency-only bump) and tags that automation derives from it.

Publishing safely: idempotent releases and pre-releases

A release that publishes forty packages can fail at package twenty-three because of a network timeout, an expired token or a registry outage. With one package, you fix the cause and run the release again. With many, a naive rerun tries to publish the first twenty-two again. Most registries, including npm, reject a version number that already exists, even if it was unpublished, so the rerun fails before it reaches the packages that are still missing.

To fix this, make each publish step idempotent. Before publishing auth-lib@2.3.0, ask the registry whether that exact version exists. If it does, skip the package and move on. Do the same for tags: create the tag only if it is missing. The release job then becomes a convergence loop rather than a one-shot script. You can rerun it as many times as needed, and every run moves the registry closer to the intended state without conflicting with earlier attempts.

For release candidates, use SemVer pre-release identifiers such as 2.4.0-rc.1. SemVer orders these below 2.4.0, so consumers on normal ranges like ^2.3.0 will not pick them up by accident. On npm, also publish pre-releases under a separate dist-tag so latest stays on the last stable version.

Independent versioning makes candidates harder to coordinate. If auth-lib@2.4.0-rc.1 needs http-client@1.8.0-rc.2, the candidate must depend on that exact pre-release, because ranges do not match pre-releases by default. Treat the set of candidates as one unit for testing. When testing passes, promote every package in the set to its stable version in a single release.

#!/usr/bin/env bash
# Idempotent release loop: safe to rerun after a partial failure.
# Each package is published only if its exact version is not yet in the registry.
set -euo pipefail

for dir in packages/*/; do
  name=$(node -p "require('./${dir}package.json').name")
  version=$(node -p "require('./${dir}package.json').version")
  tag="${name}@${version}"

  # npm view prints the version if it exists and prints nothing if it does not.
  # Errors are silenced, so a network failure also looks like "missing".
  # That only means the publish below is attempted and fails loudly.
  published=$(npm view "${name}@${version}" version 2>/dev/null || true)

  if [[ "$published" == "$version" ]]; then
    echo "skip   ${tag} (already in registry)"
  else
    # Pre-releases such as 2.4.0-rc.1 go to the 'next' dist-tag so 'latest' stays stable.
    case "$version" in
      *-*) dist_tag=next ;;
      *)   dist_tag=latest ;;
    esac
    echo "publish ${tag} --tag ${dist_tag}"
    (cd "$dir" && npm publish --tag "$dist_tag")
  fi

  # Create and push the git tag only if it is missing, for the same reason.
  if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
    echo "skip   git tag ${tag}"
  else
    git tag "$tag"
    git push origin "refs/tags/${tag}"
  fi
done

A bash release loop that checks the npm registry and the git tags before each step, so a run that failed partway can be rerun and will publish only the missing packages, with pre-release versions sent to the 'next' dist-tag.

Alternatives, hybrid groups, and impact on cost and engineering time

You rarely have to pick one model for the whole repository. A hybrid setup puts tightly coupled packages into a fixed-version group and versions everything else independently. Use one membership rule: packages that change together and are consumed together belong in the same group. A client SDK and its generated types qualify, because nobody installs one without the other. Two services that happen to share a directory do not.

The models cost you in different places:

Cost driver Fixed Independent
Registry noise High: unchanged packages get new versions Low: only changed packages publish
Consumer upgrades Needless bumps with no code change Only when something actually changed
Tooling Simple: one version, one tag Dependency-graph analysis, per-package tags
Compatibility matrix Trivial: same version works together Must track which versions are compatible
Recovery from a bad publish One version to deprecate Partial publishes to reconcile

Automation shrinks the independent column. Derived versions, generated changelogs and rerunnable publishes replace the manual work of agreeing on numbers, writing release notes and fixing half-finished releases by hand.

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 releases a year, 3 engineer-hours of manual coordination per release, and 0.5 hours of review once automation is in place.

Hours saved = releases × (manual hours − automated hours) = 40 × (3 − 0.5) = 100 engineer-hours a year.

Plug in your own figures. If the result is less than the effort to build and maintain the automation, stay with fixed versioning.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to monorepo versioning, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.