pnpm 14 📦 Publishing Packages and Managing Scoped Registries
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
| Setting | Location | Purpose |
|---|---|---|
registry= | .npmrc | Default registry URL |
@scope:registry= | .npmrc | Scoped registry URL |
registries | pnpm-workspace.yaml | pnpm 11 native routing |
| Token | Global config or env | Never committed |
Publish Commands
| Command | Purpose |
|---|---|
pnpm publish | Publish current package |
pnpm publish --dry-run | Preview without publishing |
pnpm publish --access public | Public scoped package |
pnpm publish --tag next | Attach dist-tag |
pnpm -r publish | Publish all unpublished workspaces |
pnpm --filter <pkg> publish | Publish specific package |
Publish Flags
| Flag | Effect |
|---|---|
--no-git-checks | Skip branch/cleanliness checks |
--force | Publish even if version exists |
--otp | One-time password |
--provenance | Generate attestation (CI only) |
--batch | Batch publish (pnpm 11.7+) |
--report-summary | Save publish summary |
Environment Variables
| Variable | Purpose |
|---|---|
PNPM_CONFIG_OTP | OTP for CI (replaces NPM_CONFIG_OTP) |
NPM_CONFIG_//registry/:_authToken | Token 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Token not expanded | Committed .npmrc | Use pnpm config set or env variable |
| Scoped registry not used | Missing @scope:registry | Add to .npmrc |
| Scoped package publish fails | Default access is restricted | Add --access public |
| OTP not accepted | Using NPM_CONFIG_OTP | Use PNPM_CONFIG_OTP |
| Recursive publish fails | Version already exists | Use --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
| Item | Value |
|---|---|
| Publish command | pnpm publish (native since v11) |
| Scoped registry | @scope:registry= in .npmrc |
| Token source | Global config, user ~/.npmrc, or env |
| Public scoped flag | --access public |
| Dist-tag | --tag <name> |
| Recursive publish | pnpm -r publish |
| Dry run | --dry-run |
| Git checks skip | --no-git-checks |
| OTP env | PNPM_CONFIG_OTP |
| Provenance | --provenance (CI only) |
Key takeaways:
pnpm publishis native since v11. It no longer delegates to the npm CLI. The OTP environment variable isPNPM_CONFIG_OTP, notNPM_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
.npmrcwith${TOKEN}is ignored for registry and credential settings. Usepnpm config setor a user-level~/.npmrc. --access publicis 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.
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!