| | |

pnpm 14 📦 Publishing Packages and Managing Scoped Registries

⏱️ Estimated read: 10 min | |

Publishing with pnpm is not a wrapper around the npm CLI. Since pnpm 11, pnpm publish is implemented natively, which means the behavior differs from npm in several important ways: OTP is passed through PNPM_CONFIG_OTP instead of NPM_CONFIG_OTP, web-based authentication prints a QR code, and the packageManager field is stripped from the published manifest unless --skip-manifest-obfuscation is used . Scoped registries are configured in .npmrc, but the authentication token must never be committed to the repository. The split between .npmrc (registry and auth) and pnpm-workspace.yaml (pnpm-specific behavior) is the foundation of a correct publishing setup.

Key point: pnpm publish publishes the current workspace package. The registry is determined by the @scope:registry entry in .npmrc. The token is injected from a trusted source—global config or user-level ~/.npmrc—never from the committed project file.


Why native publishing and scoped registries matter

The npm CLI dependency removal. In pnpm 10 and earlier, pnpm publish delegated to the npm CLI. This meant the behavior was npm’s behavior, the authentication was npm’s authentication, and the bugs were npm’s bugs. pnpm 11 removed the delegation. Commands like access, team, and token now throw “not implemented” because they were npm passthroughs that pnpm chose not to reimplement . The native publish command is the only path forward.

The scoped registry routing problem. A package named @myorg/utils must be published to the internal registry, not the public npm registry. The @myorg:registry=https://npm.internal.example.com/ line in .npmrc routes every package under the @myorg scope to that URL . Without this line, the package is published to the default registry, which either fails (if the scope is not claimed) or publishes private code to the public internet.

The token security problem. A committed .npmrc with //registry/:_authToken=${NPM_TOKEN} is ignored by pnpm since v10.34.2 for registry and credential settings. The environment variable is not expanded in repository-controlled files because a malicious repository could exfiltrate CI tokens by writing a registry URL that includes the token . The token must be injected from a trusted source: pnpm config set (global config), user-level ~/.npmrc, or the NPM_CONFIG_//registry/:_authToken environment variable.

The workspace publishing problem. In a monorepo, pnpm -r publish publishes every workspace package whose version is not yet in the registry . The workspace: protocol in dependencies is replaced with the actual version during packing, so the published package is consumable by external users . The LICENSE file from the workspace root is packed with each package unless the package has its own license .

The trade-off. Native publishing means pnpm-specific behavior for OTP, provenance, and batch publishing. Scoped registries mean every package under a scope must be routed correctly. The token security means the setup is split across files and environments. The configuration is more complex, but the security is stronger.


a. Configuring scoped registries in .npmrc

The .npmrc file is INI format. Registry and authentication settings live here because pnpm’s publishing still needs to communicate with npm-compatible registries . The default registry is set with registry=. A scoped registry is set with @scope:registry=.

# .npmrc — committed to repository
registry=https://registry.npmjs.org/
@myorg:registry=https://npm.internal.example.com/

The @myorg:registry line routes every package named @myorg/* to the internal URL. The trailing slash is required. When pnpm publish runs from a package named @myorg/utils, pnpm reads this line, routes the request to the internal registry, and authenticates using the token configured for that URL .

The authentication token must not be in this file. The correct pattern is to inject it from a trusted source:

# Write to global config (not the repository)
pnpm config set //npm.internal.example.com/:_authToken "$INTERNAL_TOKEN"

The pnpm config set command writes to the global configuration file by default. The token never enters the repository . For CI environments, the NPM_CONFIG_//registry/:_authToken environment variable can be set directly, or actions/setup-node with registry-url writes the token to a user-level .npmrc .

In pnpm 11, the registries setting in pnpm-workspace.yaml is the newer location for registry routing. The @scope:registry lines in .npmrc are still supported for npm compatibility, but the YAML setting is the pnpm-native approach .

# pnpm-workspace.yaml
registries:
  default: https://registry.npmjs.org/
  "@myorg": https://npm.internal.example.com/

b. Publishing with pnpm publish and its flags

The pnpm publish command packs the current workspace package into a tarball and uploads it to the registry determined by the scope configuration. The --dry-run flag performs every step except the actual upload, which is the correct way to verify the file list before publishing .

# Dry run: verify the tarball contents
pnpm publish --dry-run

# Publish a public scoped package
pnpm publish --access public

# Publish with a dist-tag
pnpm publish --tag next

# Publish even if the version already exists
pnpm publish --force

The --access public flag is required for scoped packages published to the public npm registry. Without it, the package is published as restricted, which requires a paid plan . The --tag flag attaches a dist-tag other than latest, which is used for pre-releases and canary builds .

The --no-git-checks flag skips the branch and cleanliness checks. By default, pnpm verifies that the current branch is the publish branch (master or main), that the working tree is clean, and that the branch is up to date with the remote. This prevents publishing from a feature branch or from a dirty working tree .

For CI environments with two-factor authentication, the OTP is passed through the PNPM_CONFIG_OTP environment variable or the --otp flag. If neither is provided and the registry requests an OTP, pnpm prompts interactively .

# CI publish with OTP
export PNPM_CONFIG_OTP='123456'
pnpm publish --no-git-checks

The --provenance flag generates a signed attestation linking the package to the source repository and build. It is available only in supported CI environments and requires the appropriate permissions .


c. Publishing in a workspace

In a monorepo, pnpm -r publish publishes every workspace package whose version is not yet in the registry. The recursive flag iterates over all workspaces, checks the registry for each version, and publishes the ones that are missing .

# Publish all unpublished workspace packages
pnpm -r publish --access public

# Filter to a specific package
pnpm --filter @myorg/utils publish --access public

The workspace: protocol in dependencies is replaced during packing. If @myorg/app depends on @myorg/utils with "workspace:*", the published @myorg/app will contain the actual version of @myorg/utils instead of the protocol . This allows the published package to be consumed by external users without the workspace context.

In pnpm 11.7, the --batch flag was added for recursive publishing. It sends all selected packages to the registry in a single PUT /-/pnpm/v1/publish request instead of one request per package. The batch is processed all-or-nothing: if any package fails validation, none are published. The registry must implement the batch endpoint; otherwise, the command fails with ERR_PNPM_BATCH_PUBLISH_UNSUPPORTED .

The pnpm publish --report-summary flag saves the list of published packages to pnpm-publish-summary.json. This is useful for CI pipelines that need to report which packages were published .


Complete Example Session

# ============================================
# PART 1: CONFIGURE SCOPED REGISTRY
# ============================================

# .npmrc
registry=https://registry.npmjs.org/
@myorg:registry=https://npm.internal.example.com/
# ============================================
# PART 2: INJECT TOKEN FROM TRUSTED SOURCE
# ============================================

pnpm config set //npm.internal.example.com/:_authToken "$INTERNAL_TOKEN"
# Written to global config, not to .npmrc
# ============================================
# PART 3: VERIFY AUTHENTICATION
# ============================================

npm whoami --registry=https://npm.internal.example.com/
# Output: your-username
# ============================================
# PART 4: DRY RUN
# ============================================

pnpm publish --dry-run
# Lists files to be packed.
# Confirms version and registry.
# ============================================
# PART 5: PUBLISH PUBLIC SCOPED PACKAGE
# ============================================

pnpm publish --access public
# Package published to the configured registry.
# ============================================
# PART 6: PUBLISH WITH TAG
# ============================================

pnpm publish --tag beta
# Package attached to "beta" dist-tag.
# ============================================
# PART 7: RECURSIVE PUBLISH
# ============================================

pnpm -r publish --access public
# All unpublished workspace packages are published.
# ============================================
# PART 8: CI PUBLISH WITH OTP
# ============================================

export PNPM_CONFIG_OTP='123456'
pnpm publish --no-git-checks --provenance
# ============================================
# PART 9: VERIFY PUBLISHED PACKAGE
# ============================================

npm view @myorg/utils --registry=https://npm.internal.example.com/
# Shows package metadata and version.

The nine parts trace the publishing workflow: registry configuration, token injection, authentication verification, dry run, public scoped publish, tagged publish, recursive publish, CI publish with OTP and provenance, and verification.


Quick Reference

Registry Configuration

SettingLocationPurpose
registry=.npmrcDefault registry URL
@scope:registry=.npmrcScoped registry URL
registriespnpm-workspace.yamlpnpm 11 native routing
TokenGlobal config or envNever committed

Publish Commands

CommandPurpose
pnpm publishPublish current package
pnpm publish --dry-runPreview without publishing
pnpm publish --access publicPublic scoped package
pnpm publish --tag nextAttach dist-tag
pnpm -r publishPublish all unpublished workspaces
pnpm --filter <pkg> publishPublish specific package

Publish Flags

FlagEffect
--no-git-checksSkip branch/cleanliness checks
--forcePublish even if version exists
--otpOne-time password
--provenanceGenerate attestation (CI only)
--batchBatch publish (pnpm 11.7+)
--report-summarySave publish summary

Environment Variables

VariablePurpose
PNPM_CONFIG_OTPOTP for CI (replaces NPM_CONFIG_OTP)
NPM_CONFIG_//registry/:_authTokenToken via environment

Best Practices

✅ Do This:

@myorg:registry=https://npm.internal.example.com/
<!-- Route scope to internal registry -->            # ✅
pnpm config set //npm.internal.example.com/:_authToken "$TOKEN"
<!-- Inject token from trusted source -->             # ✅
pnpm publish --dry-run
<!-- Verify file list before publishing -->          # ✅

❌ Don’t Do This:

# Commit token to .npmrc
<!-- Token exfiltration risk -->                      # ❌
# Publish scoped package without --access public
<!-- Defaults to restricted (paid) -->                # ❌
# Use --no-git-checks outside CI
<!-- Bypasses branch and cleanliness checks -->       # ❌

Common Pitfalls

PitfallWhy It HappensFix
Token not expandedCommitted .npmrcUse pnpm config set or env variable
Scoped registry not usedMissing @scope:registryAdd to .npmrc
Scoped package publish failsDefault access is restrictedAdd --access public
OTP not acceptedUsing NPM_CONFIG_OTPUse PNPM_CONFIG_OTP
Recursive publish failsVersion already existsUse --force or bump version

Real-World Examples

1. Configure Scope

@myorg:registry=https://npm.internal.example.com/

2. Inject Token

pnpm config set //npm.internal.example.com/:_authToken "$TOKEN"

3. Dry Run

pnpm publish --dry-run

4. Public Scoped Publish

pnpm publish --access public

5. Tagged Publish

pnpm publish --tag beta

6. Recursive Publish

pnpm -r publish --access public

7. Filtered Publish

pnpm --filter @myorg/utils publish --access public

8. CI OTP

export PNPM_CONFIG_OTP='123456'
pnpm publish --no-git-checks

9. Provenance

pnpm publish --provenance

10. Verify

npm view @myorg/utils --registry=https://npm.internal.example.com/

Visual

Publish Routing

┌──────────────────────────────────────────────┐
│  pnpm publish (package: @myorg/utils)        │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  .npmrc: @myorg:registry=...                 │
│  → route to internal registry                │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Token from global config or env             │
│  → authenticate to internal registry         │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Package uploaded to registry                │
└──────────────────────────────────────────────┘

Token Security Split

┌──────────────────────────────────────────────┐
│  REPOSITORY (.npmrc)                         │
│  ├── registry URLs                           │
│  ├── @scope:registry                         │
│  └── NO tokens                               │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  TRUSTED SOURCE (global config/env)          │
│  ├── pnpm config set //.../:_authToken       │
│  ├── ~/.npmrc (user-level)                   │
│  └── NPM_CONFIG_//.../:_authToken            │
└──────────────────────────────────────────────┘

Summary

ItemValue
Publish commandpnpm publish (native since v11)
Scoped registry@scope:registry= in .npmrc
Token sourceGlobal config, user ~/.npmrc, or env
Public scoped flag--access public
Dist-tag--tag <name>
Recursive publishpnpm -r publish
Dry run--dry-run
Git checks skip--no-git-checks
OTP envPNPM_CONFIG_OTP
Provenance--provenance (CI only)

Key takeaways:

  • pnpm publish is native since v11. It no longer delegates to the npm CLI. The OTP environment variable is PNPM_CONFIG_OTP, not NPM_CONFIG_OTP .
  • Scoped registries are configured in .npmrc. The @scope:registry= line routes every package under that scope to the specified URL .
  • Tokens must never be committed. A committed .npmrc with ${TOKEN} is ignored for registry and credential settings. Use pnpm config set or a user-level ~/.npmrc .
  • --access public is required for public scoped packages. Without it, the package is published as restricted, which requires a paid plan .
  • Recursive publishing publishes all unpublished workspaces. The workspace: protocol is replaced with the actual version during packing, so external users can consume the packages .

Remember: Publishing is a one-way operation. The dry run is not optional. The token is not a file in the repository. The registry is determined by the scope, not by the command line. The native pnpm publish command handles the tarball, the authentication, and the upload. The .npmrc handles the routing. The global config handles the secret.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!