How nx release turns commits into versions and changelogs

nx release is the release tooling built into Nx. It reads your git history since the last release, decides which projects need new versions, writes changelogs, and can publish the results. It suits backend and platform engineers who already keep services, shared libraries and internal SDKs in one Nx workspace and want versioning to follow the project graph Nx already maintains, without a separate release tool.

The work happens in three phases:

  1. Versioning: compute and write new version numbers.
  2. Changelog generation: render commit history into Markdown and, optionally, into git tags or hosted release notes.
  3. Publishing: push packages to a registry. This phase is optional.

Commits are attributed to projects by the files they change. The commit scope (the auth in fix(auth): ...) does not decide attribution. Nx maps each changed path to the project that owns it in the project graph. A commit that edits both libs/db-client and apps/billing-api therefore counts as a change to both projects and appears in both changelogs. That is accurate, because both artifacts changed. When a commit touches many packages at once, it is also a sign that it should have been split.

commits ──> changed files ──> project graph ──> affected projects
                                                     │
                                  version bump <─────┘
                                       │
                                  changelog ──> (optional) publish

Before chaining the phases, run each one on its own with dry-run mode: nx release version --dry-run, then nx release changelog --dry-run, then nx release publish --dry-run. Each run prints what it would change without writing files, creating tags or publishing. This lets you check which projects get bumped and which commits land where. Once each phase looks correct, run them together with nx release.

#!/usr/bin/env bash
# Rehearse each nx release phase separately before chaining them.
# --dry-run prints planned changes without writing files, creating tags or publishing.
set -euo pipefail

# Phase 1: versioning.
# Check which projects Nx attributes commits to, based on the files each
# commit changed and the project graph, and the version bump planned for each.
npx nx release version --dry-run

# Phase 2: changelog generation.
# Preview the Markdown entries. A commit that changed files in both
# libs/db-client and apps/billing-api should appear under both projects.
npx nx release changelog --dry-run

# Phase 3: publishing (optional).
# Confirm which packages would be pushed to the registry.
npx nx release publish --dry-run

# After all three previews look right, preview the chained run once more,
# then run it again without --dry-run to write versions, changelogs and tags.
npx nx release --dry-run

Running versioning, changelog generation and publishing as separate dry runs to check project attribution and version bumps, then previewing the chained nx release command.

Conventional commits that produce useful changelogs

The parser reads only a few parts of each commit: the type, an optional scope in parentheses, the description, and any breaking-change marker. A breaking change can be marked with ! after the type or scope, as in feat(api)!: drop v1 routes, or with a BREAKING CHANGE: footer in the commit body. The footer is the better place to explain what consumers must do. The ! is quicker to spot when scanning history.

The scope does not decide which project a commit belongs to. Nx assigns commits to projects based on the files they touch. The scope only labels and groups the entry inside the changelog, so fix(auth) in a commit that edits billing still lands in the billing changelog.

Commit message Bump (default mapping) Changelog result
feat(orders): add idempotency keys to POST /orders minor Clear entry under features
fix(db): release pool connections on query timeout patch Clear entry under fixes
feat(api)!: require tenant header + BREAKING CHANGE: footer major Flagged as breaking, with migration note
chore: deps usually none Typically omitted
fix: stuff patch Entry that tells readers nothing
update service none Not parsed as a conventional commit

The type-to-bump mapping can be configured. Treat the table as the common default, not a fixed rule.

The tool cannot add intent that nobody wrote down. If a commit says fix: stuff, the changelog says "stuff". If a breaking change is labelled feat, it ships as a minor version and downstream services find out at runtime. Writing precise commit messages costs a few seconds per commit. Fixing a wrong version after it has been published costs much more.

# Good: type + scope + specific description -> patch bump, clear 'fixes' entry
git commit -m "fix(db): release pool connections on query timeout"

# Good: new capability -> minor bump, clear 'features' entry
git commit -m "feat(orders): add idempotency keys to POST /orders"

# Good: breaking change marked twice.
# '!' makes it visible in one-line history; the footer carries the migration note.
# Each -m becomes its own paragraph, so the last one is parsed as a footer.
git commit \
  -m "feat(api)!: require X-Tenant-Id header on all routes" \
  -m "Requests without the header are now rejected with 400." \
  -m "BREAKING CHANGE: clients must send X-Tenant-Id on every request. Existing single-tenant clients can send 'default'."

# Bad: valid format, useless content. Still a patch bump, but the entry says 'stuff'.
# git commit -m "fix: stuff"

# Bad: not a conventional commit. Ignored, so no bump and no changelog entry.
# git commit -m "update service"

# Worse: a breaking change typed as 'feat' with no marker.
# Ships as a minor version; consumers find out at runtime.
# git commit -m "feat(api): require tenant header"

# Check one-line history before releasing: '!' entries stand out here.
git log --oneline -n 20

# Preview computed versions and changelog text without writing anything.
npx nx release --dry-run

Good and bad commit messages, including a breaking change marked with both '!' and a BREAKING CHANGE footer, followed by a dry run to preview the resulting versions and changelog before publishing.

Choosing changelog scope and versioning strategy

The two decisions are separate in nx release. projectsRelationship sets whether every package shares one version (fixed) or each moves on its own (independent). The changelog settings set whether notes go into one workspace-level file, into per-project files, or into both. For backend packages that deploy on different schedules, the right combination depends on what you need to answer during an incident.

Option Deploy-cadence fit "What ran together in prod?" Unnecessary bumps
Fixed + workspace changelog Poor: one service's fix bumps all Easy: one version identifies the set Many
Independent + per-project changelogs Good: each service ships alone Hard: needs deploy records or a manifest Few
Independent + both changelogs Good Moderate: the workspace file gives a dated timeline Few

Fixed versioning works when services are deployed as a unit, for example a monorepo deployed by one pipeline. If services ship on their own schedules, a fixed version tells you little, because 2.4.0 of the billing service may never have run alongside 2.4.0 of the gateway. Independent versioning keeps each version meaningful. You then rely on deploy history to reconstruct which versions ran together.

In independent mode, a change to a shared library also affects the services that use it. When the library is bumped, Nx uses the project graph to find its internal dependents and updates their dependency references to the new version. Depending on your configuration, it can also give those dependents a patch bump, even when their own source did not change. That patch bump is usually what you want. A service built against a new library version is a different artifact, and its changelog should state that.

Configuring the release section of nx.json

The release configuration is where that intent gets written down. Everything lives under the release key in nx.json. Start with release.projects to decide which projects take part at all. A pattern or list keeps tooling, test fixtures and example apps out of version bumps they have no reason to receive.

Release groups

Inside release.groups, each group names its projects and sets projectsRelationship. Use fixed when packages must share one version number, such as a set of client SDKs. Use independent when each service or library moves at its own pace. Groups let you mix both strategies in one workspace without running separate release processes.

Changelog options

Under changelog, choose between workspaceChangelog, projectChangelogs, or both. Set file to control whether a CHANGELOG.md is written to disk. A per-project file next to the service code is easier for on-call engineers to find than a single root file. The renderOptions settings control two parts of each entry:

  • authors controls whether contributors are listed.
  • commitReferences controls whether each entry links back to its commit.

Commit references are worth keeping. They turn the changelog into an index you can use when tracing a regression.

Git behavior

The git settings determine whether Nx creates the release commit and tags. Disable them locally if CI should own tagging.

Unpublished services

A deployed service needs version numbers and changelogs, but it has nothing to publish to a registry. To handle this, run nx release with --skip-publish, or call the version and changelog subcommands directly and leave publishing to library-only jobs.

Key names and their locations have moved between Nx major versions. Check the documentation for the version you have installed before copying any configuration.

{
  // nx.json (Nx accepts comments here). Key names vary by Nx major version; check the docs for yours.
  "release": {
    // Only these projects take part in releases. Tooling and fixtures stay out.
    "projects": ["libs/sdk-*", "services/*"],
    "groups": {
      // Client SDKs ship together under one shared version number.
      "sdks": {
        "projects": ["libs/sdk-*"],
        "projectsRelationship": "fixed"
      },
      // Deployed services version independently. They are never published to a
      // registry, so release them with: nx release --skip-publish --groups=services
      "services": {
        "projects": ["services/*"],
        "projectsRelationship": "independent"
      }
    },
    "changelog": {
      // No root changelog. Each project gets its own file next to its code.
      "workspaceChangelog": false,
      "projectChangelogs": {
        "file": "{projectRoot}/CHANGELOG.md",
        "renderOptions": {
          "authors": true,
          // Link each entry to its commit so regressions can be traced.
          "commitReferences": true
        }
      }
    },
    "git": {
      // Nx creates the release commit and tags. Set both to false if CI owns tagging.
      "commit": true,
      "tag": true
    }
  }
}

An nx.json release section that defines a fixed group for SDKs and an independent group for services, writes a per-project CHANGELOG.md with authors and commit links, and has Nx create the release commit and tags; services are released with --skip-publish.

Enforcing commit quality before it reaches main

Nx reads the history that lands on main, not what was on the branch. The merge strategy therefore decides where the convention has to be enforced.

Squash-merge Merge commits
What reaches main One commit per PR; the PR title usually becomes its subject Every branch commit, plus a merge commit
Where to enforce A CI check on the PR title A commit-msg hook locally, plus a CI check over every commit in the PR range
Changelog granularity One entry per PR One entry per qualifying commit
Failure mode A broad PR collapses several changes into one entry, and the most significant change has to be in the title A single "wip" or "fix tests" commit produces a bad entry or blocks the merge

Squash-merge is the easier option for most backend teams. Only one string per PR has to conform, and reviewers can fix it before merging. The cost is detail. A PR that adds an endpoint and fixes a retry bug becomes one feat line unless you split it. Merge commits keep both entries, but contributors then have to rebase and reword before merging. A local hook alone is not enough because it can be skipped, so CI must lint the full commit range.

Lint for semantics as well as syntax. Restrict scopes to real project names, and require ! or a BREAKING CHANGE footer wherever the public API changes.

Shared files cause a different kind of noise. A lockfile bump or a root tsconfig edit touches files that Nx may treat as affecting many projects. Every service then gets a changelog entry and possibly a version bump. To limit this, put dependency and tooling changes in separate commits typed chore or build, and leave those types out of the changelog's visible sections.

// commitlint.config.js at the workspace root
module.exports = {
  // Standard types (feat, fix, build, chore, ...) and the header format
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Scopes must be real Nx project names, plus two non-package scopes.
    // 'deps' and 'tooling' are for lockfile and root config changes. Commit
    // them separately as build(deps) or chore(tooling) so they stay out of
    // the changelog's visible sections.
    'scope-enum': [
      2,
      'always',
      ['orders-api', 'billing-worker', 'shared-db', 'deps', 'tooling'],
    ],
    // Require a scope so every entry can be traced to a package or to tooling
    'scope-empty': [2, 'never'],
  },
};

// Where to run it, depending on the merge strategy:
//
// Squash-merge: the PR title becomes the commit on main, so lint only the title
//   echo "$PR_TITLE" | npx commitlint
//
// Merge commits: every branch commit reaches main. Lint the full range in CI,
// because a local hook can be skipped with --no-verify.
//   npx commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
//
// Optional local commit-msg hook for fast feedback:
//   npx --no -- commitlint --edit "$1"

A commitlint config that limits scopes to real Nx project names plus dedicated scopes for dependency and tooling commits, with comments showing how to lint only the PR title under squash-merge or the full commit range under merge commits.

Running releases in CI and doing a first release on a messy repo

Before the release step runs, the CI job needs the full git history and all tags. Many CI checkout steps default to a shallow clone of one commit. Nx release finds the previous version by locating the latest tag that matches your releaseTagPattern. If that tag or the commits after it are missing, Nx either falls back to the wrong baseline or treats every reachable commit as unreleased. The result is a wrong bump, or a changelog that repeats commits you already shipped.

Release loops happen when the bot's release commit lands on main and triggers the pipeline again. Prevent them in two places:

  • Skip the release job for commits authored by the release bot, or for commits whose message matches your release commit message.
  • Keep chore out of the bump-producing types, so a second run has nothing to release.

Let a single nx release invocation create the commit and the tags, then push both together. If you push one without the other, local and remote state drift apart.

Releasing on every merge works for a small, well-disciplined repo. A manual or scheduled trigger batches changes into readable entries and gives a human a moment to review the dry-run output first.

On a messy repo, set a baseline before the first real run. Create a tag that matches your tag pattern at the current commit, using the version you have already published. Nx then only considers commits made after that point. If an entry still comes out wrong, edit CHANGELOG.md by hand and commit the fix. Nx adds new entries above the old ones and does not regenerate past entries.

When to avoid it, alternatives, and impact on engineering time

Generated changelogs record what changed in the code, not what an operator has to do about it. If a release requires running a schema migration before the deploy, renaming an environment variable, or rolling back in a specific order, a commit subject line cannot carry that. You can put details in a commit body or a BREAKING CHANGE footer, but the instructions end up split across entries, with no ordering. In that case, write the operator notes by hand and keep the generated file as the developer-facing record.

Two other situations favour skipping it. If commit discipline is weak, for example squash merges reuse unreviewed PR titles like "updates", the output is noise with a version number on it. If a package ships only a few times a year, the tooling costs more to maintain than the bookkeeping it removes.

Approach Source of truth Fits best when Main cost
Generated from conventional commits Commit messages Many packages, frequent releases Lint setup, ongoing commit discipline
Hand-written changelog The file itself Operator-heavy notes, rare releases Manual editing, easy to forget
Changeset files A file added to each PR Bump intent should be reviewed in the PR An extra file per PR
Curated from PRs PR titles and labels A release manager already edits notes Curation time each release

Illustrative estimate (assumed numbers, not a benchmark): monthly manual cost = packages × releases per package × minutes per release. With 6 × 2 × 20 = 240 minutes, an assumed 8-hour setup, and 60 minutes per month of maintenance, break-even = 480 ÷ (240 − 60) ≈ 2.7 months.

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 changelog, 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.