How nx release works: version, changelog and publish as three phases

nx release is the release tooling built into Nx. It treats a release as three phases, each available as its own subcommand:

  • Version (nx release version) works out the next version for each project, writes it to the project's package.json, and updates any workspace packages that depend on it so their internal ranges stay correct.
  • Changelog (nx release changelog) builds changelog entries from the commit history since the last release. It can write a workspace-level file, per-project files, or both, and can create git commits, tags and releases on your git host.
  • Publish (nx release publish) runs the publish step for each project. For JavaScript packages this means publishing to an npm registry, ordered so dependencies go out before the packages that rely on them.

Running nx release on its own executes all three phases in order. That suits a single-command release from CI. Running the subcommands separately gives you checkpoints. A common split is to version and generate changelogs in one step, review and merge the result, then publish from a pipeline that holds the registry credentials. Keeping the phases apart also means a failed publish does not force you to recalculate versions.

The design fits backend and platform teams that publish several libraries from one Nx workspace, such as shared clients, logging wrappers and config packages. It understands the workspace's project graph, so you don't have to maintain a separate inventory of packages and dependency edges.

Option names, defaults and configuration shape have changed between Nx versions. Before copying any configuration, check the documentation for the Nx version installed in your workspace.

# Preview what a full release would do without writing files,
# creating commits or tags, or publishing anything.
nx release --dry-run

# Option A: one command runs version -> changelog -> publish in order.
# Suits a fully automated release job in CI.
nx release

# Option B: split the phases so you have checkpoints.

# Step 1: work out new versions, update package.json files and
# internal dependency ranges, then generate changelogs.
# Publishing is skipped so the result can be reviewed and merged first.
nx release --skip-publish

# Step 2: later, in a pipeline that holds the npm registry credentials,
# publish the already-versioned projects. Dependencies go out before
# the packages that rely on them. If this fails, you can rerun it
# without recalculating versions.
nx release publish

# The phases can also be run one at a time, e.g. a minor bump only:
nx release version minor --dry-run

# Flags and defaults vary between Nx versions; check what yours supports:
nx release --help

Running nx release as a single command compared with splitting it into a version-and-changelog step and a separate publish step, with dry runs to preview each.

Configuring the release section in nx.json and defining release groups

All release configuration lives under the top-level release key in nx.json. Take an illustrative backend workspace with five libraries: db-core, db-migrations and db-testing, which share a schema contract, plus logger and http-client, which are consumed by many services but change on their own schedule. The workspace also contains deployable services that should never reach the registry.

Selecting participating projects

Start by limiting what nx release can touch. The release.projects option accepts project names, glob patterns or tag selectors such as tag:publishable. Tags are usually the safer choice because a new library opts in through its own project configuration. With an explicit tag, a service cannot be versioned and published just because it happens to match a broad glob. Anything outside the selection is ignored by all three phases.

Defining release groups

When you need different behaviour for different libraries, replace the flat project list with release.groups. Each named group lists its own projects and carries its own settings. Put db-core, db-migrations and db-testing in one group, for example database. A breaking schema change usually affects all three, and consumers benefit from knowing that matching versions work together.

Keep logger and http-client out of that group. If they shared a group with the database libraries, a migration fix could force a release of the logger, which tells consumers something changed when nothing did. Separate groups also keep changelogs focused: each group's notes describe only the libraries in it.

Each project should belong to only one group. Treat group boundaries as a statement about which libraries are released together.

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "release": {
    "groups": {
      "database": {
        "projects": ["db-core", "db-migrations", "db-testing"],
        "projectsRelationship": "fixed"
      },
      "logger": {
        "projects": ["logger"]
      },
      "http-client": {
        "projects": ["http-client"]
      }
    }
  }
}

An illustrative nx.json release block where the three schema-coupled database libraries form one fixed-version group, while logger and http-client each have their own group so they are versioned and changelogged separately. Deployable services are excluded because no group lists them.

Fixed or independent versioning: choosing per release group

Each release group chooses one of two models through its projectsRelationship setting: fixed or independent.

Under fixed versioning, every project in the group shares one version number and moves together. This is easy to communicate. A consumer who installs version 4.2.0 of your client knows it pairs with 4.2.0 of the auth and retry helpers, and support conversations need only one number. The cost is churn. A typo fix in one package republishes all of them, and Renovate or Dependabot opens update PRs for libraries whose code did not change. A single breaking change in any member forces a major bump across the whole group. Downstream teams then have to read migration notes for packages that were not affected.

Under independent versioning, each project gets its own version, derived from its own changes. Semver stays precise: a major bump means that package broke. The trade-off is volume and reasoning cost. You get one tag and one changelog entry per changed project per release. Consumers can no longer tell from version numbers alone which combinations were tested together, so you need peer dependency ranges or a compatibility table to fill that gap.

Library set Suggested model Reason
SDK family (core client, transport, generated types) Fixed Packages are used together, and one shared version simplifies support
Framework plugins for one host framework Fixed They track the host's breaking changes together
Standalone utilities (logging, config parsing, ID generation) Independent Consumers adopt them individually, and churn would be noise
Internal adapters for separate databases or queues Independent They evolve at different rates and serve different owners
// nx.json (Nx parses this file with comments allowed)
{
  "release": {
    "groups": {
      // SDK family: used together, so one shared version keeps support simple.
      // A breaking change in any member bumps the major version for all three.
      "sdk": {
        "projects": ["sdk-core", "sdk-transport", "sdk-types"],
        "projectsRelationship": "fixed"
      },
      // Framework plugins: they track the host framework's breaking changes together.
      "plugins": {
        "projects": ["plugin-*"],
        "projectsRelationship": "fixed"
      },
      // Standalone utilities: adopted individually, so each one gets its own version.
      // Each changed project gets its own tag and changelog entry.
      "utils": {
        "projects": ["logging", "config-parser", "id-gen"],
        "projectsRelationship": "independent",
        "changelog": {
          "projectChangelogs": true
        }
      },
      // Internal adapters: they change at different rates and have different owners.
      "adapters": {
        "projects": ["adapter-postgres", "adapter-redis", "adapter-kafka"],
        "projectsRelationship": "independent"
      }
    }
  }
}

An nx.json release configuration that assigns each backend library set from the table to its own release group, with fixed versioning for the SDK family and plugins and independent versioning for utilities and adapters.

Conventional commits, version bumps and internal dependency ranges

Once a group's versioning strategy is chosen, the next question is how each library's bump size gets decided. With conventional commits enabled in the version configuration of the release section, Nx stops asking you for a bump. It reads the commits since each project's last release tag, maps them to projects by the files they touched, and derives the bump from the commit types:

  • fix: produces a patch.
  • feat: produces a minor.
  • A ! after the type, or a BREAKING CHANGE: footer, produces a major.

This makes commit messages part of your release mechanism. A commit labelled fix(db-adapter): handle null pool config that also renames an exported function ships as a patch. Every consumer pinned to ^2.3.0 then picks it up on the next install and breaks. Nx cannot detect this, because it trusts the label. Two controls reduce the risk:

  • Lint commit messages in a local hook and again as a required CI check. Merges that bypass the hook still need to be caught.
  • Review the message as part of code review. Reviewers should ask whether the diff changes a public signature, a config shape, or a wire format. If you squash-merge, the PR title often becomes the commit, so review the title too.

Internal dependency ranges are the other half. When shared-types moves from 1.4.0 to 2.0.0, a library that depends on it must not keep declaring ^1.4.0. If it does, consumers resolve an old shared-types against code written for the new one. Nx updates the dependency ranges in dependents' package.json files during the version phase and can bump those dependents as well. Whether dependents are bumped is controlled by the updateDependents option, so check its behavior in your Nx version. The result is that the published set stays internally consistent.

# 1) nx.json: derive bumps from conventional commits instead of prompting.
#    Layout shown is Nx 21+. Older versions put updateDependents under
#    release.version.generatorOptions, so check the docs for your version.
cat > nx.json <<'EOF'
{
  "release": {
    "projects": ["libs/*"],
    "projectsRelationship": "independent",
    "version": {
      "conventionalCommits": true,
      "updateDependents": "auto"
    }
  }
}
EOF

# 2) Commit lint config: reject messages Nx cannot classify.
cat > commitlint.config.js <<'EOF'
module.exports = { extends: ['@commitlint/config-conventional'] };
EOF

# 3) Required CI check: lint every commit on the branch.
#    This also catches commits that skipped the local hook.
npx commitlint --from origin/main --to HEAD --verbose

# What decides the bump (the label, not the diff):
#   fix(db-adapter): handle null pool config      -> patch
#   feat(db-adapter): add read replica support    -> minor
#   fix(db-adapter)!: rename createPool to open   -> major

# 4) Preview the result before releasing. Check that each library gets
#    the bump you expect and that dependents' package.json ranges change too.
npx nx release version --dry-run

This example enables conventional-commit-driven bumps with dependent updates in nx.json, then enforces commit message format in CI and dry-runs the version step to check the bumps and the rewritten internal ranges.

Changelogs, GitHub releases and dry runs as the release review

Changelog configuration lives under release.changelog in nx.json and has two independent switches. workspaceChangelog writes one file at the repository root that covers every project released in a run. projectChangelogs writes a CHANGELOG.md inside each project. For backend libraries consumed by separate teams, per-project changelogs usually work better because a consumer of your logging library should not have to read through queue-client changes. A workspace changelog fits fixed groups, where every package moves together and one narrative is easier to follow. Each switch also accepts createRelease: "github", which creates a GitHub release from the same entries. Pair it with per-project changelogs when tags are per project, so each release lines up with one tag. The exact option shapes have shifted between Nx versions, so confirm them against your installed version's docs.

Generated entries come straight from commit messages. A history full of fix: stuff and chore: wip produces a changelog full of the same. You can edit the files before committing, but those edits are lost the next time someone looks at the history. A better fix is at the source: squash-merge with a reviewed PR title, or lint commit messages in CI.

Run every phase with --dry-run before you release for real: nx release version, nx release changelog and nx release publish, or nx release --dry-run to run them all. Check three things in the output:

  • the planned version for each project
  • the file diffs to package.json and the changelogs
  • the packages and registries that publish would target

Treat that output as the release review. If any of it surprises you, stop and investigate before releasing.

Publishing npm packages from a monorepo safely in CI

Point publishing at what you built, not at the source tree. If the package root for publishing is the library's source folder, npm will pack whatever files or .npmignore allows, which may include TypeScript sources or miss compiled output. Configure the package root to the build output directory, make the publish target depend on build, and confirm the version bump is written to the package.json that actually ships.

Scoped packages are published as restricted by default on the public npm registry. That fails for a free scope and is the wrong default if you intend the package to be public. Set publishConfig with an explicit access and registry in each package's package.json. This makes the target visible in code review and removes the reliance on whichever .npmrc happens to be present on the runner.

In CI, store the registry token as a secret. Have .npmrc reference it through an environment variable instead of writing the value into the file, and never commit a resolved token. Grant the token publish rights only, and expose it only to the job that publishes.

Split the release into two jobs:

  • Release job: a controlled job, triggered manually or from a protected branch, runs version and changelog with publishing skipped. It then commits and pushes the release tags.
  • Publish job: a separate job triggered by those tags checks out the tagged commit, builds, and publishes.

Partial failures usually end with the commit and tags pushed while only some packages are on the registry. Do not run versioning again, because that bumps versions a second time and creates orphaned tags. Rerun the publish job against the same tag. npm rejects a version that already exists, so the rerun completes the missing packages and the existing ones fail harmlessly.

If a version that was published is broken, release a new patch. Do not unpublish it or move the tag.

When to avoid nx release, alternatives, and impact on cost and engineering time

Nx release automates the mechanics of a release, not the policy around it. It has no concept of approval gates, so a human sign-off must live in your CI platform's protected environments or branch rules. It does not decide when a pre-release on a channel becomes stable, it does not manage backports to maintenance branches, and it does not deprecate old versions. You still script or document all of these yourself.

When to skip it

  • Non-Nx repositories. Adopting Nx only to get releases adds a project graph and configuration you would not otherwise maintain.
  • A single package. Release groups and dependency-range updates solve nothing when nothing depends on anything else.
  • A heavily customised existing pipeline. If your current pipeline already encodes signing, approvals and channel promotion, replacing it means rebuilding all of that logic.
Approach Best fit Main cost
Manual npm version scripts One or two packages Error-prone ordering across dependents
Changeset-style workflows Teams that want per-PR release intent Contributors must write change files
Semantic-release-style tools Single-package, commit-driven repos Weak monorepo awareness
Nx release Existing Nx monorepos Tag migration and config upkeep

Migration and payoff

Existing tags must match the configured tag pattern. If they don't, the first run computes versions and changelogs from the wrong base commit. Retag or adjust the pattern, then run a supervised dry run and have someone review every proposed version before the first real release.

Illustrative estimate (assumed numbers, not a benchmark).

Break-even months = setup hours ÷ (releases per month × packages per release × manual hours per package)

Assumed inputs:

  • Setup: 16 hours
  • Releases per month: 4
  • Packages per release: 6
  • Manual work per package: 0.25 hours

16 ÷ (4 × 6 × 0.25) = 16 ÷ 6 ≈ 2.7 months

Plug in your own figures. A low release frequency can stretch break-even past a year.

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, 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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.