What nx release does: version, changelog, publish
nx release is the Nx command that versions, documents and publishes the packages in a monorepo. It replaces the shell script that greps package.json, bumps a number, writes a tag and loops over npm publish. That kind of script tends to break the first time two packages depend on each other or a publish fails halfway. Nx already has the project graph, so it knows which packages changed and which internal dependents need their version ranges updated.
The work happens in three phases:
git commits since last release tag
│
▼
┌────────────────────┐
│ version │──► package.json versions
│ │──► updated internal dependency ranges
└─────────┬──────────┘
▼
┌────────────────────┐
│ changelog │──► CHANGELOG.md entries
│ │──► git commit + tags (optional host release)
└─────────┬──────────┘
▼
┌────────────────────┐
│ publish │──► tarballs pushed to the npm registry
└────────────────────┘
Running bare nx release performs all three phases. You can also invoke them one at a time with nx release version, nx release changelog and nx release publish. Splitting them up narrows down where a problem comes from. A wrong version number points to commit parsing or configuration. A missing changelog entry points to how commits are attributed to projects. A failed upload points to registry authentication.
Add --dry-run to any phase to see the version bumps, file diffs and publish targets it would produce, with nothing written to disk, git or the registry. Use it every time you change your release configuration.
#!/usr/bin/env bash
# Preview each release phase separately before anything is written.
# Assumes a fixed release group (all packages share one version) and a
# conventional npm registry login (e.g. NPM_TOKEN in ~/.npmrc).
set -euo pipefail
# Phase 1: version
# Shows the computed bumps and the package.json diffs, including
# internal dependency ranges. Nothing is written to disk or git.
npx nx release version --dry-run --verbose
# Phase 2: changelog
# Run standalone, the changelog phase needs to know which version it is
# documenting. Pass the version the dry run above reported.
# Shows the CHANGELOG.md entries and the commit and tag it would create.
npx nx release changelog 1.4.0 --dry-run
# Phase 3: publish
# Lists what would be packed and uploaded. Build first so the dry run
# inspects the real package contents.
npx nx run-many -t build
npx nx release publish --dry-run
# Once every preview looks right, run all three phases for real:
# npx nx release
Running nx release version, changelog and publish one at a time with --dry-run previews the version bumps, changelog entries and publish targets before anything reaches disk, git or the registry.
Who should use it and when to avoid it
Teams that benefit most
The strongest fit is a backend or platform team publishing several npm packages from a single Nx workspace: shared clients, internal SDKs, config presets, CLI tools. Nx already knows the project graph, so it can work out which packages a commit touched and which dependents need a new version. A standalone release tool would have to be told this separately. If you maintain more than a handful of packages, the time saved on manual bumps and changelog edits adds up quickly.
That benefit depends on commit discipline. Nx derives bumps and changelog entries from commit messages, so the team has to write Conventional Commits consistently and accept enforcement in review or CI. Teams that already squash-merge with structured PR titles usually adapt fastest.
When to avoid or delay adoption
- No commit-format enforcement. Without a gate, a single
fix:that should have beenfeat!:ships a breaking change as a patch. Add enforcement first, then automate releases. - Heavily curated release notes. If product or docs teams rewrite every changelog by hand, generated notes become a draft you discard each time, and that removes much of the value.
- A workspace not on Nx. Adopting Nx only to get release tooling adds a build system you don't otherwise need.
- Reluctance to couple releases to Nx configuration. Release options and defaults have changed between Nx versions, and an upgrade can change release behaviour. Read the documentation for your installed Nx version and recheck it after every upgrade.
Configuring the release block: fixed vs independent versioning
The release block in nx.json controls three things. projects selects which projects are released, either by name, glob or tag. groups splits those projects into release units that are versioned separately. projectsRelationship, set at the top level or per group, chooses between fixed and independent versioning. Fixed is the default, so if you configure nothing, every selected project moves together.
| Aspect | Fixed | Independent |
|---|---|---|
| Version numbers | One shared version across the group | Each project has its own version |
| Consumer compatibility | Same number means compatible. Easy to document. | Consumers must track a compatibility matrix |
| Git tags | One tag per release, e.g. v{version} |
One tag per project, e.g. {projectName}@{version} |
| Unchanged packages | Bumped and republished anyway, which adds noise | Only changed projects and their dependents are bumped |
| Best fit | Tightly coupled packages shipped as a suite | Loosely coupled libraries with separate consumers |
Set releaseTagPattern to match the mode you choose. In independent mode, the pattern must include the project name. If it does not, tags from different projects collide.
When an upstream package bumps, Nx rewrites the matching references in its local dependents' manifests. It can also bump those dependents so that they get published too. The exact options and defaults for this behaviour vary between Nx versions, so don't rely on memory.
Verify the result before publishing:
- Run
nx release --dry-runand read the planned manifest diffs. - Check that each dependent's range includes the new upstream version.
- Check that no workspace-only protocol leaks into a published package.
- Run
npm pack --dry-runon one dependent to inspect what would ship.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"release": {
"groups": {
"ui-suite": {
"projects": ["ui-core", "ui-react", "ui-theme"],
"projectsRelationship": "fixed",
"releaseTagPattern": "ui-suite-v{version}"
},
"utils": {
"projects": ["tag:scope:utils"],
"projectsRelationship": "independent",
"releaseTagPattern": "{projectName}@{version}"
}
}
}
}
An nx.json release block with a fixed group that shares one version and one tag, and an independent group selected by project tag whose tag pattern includes {projectName} so per-project tags do not collide.
Conventional Commits as the input: bump rules, attribution and enforcement
Nx reads commit messages since the last release tag and maps each Conventional Commit type to a semver bump. fix: produces a patch, feat: produces a minor, and a breaking-change marker produces a major. The marker can be either ! after the type, as in feat!:, or a BREAKING CHANGE: footer. Types such as chore, docs or test normally trigger no bump. The highest bump wins, so one feat among ten fix commits yields a minor.
Attribution
Nx assigns a commit to a project when the commit touches files inside that project's root. This is cheap and predictable, but it breaks down at the edges:
- Over-attribution: Changes to root files, such as the lockfile, shared tsconfig or workspace config, can be treated as affecting every project. One
fix:to a shared lint rule then patches every package. - Under-attribution: Generated code or configuration that lives outside a project can change runtime behavior without bumping that package.
Review the planned bumps in a dry run before you trust them.
Enforcement
Use two layers:
- Local
commit-msghook. It gives authors fast feedback. - CI check. Validate whatever actually lands on the main branch: the PR title for squash merges, or every commit for merge commits.
The CI check is the one that matters, because hooks can be skipped.
| Strategy | What lands in history | What to lint |
|---|---|---|
| Squash merge | One commit per PR, built from the PR title | The PR title |
| Merge commit | Every branch commit plus a merge commit | Each commit message |
Labels deserve this much care because a mislabeled commit ships the wrong semver. A breaking change typed as fix: reaches consumers as a patch. The registry also won't let you republish a version number, so the only repair is to release another version on top of the mistake.
# .github/workflows/pr-title.yml
# With squash merge, the PR title becomes the commit on main that Nx
# reads, so lint the title rather than the branch commits.
name: Lint PR title
on:
pull_request:
# 'edited' re-runs the check when someone fixes the title
types: [opened, edited, synchronize, reopened]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# Assumes @commitlint/cli and @commitlint/config-conventional are
# devDependencies, and commitlint.config.js extends the conventional preset.
# The local husky hook (.husky/commit-msg) runs the same config:
# npx --no -- commitlint --edit "$1"
- run: npm ci
- name: Validate title as a Conventional Commit
# Pass the title through env rather than interpolating it into the
# script, so a crafted title cannot inject shell commands.
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE" | npx commitlint
- name: Preview the version bumps this history would produce
# A dry run changes nothing. Use it to catch over- or under-attribution
# before a wrong version is published, because a published version
# cannot be reused.
run: |
git fetch --tags --unshallow || git fetch --tags
npx nx release version --dry-run
A GitHub Actions workflow that lints the PR title with commitlint for squash-merge repos, reusing the config from the local commit-msg hook, and previews the Nx version bumps with a dry run.
Changelog generation and git host releases
Nx Release can write two kinds of changelog, and they serve different readers. Both are configured under release.changelog in nx.json.
Workspace changelog
The workspace changelog (workspaceChangelog) is a single CHANGELOG.md at the repository root. Each release adds an entry with every relevant commit across all released projects, grouped by commit type and labelled with its scope. This works well with fixed versioning, where every package shares one version number and one history.
Per-project changelogs
Per-project changelogs (projectChangelogs) write a CHANGELOG.md into each project's root. Each entry lists only the commits that touched files belonging to that project. This is the natural fit for independent versioning. A consumer of @acme/http-client doesn't want to read about a fix in @acme/queue, and each package's changelog travels with it to the registry.
You can enable both, but most teams pick the one that matches their versioning mode.
Git host releases
Either changelog type can also create a release on the git host. Set createRelease to a supported provider such as "github", and Nx will create a release for the new tag with the generated entry as its body. The CI job needs a token with permission to create releases. Treat that token as you would the registry token, and scope it to the release job only.
Generated notes are not curated notes
A generated changelog reflects your commit messages, not the impact on users. fix(auth): handle nil claims is accurate, but it doesn't tell a consumer whether they need to act. If you publish release notes for external users, add a human editing step before publishing. You can run the changelog step without publishing, edit the generated entry, then publish.
{
"release": {
"projects": ["packages/*"],
"projectsRelationship": "independent",
"changelog": {
"workspaceChangelog": false,
"projectChangelogs": {
"createRelease": "github"
}
}
}
}
An nx.json release block for independent versioning: it turns off the root workspace changelog, writes a CHANGELOG.md into each package under packages/, and creates a GitHub release for each new project tag with the generated entry as its body.
Publishing from CI safely, plus a first-release checklist
Run the release job on a single branch, from a single job, with a CI-level concurrency group so that two merges in quick succession cannot both compute the same next version. Store the npm token as a CI secret. Use a granular automation token scoped to your packages, and reference it from .npmrc through an environment variable so it never reaches the repository.
Order the steps so that the remote only changes after the registry does:
- Version and generate changelogs locally. The commit and tags stay on the runner.
- Publish.
- Push the commit and tags.
- Create git host releases.
If publish fails, the runner is discarded and nothing has leaked. The weak point is a push failing after a successful publish. Rerun the job against the same commit, and set it up to skip any version that already exists on the registry rather than treating that as an error.
Pushing requires CI write access. Grant it through a dedicated bot identity that has a narrow bypass of branch protection, instead of loosening the rules for everyone. Anyone who can edit the workflow file inherits that access, so require review for changes to the release workflow.
First-release checklist
- Tag the currently published version of each package using your configured tag pattern, so commit analysis starts from that tag instead of from the first commit in history.
- Confirm that the versions in each
package.jsonmatch what is on the registry. - Run a dry run and read the proposed bumps.
- Publish one low-risk package first.
Recovering from a partial failure
If tags exist but some packages failed to publish, do not bump again. Fix the cause, then publish only the missing versions from the tagged commit.
Alternatives and impact on cost and engineering time
Every option replaces the same manual work, but each one moves the cost to a different place.
| Approach | Setup cost | Ongoing cost | Lock-in |
|---|---|---|---|
| Hand-written scripts | Low at first, then grows | You own every edge case: partial publishes, tag collisions, dependency ranges | None, but knowledge stays with whoever wrote the scripts |
| Changeset files | Moderate | Authors write an intent file per change, which reviewers can check | Low. The files are plain Markdown. |
| Commit-driven release tooling | Moderate | Strict commit discipline, and per-package monorepo support often needs extra setup | Low to moderate |
| nx release | Low if you already use Nx | Commit discipline, or explicit version plans | Release configuration lives in Nx config |
Changeset-style intent files cost a little effort on every pull request. That is often easier to enforce than commit message format, because reviewers can see the file in the diff. The main lock-in risk with nx release is migration effort if you leave Nx. Your tags and changelogs stay portable.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: monthly hours saved = (R × M) + (F × C) − D − K
Assumed inputs:
- R (releases per month) = 8
- M (manual minutes per release for bumps, changelog, tags) = 30
- F (failed releases per month) = 1
- C (cleanup minutes per failure) = 90
- D (commit-discipline overhead) = 2 hours
- K (config maintenance) = 1 hour
Result: (8 × 0.5) + (1 × 1.5) − 2 − 1 = 2.5 hours per month.
That saving is modest. If you release rarely, or your team resists commit conventions, hand-written scripts may stay cheaper.
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.