Fixed vs independent versioning: what each mode means

Fixed versioning gives every project in a set one shared version number. When any project changes, they all get the next version and are published together. If auth, logging and db-utils are fixed at 3.2.0 and only logging gets a fix, all three are released as 3.2.1.

Independent versioning versions each project from its own changes. A fix to logging bumps only logging. The other libraries keep their versions until their own code changes.

The two modes tell consumers different things. A fixed version says "these packages were built and tested as a unit, so use matching numbers." An independent version says "this number describes this package's API and nothing else." Because a fixed set moves as one, every bump looks like a change to every package, even packages whose code did not change. With independent versions, a major bump points to a real breaking change in that specific package.

Most backend monorepos contain both kinds of code. Tightly coupled runtime libraries should be upgraded together. Loosely coupled clients and utilities evolve at their own pace. One global mode forces a bad fit on one of those groups. Release groups let you pick a mode per set of projects.

Concern Fixed Independent
Consumer signal "Use these together" "This package's API changed"
Version inflation High: unchanged packages bump anyway Low: bumps only on real changes
Cognitive load One number to track One number per package
Support burden ("which versions work together?") Trivial: matching numbers Needs a compatibility matrix or peer ranges

Who should use release groups

Release groups are for teams that publish several libraries from one Nx workspace and need more than one versioning strategy. Typical users:

  • Platform teams that own a set of foundational backend libraries (logging, config, database access) consumed by many services, and that also host loosely related packages in the same repo.
  • Teams publishing generated or hand-written service clients next to shared core libraries, where the clients have different owners or release cadences from the core.
  • Workspaces already using Nx Release with a single global version, where consumers complain about version bumps that contain no changes for them.

They are not worth the setup if you publish one package, if every package in the repo is genuinely coupled and released together, or if you don't publish libraries at all and only deploy applications. In those cases a single global release configuration, or no release configuration, is simpler.

When to use release groups

Introduce groups when one of these is true:

  • Coupling differs inside the repo. Some libraries must be upgraded together, others stand alone. A single fixed version inflates versions for the standalone ones; a single independent mode leaves consumers guessing which core libraries work together.
  • Release cadences have diverged. One team wants to ship a client several times a week while the core platform releases on a slower, reviewed schedule.
  • Consumers are paying for no-op upgrades. Services that pin your libraries rebuild and redeploy on bumps that changed nothing they use.
  • Changelogs have become noise. A consumer of one package has to read entries for packages they never install.

Hold off while the repo is small and has one owner, or while you are still settling on which libraries belong together. Group membership affects version history, so it is easier to decide once boundaries are stable than to move projects between groups later.

How Nx Release works with groups

Nx Release splits a release into three phases, which you can run together with nx release or separately with nx release version, nx release changelog and nx release publish:

  1. Version. For each project, Nx determines the current version (for example from the latest matching git tag or from the project's manifest, depending on configuration) and the next version. The next version comes either from a specifier you pass, such as patch, or from the conventional commits made since the last release when conventional commits are enabled. Nx then writes the new version into each project's manifest and updates dependency ranges in projects that depend on it, using the workspace project graph to find those dependents.
  2. Changelog. Nx generates changelog entries from the same commits and, unless told otherwise, commits the changes and creates git tags using the configured tag pattern.
  3. Publish. Nx runs the publish step for each released project, for npm packages uploading the versions now on disk to the registry.

Groups change how the version phase is computed. Nx processes each group separately. In a fixed group, it works out the largest bump required by any member and applies that single version to every member. In an independent group, it computes a bump per project from the commits that touch that project's files. Changelog and tag settings are also applied per group, which is why tag patterns must keep each group's history distinguishable. Adding --dry-run runs the same computation and prints the result without writing files, creating tags or publishing.

How release groups work in nx.json

All Nx Release configuration lives under the top-level release key in nx.json. Inside it, the groups object is where you split your workspace. Each key in groups is a group name you choose, such as platform-core or service-clients. Its value describes which projects belong to the group and how they are versioned together.

Selecting projects

A group's projects field accepts explicit project names, glob patterns that match project names, and tag selectors in the form tag:some-tag. Tags usually age best for backend libs. A new library tagged scope:platform-core joins the right group with no edit to nx.json. Name globs only work if your naming convention holds.

Per-group behavior

Each group sets its own projectsRelationship:

  • fixed means every project in the group shares one version and moves together.
  • independent means each project is bumped on its own.

Versioning and changelog settings can also be set per group. That is why one workspace can run both strategies side by side without compromise.

One project, one group

A project should match exactly one group. Overlapping selectors leave Nx with two competing answers for a single package's next version. A broad libs-* glob next to a tag: selector that catches the same library is a common cause. Depending on the Nx version, this shows up as an error or as a release that skips or mis-versions packages. Keep selectors disjoint, and check membership whenever you add a project.

Release options have been renamed and restructured across Nx versions. Pin your Nx version, and confirm group membership and computed versions with a dry run before trusting a config change.

{
  "release": {
    "groups": {
      "platform-core": {
        "projects": ["tag:scope:platform-core"],
        "projectsRelationship": "fixed"
      },
      "service-clients": {
        "projects": ["tag:scope:service-client"],
        "projectsRelationship": "independent"
      }
    }
  }
}

A minimal nx.json release block with two groups selected by disjoint tags: platform-core libs share one fixed version, while service-clients are versioned independently. Confirm membership with a dry run first.

Deciding which backend libs go in which group

Start from how consumers upgrade, not how the repo is laid out. The question for each library is: when this package changes, must its consumers change something else at the same time?

Put packages in a fixed group when the answer is yes. A framework core and its plugins are the typical case. If platform-core changes a lifecycle hook, every plugin must ship a matching version, so one shared number saves consumers from working out compatible combinations. Avoid fixed when the coupling is weak. A typo fix in one package then bumps every package in the group, and services that pin those packages get version updates, rebuilds and redeploys that contain no change for them.

Put packages in an independent group when they stand alone: small utilities, internal clients for different services, or libraries with different owners who release on their own schedules. Avoid independent when consumers cannot tell which versions work together. If people keep asking "does client 4.2 work with core 3.7?", the packages are probably coupled and belong in a fixed group.

Skip mixing groups entirely in small repos with one owner. A single global fixed version is easier to reason about, and the extra configuration only pays off once teams or release cadences diverge.

Situation Recommended setup
Core plus plugins upgraded together Fixed group
Unrelated utilities or per-service clients Independent group
Small repo, one owner One global fixed version
Many unrelated packages, many owners All independent
Separate release tooling, access or lifecycle needed Separate repositories

Worked example: a fixed platform-core group and an independent service-clients group

Consider a repo with three foundational libraries (logger, config, db) and two generated clients (billing-client, orders-client). Conceptually, the release section of nx.json declares two groups. platform-core lists the three foundational projects with a fixed relationship. service-clients lists the two clients with an independent relationship.

platform-core (fixed)          service-clients (independent)
+---------+                    +----------------+
| logger  |<-------------------| billing-client |
| config  |                    +----------------+
| db      |<-------------------| orders-client  |
+---------+                    +----------------+

A fix in db. Because platform-core is fixed, logger, config and db all move to the same new version, even though only db changed. Consumers can then pin one version for the whole platform layer.

A fix in billing-client. Only billing-client is bumped. orders-client keeps its version, so its consumers see no release.

Dependency edges change the math. When platform-core bumps, orders-client depends on db, so its declared range for db must be updated. That manifest change usually needs its own patch release, or the published package keeps pointing at the old range. billing-client depends only on logger. Whether it also gets a patch depends on how your config treats dependents.

Cross-group pitfalls:

  • Stale ranges. A client keeps advertising an old db range after db ships a fix.
  • Overly loose ranges. A range such as *, or a wide caret, lets consumers resolve a core version the client was never tested against.

Before you rely on this setup, run a dry run on a change to db. Confirm which clients get bumped and how their dependency ranges are rewritten.

cat > nx.json <<'EOF'
{
  "release": {
    "groups": {
      "platform-core": {
        "projects": ["logger", "config", "db"],
        "projectsRelationship": "fixed"
      },
      "service-clients": {
        "projects": ["billing-client", "orders-client"],
        "projectsRelationship": "independent"
      }
    }
  }
}
EOF

npx nx release version patch --projects=db --dry-run

npx nx release --dry-run

An nx.json release config with a fixed platform-core group and an independent service-clients group, plus dry-run commands that show which clients a db fix bumps and how their dependency ranges change.

Changelogs, conventional commits and git tags per group

Changelogs follow the same split as versions. A fixed group releases as one unit, so it gets one combined changelog. Every change to any member appears under a single version heading, which matches how consumers adopt platform-core: they upgrade the whole set at once. Independent projects each get their own changelog, because a billing-client consumer has no use for search-client entries.

Version bumps come from conventional commits. Nx reads the commits since each project's last tag. A fix: commit produces a patch, a feat: commit produces a minor, and a ! marker or BREAKING CHANGE footer produces a major. In a fixed group a commit only has to touch some member, and the whole group moves anyway. Under independent versioning, Nx assigns each commit to projects based on the files it changes. A commit that touches several clients bumps all of them at the level its type declares. Keep commits scoped to one project and use the scope field honestly, for example fix(billing-client):. Then reviewers can see from the history what each release will do.

Mistyped commits are the main risk. A stray ! publishes a major version. Once consumers install it, you can't take it back cleanly; you can only deprecate it and publish again. Add commit linting in CI, and read the dry-run output before you merge.

Tags must be unique and resolvable. A fixed group needs one tag per release, such as platform-core-v{version}. Independent projects need the project name in the tag, as in {projectName}@{version}. Without it, two clients at 1.4.0 would collide on the same tag. Nx would also find the wrong previous release when it calculates the next bump.

Conceptually, the config looks like this:

  • platform-core group: fixed, with one group changelog and a group-level tag pattern.
  • service-clients group: independent, with per-project changelogs and a tag pattern that includes {projectName}.
// nx.json (excerpt, conceptual). Nx parses nx.json with comments allowed.
{
  "release": {
    // Bump levels come from conventional commits since each tag:
    // fix: -> patch, feat: -> minor, ! or BREAKING CHANGE -> major
    "version": { "conventionalCommits": true },
    // Group changelogs replace the workspace-wide changelog
    "changelog": { "workspaceChangelog": false },
    "groups": {
      "platform-core": {
        // Members always share one version
        "projects": ["core-config", "core-logging", "core-db"],
        "projectsRelationship": "fixed",
        // One tag per release for the whole group
        "releaseTagPattern": "platform-core-v{version}",
        "changelog": true
      },
      "clients": {
        // Each client is versioned on its own
        "projects": ["billing-client", "search-client"],
        "projectsRelationship": "independent",
        // Project name in the tag keeps tags unique and lets Nx
        // find each project's previous release correctly
        "releaseTagPattern": "{projectName}@{version}",
        "changelog": true
      }
    }
  }
}

// Preview bumps, changelog entries and tags before releasing:
// npx nx release --dry-run

A conceptual nx.json release config with a fixed platform-core group and an independent clients group, each with its own changelog setting and tag pattern.

CI publishing flow: dry runs on PRs, real releases on main

The pipeline has two modes, and branch decides which one runs.

Pull requests: preview only

On every PR, run nx release --dry-run. It computes the bumps each group would get from the conventional commits on the branch and prints the changelog entries. It writes no files, creates no tags and publishes nothing. Reviewers see that a feat: in service-clients/billing bumps only that client, while a fix: anywhere in platform-core bumps the whole group. If the preview shows an unexpected major bump, the commit message is wrong, and the PR is the cheapest place to fix it.

Main: version, then publish as a separate step

Library publishing is not atomic across packages. If the registry returns an error or a token expires after three of eight packages are uploaded, those three stay published and the rest do not. Nx cannot roll that back. Split the job so a failure is easy to recover from:

  1. nx release --skip-publish bumps versions, writes changelogs, commits and creates tags. Push the commit and tags.
  2. nx release publish publishes the versions now on disk. Scope it with --groups if needed.

Step 2 depends only on committed state, so you can rerun it on its own. To make retries idempotent, check before each upload whether that exact name@version already exists in the registry, for example with npm view, and skip it if it does. That way a retry publishes only the packages that are missing.

Don't patch by hand

Don't fix a partial release by editing versions or moving tags manually. Nx works out the next version from those tags. A hand-edited tag can lead to a skipped version, or to a later run trying to publish a version the registry already has. If a release is broken, rerun the publish step or ship a new patch version.

#!/usr/bin/env bash
set -euo pipefail

if [ "${CI_BRANCH}" != "main" ]; then
  # PRs: preview bumps and changelogs only. No files, tags or publishes.
  npx nx release --dry-run
  exit 0
fi

npx nx release --skip-publish
git push origin HEAD
git push origin --tags

declare -A PROJECTS=(
  [platform-core]="libs/platform-core/package.json"
  [billing-client]="libs/service-clients/billing/package.json"
)

missing=()
for project in "${!PROJECTS[@]}"; do
  pkg_json="${PROJECTS[$project]}"
  name=$(node -p "require('./${pkg_json}').name")
  version=$(node -p "require('./${pkg_json}').version")
  # Empty output means this exact name@version is not in the registry yet.
  if [ -z "$(npm view "${name}@${version}" version 2>/dev/null)" ]; then
    missing+=("$project")
  else
    echo "skip ${name}@${version}: already published"
  fi
done

if [ ${#missing[@]} -gt 0 ]; then
  npx nx release publish --projects="$(IFS=,; echo "${missing[*]}")"
fi

A CI script that runs nx release --dry-run on pull requests. On main it versions and tags first, then publishes only the packages whose exact name@version is not already in the registry, so the publish step can be rerun after a partial failure.

Migrating from one global version, and the impact on cost and engineering time

Migrating without breaking consumers

Start each library that becomes independent at the current global version rather than resetting to 1.0.0 or 0.x. A lower number looks like a downgrade to package managers and to the people reading lockfiles. From that point, each library's version moves only when its own code changes.

Before the first mixed release, set a distinct release tag pattern per group. A fixed group can share one tag per release, but independent projects need the project name in the tag. If two patterns can resolve to the same string, version resolution reads the wrong history. Announce the change in the changelog and the README of each affected package. Consumers who pinned every library to one number will see versions drift apart and may assume something broke.

Treat the grouping as a long-term decision. Moving a library from independent back to fixed, or the reverse, rewrites its version history. That can justify a major bump so consumers re-check their constraints.

Cost Saving
Config Groups and tag patterns in nx.json Release steps that are repeatable
Maintenance Review groups when libs are added or renamed Changelogs generated automatically
Discipline Conventional commits enforced in review Semver bumps that consumers can trust

Illustrative estimate (assumed numbers, not a benchmark). Hours saved per month = consumers × unrelated releases per month × hours per needless upgrade. Assume 6 services, 8 unrelated releases, and 0.5 h per upgrade and redeploy: 6 × 8 × 0.5 = 24 h. Subtract your own upkeep (assume 2 h/month) to get the net saving.

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 nx release groups independent 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.