Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.
A pnpm workspace publishing every package under one shared version number (lockstep) forces an unrelated package to release whenever any sibling changes. This tool runs semantic-release independently per package, driven by each package's own commit history and its own dependencies within the workspace, so a change to one package never forces a version bump in another — while a package whose dependencies genuinely changed still releases, so no published manifest ever disagrees with the repository.
Built for the documents.js ecosystem's monorepo consolidation and reusable from any pnpm workspace: it discovers packages from pnpm-workspace.yaml and their manifests, with nothing hardcoded about any particular ecosystem layout.
One orchestrator run, five stages:
- Workspace discovery. Reads the
packagesglobs frompnpm-workspace.yamland every matched package'spackage.json(dependencies,devDependencies,peerDependencies,optionalDependencies), building the inter-package dependency graph keyed by package name. Packages must live in subdirectories (a package at the workspace root touches every commit and cannot be path-scoped) and names must be unique. This stage also resolves the workspace root's own path relative to the git repository's toplevel (git rev-parse --show-prefix), becausegit log --name-onlyalways reports changed paths relative to that toplevel, not topnpm-workspace.yaml's own directory -- the two differ whenever the workspace is nested inside a larger repository, and every package's commit filtering is scoped against the repository-relative path, not the workspace-relative one, so nested workspaces are path-scoped correctly rather than silently matching nothing. - Topological release ordering. Kahn's algorithm over the discovered graph, so a package only releases after every workspace sibling it depends on has already released in this run. Ties are broken alphabetically within each dependency layer, so the same workspace always produces the same order. A dependency cycle has no valid order at all — the run fails loudly, naming the loop (
x -> y -> x), rather than picking one of the wrong answers and publishing a package whose sibling dependency points at a version that does not exist yet. - Per-package scoped release. For each package in order, the orchestrator calls semantic-release's programmatic API (
require('semantic-release'), not the CLI) withcwdset to the package's directory,tagFormatresolved from thetagFormatoption (default'${name}@${version}') so each package's tags stay distinct in the one shared tag namespace, and inlineanalyzeCommits/generateNotesplugins. Each wrapper runs onegit log --name-only --no-renamespass over the same release range semantic-release already analysed (from the package's last matching tag toHEAD, or the whole history for a first release), maps every commit to the paths it changed, and filters the commit list down to commits touching the package's own directory before delegating to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins — the orchestrator only scopes what they see. - Cross-package manifest bumping — the heart of the design, covered in its own section below.
- Standard plugins do the publishing. @semantic-release/npm, @semantic-release/github, @semantic-release/changelog, and @semantic-release/git run per package exactly as in a single-package repository, scoped by
cwd. The orchestrator coordinates and sequences them; it does not reimplement npm publishing, GitHub release creation, or changelog file writing.
When a package releases version V, every not-yet-released-this-run workspace package that depends on it gets its dependency range updated — and the timing of that update is the least obvious part of the whole design, because getting it wrong is exactly the bug class the documents.js ecosystem's old cross-repo automation hit (the sibling-dependency-update heal-job downgrade race: repository state and published manifests disagreeing, then automation "healing" in the wrong direction; see documents.js#664).
The orchestrator's rule: the moment a package's release completes, each dependent's manifest is rewritten on disk, pnpm-lock.yaml is regenerated to match, and both are committed together and pushed — before anything else happens. A bump commit looks like:
chore(deps): bump @fixture/a to ^1.1.0 in @fixture/b [skip ci]
Why commit immediately, rather than the alternatives:
- Why commit at all (not just edit the working tree)? A dependent's semantic-release run analyses git history, not the working tree. An uncommitted manifest edit is invisible to its commit analysis, so the dependent could be judged "no changes" and skip a release — leaving a manifest that names a version the registry has, but which the dependent never published, stranded uncommitted in one developer's checkout.
- Why regenerate
pnpm-lock.yamlin the same commit?pnpm install --frozen-lockfile(what CI runs) rejects a tree where the lockfile's recorded specifier for a workspace dependency disagrees with the manifest. Committing the manifest bump without regenerating the lockfile leaves exactly that disagreement, breaking--frozen-lockfileinstalls on the dependent's directory until someone runspnpm installby hand and commits the result — so the lockfile is regenerated and committed alongside the manifest, never as a separate step. - Why before the dependent's own run (not after)? The dependent's release commit and its published artifact must carry the new range. Bumping after would publish the dependent with a stale range, then mutate the repository afterwards — the repository/published-artifact disagreement this tool exists to prevent.
- Why push immediately? The same crash-consistency discipline semantic-release applies to its own release commits: if the orchestrator dies halfway through the run, everything pushed so far (releases, tags, bump commits) is a consistent prefix, and the next run picks up cleanly from the tags.
[skip ci]on the bump message stops the push from triggering a second, racing release run. The bump commit also carries a machine-parseable trailer alongside its human-readable subject, recording the dependency, its released version, and the new range -- so a fresh run's per-package analysis recognises a bump commit that already exists in history (whether from earlier in the same run or left over from a run that stopped right after pushing it) and still forces the dependent's patch release, rather than depending on a record that only ever existed in the process that made the commit.
Because every dependent sits downstream in topological order, its own run always sees the bump commit: the commit touches only the dependent's directory, so it passes that dependent's path filter and participates in its analysis.
A package whose only change is dependency bumps still gets a patch release — deliberately. A range rewritten on disk (^1.0.0 → ^1.1.0) changes the dependent's published dependency range, so the dependent must be republished for the change to reach consumers. A range that names no version, such as a bare workspace:^ in a private package, has nothing to rewrite, but the dependent still releases, so a package always follows a sibling it depends on. This is not left to chance: the wrapped analyzeCommits returns patch whenever the standard analyzer found nothing but this run bumped one of the package's dependency ranges, so the behaviour does not depend on how the workspace's own analyzer config happens to classify chore(deps) commits (many presets release nothing for chore). Release notes gain a ### Dependencies section listing the bumps, so the release is self-explaining rather than empty.
Dependency-range handling, in full:
| Range in the dependent's manifest | What happens |
|---|---|
^1.0.0, ~1.0.0, >=1.0.0, =1.0.0, 1.0.0 |
Rewritten in place, preserving the comparator (^1.0.0 → ^1.1.0); pnpm-lock.yaml is regenerated to match, and both are committed and pushed together; dependent gets at least a patch release |
workspace:^1.0.0 and the other anchored workspace: forms, in devDependencies or a private package |
Rewritten in place with the workspace: prefix kept (workspace:^1.0.0 → workspace:^1.1.0), committed and pushed as above; dependent gets at least a patch release |
workspace:*, workspace:^, workspace:~, in devDependencies or a private package |
No manifest edit, since the range names no version; the dependent still gets a patch release |
Any workspace:, catalog:, link:, or file: specifier in the dependencies, peerDependencies, or optionalDependencies of a package that is not private, whichever package it names |
The run stops with UnsupportedDependencyRangeError: npm publish ships the specifier unchanged, so the published package could not be installed |
*, x, latest |
Nothing to update and the published range is unaffected — no bump, no forced release |
Compound ranges (>=1.0.0 <2.0.0), unions (1.x || 2.x), </<= bounds, catalog:, npm: aliases, git/tarball URLs |
The run stops with UnsupportedDependencyRangeError — rewriting any of these wrongly, or leaving them silently stale, both produce a published manifest that disagrees with the repository, so neither is attempted |
Every range above is validated before the release loop starts, not just when the dependency it names happens to release: both the shape of each workspace dependency edge and the specifiers in each publishable package's installed dependency fields are static properties of the manifests, knowable at discovery time, so UnsupportedDependencyRangeError stops the run before the first package publishes, rather than after some upstream sibling has already been published, tagged, committed, and pushed. Every offending entry is listed in one error. resume checks the manifests again before it publishes anything.
Publishing goes through @semantic-release/npm, which is plain npm publish: it ships a manifest's specifiers unchanged and never substitutes pnpm's protocols the way pnpm publish does. A publishable package therefore declares a concrete version range for each workspace sibling, which the orchestrator keeps up to date for you, and keeps linkWorkspacePackages: true in pnpm-workspace.yaml so pnpm still links the sibling locally instead of fetching it from the registry. workspace: ranges remain usable in private packages, which never publish, and in devDependencies, which no consumer installs.
Everything above describes commitStrategy: 'per-package', the default: unchanged, and the shape every existing consumer of this tool already sees. commitStrategy: 'single' is an opt-in alternative that produces exactly one commit per run instead of one commit per release plus one per dependency bump.
'per-package' (default) |
'single' |
|
|---|---|---|
| Commits per run | One per package release, plus one per dependency-range bump — potentially dozens for a run that releases many packages | Exactly one, containing every version bump, every changelog write, and every dependency-range rewrite for the whole run |
| Tags | Created and pushed as each package's own semantic-release run reaches its prepare/tag step |
Created once analysis finishes for every released package, all pointing at the same combined commit, pushed together with it |
@semantic-release/git |
Required in the publish plugin list for a real run (it makes the per-package commit) | Rejected outright if listed — its own prepare step would create exactly the per-package commit this mode exists to avoid; the combined commit is made by the orchestrator itself |
| Default publish plugins | DEFAULT_PUBLISH_PLUGINS (changelog, npm, github, git) |
SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS — the same list minus git |
| Crash recovery | A bump commit already sitting in git history (from this run or an earlier one) is recognised via a machine-parseable trailer, so a run that resumes after a partial push still forces the right dependent releases | Not needed: either the whole combined commit lands and pushes, or the run fails before touching git at all, so there is never a partial push to recover from |
Everything about what releases (topological order, forced-patch dependents, dependency-range classification, path-scoped commit analysis, unsupported-range rejection) is identical between the two modes — commitStrategy only changes how the result is committed, tagged, and pushed.
Set it via the --commit-strategy <mode> CLI flag, the commitStrategy field in a --config file, or the commitStrategy option to releaseWorkspace(); omit it and nothing changes.
'single' mode still runs each package's own configured publish plugins afterward (npm publish with provenance, GitHub releases), scoped per package exactly as 'per-package' mode does — it just runs their verifyConditions/publish/success steps directly against the already-committed-and-tagged repository state, since by that point semantic-release's own top-level orchestrator would misread the tag this mode already created as an existing release. addChannel and fail are not called in this mode (pre-release channel promotion and posting an automated failure comment/issue, respectively) — a deliberate scope boundary, not a silent gap: raise an issue if your workflow needs them.
Under commitStrategy: 'per-package' a release is a sequence of independent pushes, so a commit landing on the release branch mid-run can interrupt it between packages. The orchestrator integrates the new tip and carries on with the packages that have not released yet, and packages already published are never republished, since their tags record them. Where semantic-release declines to release a package while the branch is moving underneath it, which is indistinguishable from that package genuinely having nothing to release, the package is released again against the integrated tip rather than assumed finished; a run that can never establish the answer fails rather than finishing as though the package had nothing, which would leave it and every package after it silently unreleased.
One window is outside this tool's control: semantic-release pushes a package's tag before running its publish plugins, and that push is not atomic, so a rejection there can leave a tag on the remote for a version that never reached npm. Nothing is corrupted and no later release is blocked, but that version number is skipped. If it happens, find the tag with no matching published version (npm view <name> versions) and either publish from it by hand or delete the tag locally and on the remote and let the next run recompute it.
If you need a run to be strictly all-or-nothing, use commitStrategy: 'single', which makes one commit and one atomic push before anything publishes, and which retries the whole attempt against the new tip when that push loses a race.
Each package's release tag comes from the tagFormat option, a lodash template with ${name} and ${version} placeholders, defaulting to '${name}@${version}'. Set it with the --tag-format <template> CLI flag, the tagFormat field in a --config file, or the tagFormat option to releaseWorkspace(); when both the flag and the config file set it, the flag wins. The template must contain ${version} and may contain ${name}; anything else is rejected before any package releases.
Override it when the tags are consumed as refs outside git: GitHub Actions pins composite actions as owner/repo/path@ref, and GitHub's workflow parser rejects a ref containing @ -- so a repository of actions sets '${name}-v${version}' and every workflow pins ...@<action>-v<major>. Note that switching an existing repository's format starts a fresh tag namespace: semantic-release will not see prior releases recorded under the old format, so rename existing tags to the new template as part of the switch.
pnpm exec semantic-release-workspace release --tag-format '${name}-v${version}'A gated run records each package's tag in its state file, so resume takes no tag format of its own.
commitStrategy changes how a release is committed; gatePublish changes when it gets published relative to being tagged and pushed -- a different, orthogonal axis. With gatePublish: true, each due package is tagged and pushed via @exadev/release-gate (a real dependency of this package -- no separate install needed) but never published: nothing calls npm publish, creates a GitHub Release, or runs any other configured publish step, until a separate resume step does so explicitly -- from the same process, or a completely different one (a later CI job, once a deploy or smoke test has confirmed the release should actually go out).
Rejected outright combined with commitStrategy: 'single': that mode's tag/publish machinery is entirely bespoke and never goes through semantic-release's own run() (see Commit strategies above), so it has no insertion point for release-gate's detach/resume primitives.
Two-step CI example -- tag in one job, publish once a gate has passed in a later one:
jobs:
tag:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version-file: .node-version }
- run: pnpm install --frozen-lockfile
- run: pnpm exec semantic-release-workspace release --gate-publish --gate-state-file gate-state.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
with: { name: gate-state, path: gate-state.json }
deploy-and-verify:
needs: tag
runs-on: ubuntu-latest
steps:
- run: ./deploy-and-smoke-test.sh # whatever the actual gate is
publish:
needs: deploy-and-verify
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version-file: .node-version }
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@v4
with: { name: gate-state }
- run: pnpm exec semantic-release-workspace resume --gate-state-file gate-state.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: 'true'The programmatic equivalent, run in the same process or two separate ones:
import { releaseWorkspace, resumeWorkspaceRelease } from '@exadev/semantic-release-workspace';
// Detach: tags and pushes, publishes nothing.
const { detached } = await releaseWorkspace({ root: process.cwd(), gatePublish: true });
// ...later, once whatever external gate needs to pass has passed, in this process or a fresh one:
const outcome = await resumeWorkspaceRelease({ root: process.cwd(), detached: detached ?? [] });
for (const pkg of outcome.packages) {
console.log(pkg.name, pkg.released ? `published ${pkg.gitTag}` : 'no release');
}detached (WorkspaceReleaseOutcome.detached, present only when gatePublish: true) carries everything resumeWorkspaceRelease needs -- one entry per package, each holding either the @exadev/release-gate state for a real release or null for a package with nothing to release. Its relativeDirectory field, not an absolute path, is what a resume pass resolves cwd from, so it works correctly even when the resume runs against a different checkout of the same repository than the one that ran the detach.
This tool exists because of documents.js#664's research, which compared the third-party landscape — @qiwi/multi-semantic-release (itself a fork of dhoulb's original), its successor bulk-release, and Changesets — against building in-house, and chose in-house: the org already maintains shared tooling config in exactly this shape, semantic-release's plugin lifecycle is well documented rather than proprietary, and the failure modes specific to cross-package version propagation were already understood from operating the ecosystem's existing automation (background reading).
The core technique is the same one multi-semantic-release proved in production: per-package semantic-release with a name@version tag format and commit lists path-filtered to the package's directory. The differences are deliberate:
- In-process delegation, not CLI wrapping. semantic-release is invoked through its programmatic API with inline plugin functions, so the wrappers delegate to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator running in the same process. (The analysed plugins are ESM named exports here, resolved as peers of this package — no plugin re-implementation anywhere.)
- Bump-only dependents always release. multi-semantic-release rewrites dependency ranges in the working tree without committing them, so a dependent whose only change is a dependency update can go unreleased until some other commit triggers it. Here the bump is committed before the dependent's turn and a patch release is forced deterministically (see the timing section above).
- Loud failures by design. A dependency cycle, an unsupported dependency range, a publish pipeline without @semantic-release/git (which would leave released manifests uncommitted), an unresolvable plugin, a duplicate package name — each stops the run with a specific error rather than degrading silently. There is deliberately no "skip this package and carry on" path: a partially-consistent set of publishes is worse than none.
- Only installable specifiers reach the registry.
workspace:,catalog:,link:, andfile:specifiers in a publishable package's installed dependency fields are rejected instead of being published as written. In private packages anddevDependencies,workspace:*/workspace:^/workspace:~are understood as naming no version (bump the release, not the manifest text), andcatalog:andnpm:aliases on a workspace sibling are rejected with an explanation instead of being mangled. - Private packages release without advertising themselves. A package marked
privatetakes part in the run exactly as any other, tag and version bump and dependency cascade included, but creates no GitHub Release, because a Release for a package that never reaches a registry both points at nothing installable and takes the repository's Latest label off a package that does. - Workspace-agnostic discovery. Everything comes from
pnpm-workspace.yamland the manifests its globs match; pointing the orchestrator at any pnpm workspace is the entire configuration.
Out of scope, on purpose: parallelising independent branches of the dependency graph (packages release sequentially in topological order for correctness first — a real future optimisation, not attempted here), and any Changesets-style explicit-changeset mode, which is a different paradigm rather than a missing feature.
Install once at the workspace root (the six standard plugins are peer dependencies and must be installed alongside):
pnpm add -D @exadev/semantic-release-workspace semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/changelog @semantic-release/npm @semantic-release/github @semantic-release/gitThen one step replaces the per-repo release job. In GitHub Actions (this package's own OIDC trusted-publishing pattern carries over unchanged — publishing credentials stay between semantic-release's plugins and the registry):
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- run: pnpm install --frozen-lockfile
- run: pnpm exec semantic-release-workspace release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: 'true'Run it from the workspace root (or pass --root <directory>). A dry run analyses and reports every package's would-be release, including the dependency-bump cascade, without publishing, tagging, committing, or pushing — useful locally, where semantic-release would otherwise force dry-run mode anyway outside CI.
| Option | Meaning |
|---|---|
--root <directory> |
Workspace root holding pnpm-workspace.yaml (default: the process working directory) |
--dry-run |
Analyse and report only |
--branches <branch> |
Release branch for semantic-release; repeat for multiple branches (default: semantic-release's own default branch list) |
--plugin <spec> |
Publish-pipeline plugin, repeatable — a module name (@semantic-release/github) or a JSON tuple ('["@semantic-release/git",{"assets":["package.json"]}]'); defaults to the standard changelog/npm/github/git pipeline |
--analyze-commits <json> |
Options for the wrapped @semantic-release/commit-analyzer (e.g. '{"preset":"conventionalcommits","releaseRules":[...]}') |
--generate-notes <json> |
Options for the wrapped @semantic-release/release-notes-generator |
--commit-strategy <mode> |
per-package (default) or single — see Commit strategies |
--tag-format <template> |
Template for each package's release tag, with ${name} and ${version} placeholders; must contain ${version} (default: ${name}@${version}) — see Tag format |
--gate-publish |
Tag and push each due package, but defer publishing — see Gating publish. Requires --gate-state-file; rejected with --commit-strategy single |
--gate-state-file <path> |
With --gate-publish: where to write the state a later resume run needs |
--config <file> |
A config file (.json, .yaml, .yml, .js, .cjs, .mjs, .ts, .cts, or .mts, loaded via cosmiconfig) providing any of the above, plus packagePlugins (see Per-package publish plugins), through its default export; explicit flags win. TypeScript files are run by Node's own type stripping, so they may use only erasable type syntax (annotations and import type, not enum or namespace) |
resume (a separate subcommand, not a release flag) finishes publishing what a --gate-publish run tagged and pushed:
| Option | Meaning |
|---|---|
--root <directory> |
Workspace root holding pnpm-workspace.yaml in this checkout — may differ from the one that ran release --gate-publish (default: the process working directory) |
--gate-state-file <path> |
Required — the state file a release --gate-publish run wrote |
Listing @semantic-release/commit-analyzer or @semantic-release/release-notes-generator as a --plugin is rejected: the orchestrator always provides those two steps itself (wrapped), so configuring them there would be a silent no-op — pass their options via --analyze-commits/--generate-notes instead. A real (non-dry) run under commitStrategy: 'per-package' (the default) must include @semantic-release/git in the pipeline, because without it nothing commits released manifests and changelogs back to the branch; commitStrategy: 'single' is the opposite — it rejects @semantic-release/git outright, since it does that committing itself (see Commit strategies).
Note that the orchestrator sets tagFormat, plugins, analyzeCommits, and generateNotes explicitly on every per-package run, so those keys in any release.config.* found in the workspace are overridden by construction — configure the release through the orchestrator, not through a leftover single-package config.
A package whose manifest sets "private": true gets the workspace-wide plugin list minus @semantic-release/github, without being configured to. Nothing else about its release changes: it still gets its version bump, its name@version tag and the dependency cascade to its dependents, all of which a dependent's own release can hinge on (a private package whose build output ships inside a published one, for example). What it loses is a public GitHub Release for something nobody can install.
That release is not merely redundant. @semantic-release/github sets the REST API's make_latest from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. Topological order puts a package that depends on the published ones at the end of the run, which is exactly where a private package usually sits, so without this rule the repository's front page advertises an unpublishable package as its current release.
To keep the Release for a private package anyway, name it in packagePlugins below: an explicit list is taken exactly as written.
plugins is one list for the whole workspace. packagePlugins (config file and programmatic API only, since a list keyed by package name has no natural flag form) replaces that list outright for the packages it names, and every other package keeps the workspace-wide list, or the private-package variant of it described above. The override is not merged with either: it is the complete list for that package, subject to the same rules as any other (for instance @semantic-release/git is required under commitStrategy: 'per-package' and rejected under 'single'). A name that is not a package in the workspace is rejected, so a misspelling cannot leave a package on the default list unnoticed.
// release-workspace.config.ts
import { DEFAULT_PUBLISH_PLUGINS, type ReleaseWorkspaceOptions } from '@exadev/semantic-release-workspace';
const config: ReleaseWorkspaceOptions = {
packagePlugins: {
// A private package that does want its GitHub Release, opting back in to the list every public package gets.
'@acme/internal-tooling': DEFAULT_PUBLISH_PLUGINS,
// A published package kept out of a step the rest need.
'@acme/docs-site': DEFAULT_PUBLISH_PLUGINS.filter((plugin) => plugin !== '@semantic-release/npm'),
},
};
export default config;The override behaves the same under both commit strategies and with gatePublish. A gated run persists each package's pipeline in its state file, so the override reaches resume without being repeated there. Under commitStrategy: 'single' start from SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS instead of DEFAULT_PUBLISH_PLUGINS.
import { releaseWorkspace } from '@exadev/semantic-release-workspace';
const outcome = await releaseWorkspace({
root: process.cwd(),
dryRun: false,
// commitStrategy: 'per-package' is the default -- omit it entirely for today's exact behaviour.
plugins: [
'@semantic-release/changelog',
'@semantic-release/npm',
'@semantic-release/github',
['@semantic-release/git', { assets: ['CHANGELOG.md', 'package.json'], message: 'chore(release): ${nextRelease.gitTag} [skip ci]' }],
],
analyzeCommits: { preset: 'conventionalcommits' },
});
for (const pkg of outcome.packages) {
console.log(pkg.name, pkg.released ? `released ${pkg.gitTag}` : 'no release', pkg.dependencyBumps);
}Opting into one combined commit for the whole run (see Commit strategies) is the same call with commitStrategy: 'single' and no @semantic-release/git in the plugin list — omit plugins entirely and SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS (changelog, npm, github) applies automatically:
const outcome = await releaseWorkspace({
root: process.cwd(),
commitStrategy: 'single',
});Every stage is also exported individually — discoverWorkspace, buildDependencyGraph, topologicalOrder, updateDependencyRange, createScopedPlugins, filterCommitsToDirectory, resumeWorkspaceRelease — along with the error hierarchy (WorkspaceReleaseError and friends) so embedders can distinguish orchestration failures from unexpected crashes.
- Node 22.18 or later on the 22 line, or Node 24 and later: the range the config loader (cosmiconfig) supports, and the first 22 release that strips TypeScript types without a flag.
- A git repository with a pushable
origin(semantic-release verifies push access even in dry runs, and pushes tags and release commits in real ones). The workspace does not need to sit at the repository's toplevel -- discovery resolves its own prefix within the repository and scopes commit filtering against it -- but it does need to sit inside one. - A git identity (
user.name/user.email) in CI for the[skip ci]bump commits, or the semantic-release-bot fallback identity is used automatically. - A branch checkout, not a detached HEAD: dependency-bump commits are pushed to the current branch by name, so a detached HEAD stops the run with a
WorkspaceStateErrorrather than pushingHEAD:HEADat the remote. CI checkouts that default to a detached HEAD need the branch checked out explicitly. - A recognised CI environment for real runs (semantic-release refuses to publish from an unknown environment unless told otherwise); outside CI it falls back to dry-run behaviour.
- Merge commits count for no package:
git log --name-onlylists no files for them, so their changes arrive through their parents, which the same range covers individually. Squash-merge workflows are unaffected, since a squash commit is an ordinary commit with a full file list. - A
resumerun needs the same checkout therelease --gate-publishrun pushed to, or an equivalent one at the same commit and tags (a freshgit clone/checkout of the same repository at the same ref works fine) -- it resolves each package's directory frompnpm-workspace.yamlrelative to its own--root, not from any absolute path recorded during the detach pass.
The full orchestration path — discovery, topological ordering with cycle rejection, path-scoped analysis and notes, cross-package manifest bumping with forced patch releases, and the standard publish pipeline, for both commit strategies — is implemented and exercised end to end by the test suite against real temporary git workspaces (real commits, tags, bare remotes, and semantic-release runs with the npm registry switched off), not just by unit tests of the pieces in isolation.
Tracked follow-up work lives in documents.js#664, which also covers migrating the documents.js ecosystem's repositories onto this tool.
pnpm install
pnpm run lint # eslint, zero warnings
pnpm run typecheck # tsc --noEmit, strict
pnpm run test # vitest: unit + real-git integration fixtures
pnpm run build # tsdown: dist/ library + bin
pnpm run test:smoke # rebuilds, then spawns the real dist/cli.js as a subprocessConventional commits, enforced by commitlint; releases of this package itself go through semantic-release on main.
MIT