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

npm publish vs pnpm publish inside a workspace Comparison showing that a raw npm publish command packs the manifest as-is and ships the literal workspace colon caret string, while pnpm publish rewrites that range to the sibling package's real published version before creating the tarball. npm publish (raw) pnpm publish packs manifest as-is ships "workspace:^" literally install fails with ETARGET unsafe inside a pnpm workspace reads sibling's published version rewrites to "^1.3.0" before packing install resolves normally the only safe path for workspace: ranges

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 publish succeeds 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 to npm publish internally instead of pnpm’s own publish path.

Fix: Always invoke pnpm publish (or pnpm --filter <name> publish) directly — never wrap it in a script that ultimately calls npm publish on 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 public on the very first publish of any @scope/name package or the registry rejects it with 402 Payment Required.
  • workspace:* vs workspace:^ vs workspace:~. The operator you choose is preserved in the rewrite — workspace:* becomes an exact pinned version ("1.3.0"), while workspace:^ becomes a caret range ("^1.3.0"). Pick workspace:* for internal packages you always want to bump in lockstep.
  • Dry-run before the real publish. pnpm publish --dry-run prints 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 no workspace: 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-lockfile immediately before publishing in CI so the published build reflects exactly what is committed, not a locally modified node_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.



↑ Back to Monorepo & Workspace Publishing