Publishing Packages from a pnpm Workspace
Publish individual packages from a pnpm workspace: resolve workspace protocol deps, order builds, and ship correct exports for each published package.
Run npm publish inside a package folder from a pnpm workspace and the packed tarball can end up with a literal "@acme/core": "workspace:^" line in its dependencies — a range string that means nothing to any registry outside your workspace. Anyone who installs the package gets:
npm error code ETARGET
npm error notarget No matching version found for @acme/core@workspace:^.
The fix requires publishing through pnpm itself (or a release tool built on top of it), because only pnpm’s own publish path knows to rewrite workspace: ranges into real version numbers before the manifest is packed. This builds on the workspace and reference wiring covered in Monorepo & Workspace Publishing.
Root Cause
The workspace: protocol exists purely for local development inside a pnpm workspace — it tells pnpm “resolve this dependency from a sibling directory, not the registry.” It is never a valid value in a published package.json. pnpm’s publish command intercepts the pack step and rewrites every workspace: range to the current version of the referenced sibling (respecting the range operator you used — ^, ~, or an exact pin) before the tarball is created. Any other tool that packs the manifest directly — a raw npm pack, a custom CI script that copies package.json verbatim — bypasses that rewrite and ships the literal string.
Minimal Reproduction
// packages/app/package.json
{
"name": "@acme/app",
"version": "1.0.0",
"dependencies": {
"@acme/core": "workspace:^"
}
}
cd packages/app
npm publish --access public # WRONG — bypasses pnpm's rewrite step
The published tarball’s package.json still contains "workspace:^" verbatim, because plain npm publish has no awareness of pnpm-specific protocol syntax.
Mechanism: What pnpm publish Rewrites
Step-by-Step Fix
Step 1 — Confirm the build is current before publishing
{
"scripts": {
"build": "tsc --build",
"prepublishOnly": "pnpm run build && npx publint && npx attw --pack ."
}
}
pnpm runs prepublishOnly automatically before packing, so a stale or missing dist/ fails the publish rather than shipping outdated code.
Step 2 — Publish leaf packages first, using pnpm directly
pnpm --filter "@acme/core" publish --access public
Expected output (excerpt):
@acme/core: Packed 12 files, 8.4kB (2.1kB gzipped)
+ @acme/[email protected]
Step 3 — Publish dependent packages and confirm the rewrite
pnpm --filter "@acme/utils" publish --access public
Before publishing, inspect what pnpm will actually pack:
pnpm --filter "@acme/utils" pack --pack-destination /tmp
tar -xOzf /tmp/acme-utils-1.3.0.tgz package/package.json | grep "@acme/core"
Expected result:
- "@acme/core": "workspace:^"
+ "@acme/core": "^1.3.0"
HAZARD PREVENTION
Symptom:
pnpm publishsucceeds without error, but the published tarball still shows"workspace:^"when inspected.Root cause: The command was run from the workspace root without
--filter, or--recursive(-r) was combined with a script that shells out tonpm publishinternally instead of pnpm’s own publish path.Fix: Always invoke
pnpm publish(orpnpm --filter <name> publish) directly — never wrap it in a script that ultimately callsnpm publishon the same directory.
Step 4 — Publish the top-level consumer last
pnpm --filter "@acme/app" publish --access public
At this point every internal workspace: range in the dependency chain has been rewritten to a concrete version that already exists on the registry, so a fresh npm install @acme/app from outside the workspace resolves cleanly.
Verification
# From a directory outside the workspace entirely
mkdir /tmp/consumer-check && cd /tmp/consumer-check
npm init -y
npm install @acme/app
# Confirm no workspace: strings survived into node_modules
grep -r "workspace:" node_modules/@acme/*/package.json && echo "FAIL" || echo "PASS: all ranges resolved"
Expected: PASS: all ranges resolved, and node -e "require('@acme/app')" loads without a resolution error.
Edge Cases / Gotchas
- Scoped packages default to private. Add
--access publicon the very first publish of any@scope/namepackage or the registry rejects it with402 Payment Required. workspace:*vsworkspace:^vsworkspace:~. The operator you choose is preserved in the rewrite —workspace:*becomes an exact pinned version ("1.3.0"), whileworkspace:^becomes a caret range ("^1.3.0"). Pickworkspace:*for internal packages you always want to bump in lockstep.- Dry-run before the real publish.
pnpm publish --dry-runprints exactly what would be packed, including the rewritten ranges, without touching the registry — always run this in CI before the real publish step. - npm workspaces have no equivalent rewrite. If your workspace uses npm’s
"workspaces"field instead of pnpm, there is noworkspace:protocol to rewrite — declare internal dependencies with a normal semver range and bump them manually or via a release tool. - Publishing from CI with a frozen lockfile. Run
pnpm install --frozen-lockfileimmediately before publishing in CI so the published build reflects exactly what is committed, not a locally modifiednode_modules.
FAQ
Does npm publish also rewrite the workspace: protocol like pnpm does?
No. Plain npm workspaces do not rewrite workspace-relative ranges automatically because npm workspaces do not use the workspace: protocol at all — internal dependencies are declared with a normal semver range and npm relies on hoisting and symlinking within the workspace during development. If you use pnpm’s workspace: syntax in a repository that publishes with npm, you must rewrite those ranges yourself before packing.
Can I publish all packages in one command?
pnpm has no single built-in command that publishes every workspace package in dependency order automatically. Use pnpm -r publish for a simple recursive publish when packages have no interdependencies that require strict ordering, or use a release tool such as Changesets, which computes the dependency order and calls pnpm publish per package in the correct sequence.
What happens if I forget --access public on a scoped package?
npm defaults new scoped packages (anything starting with @scope/) to private, which requires a paid organization plan on the registry. Without --access public, the first publish of a new scoped package fails with 402 Payment Required even on a free npm account, unless the scope already has a default publish access level configured.
How do I avoid publishing a package whose build is out of date?
Add a prepublishOnly script that runs the build and validation commands, and rely on pnpm’s default behavior of running that script before packing. Do not rely on a stale dist/ directory left over from a previous session — pair prepublishOnly with tsc --build so publishing always reflects the latest compiled output.
Related
- tsconfig Project References for Monorepos — the build graph that must compile cleanly before any of these packages can be published.
- Versioning and Changelog Automation — automating the version bumps that determine what each
workspace:range gets rewritten to. - Mastering the package.json Exports Field — every package published here still needs its own correct
exportsmap independent of the workspace protocol.