Workspaces & Publishing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out. All versions and statuses were checked on 2026-10-03, and every example below was run on the three lines unless it says otherwise. The npm CLI matters as much as the Node.js version here: Node.js 26.10.0 bundles npm 11.19.1, Node.js 24.21.0 bundles npm 11.19.0 and Node.js 22.23.3 bundles npm 10.9.9, while npm 12 (12.2.0 on 2026-09-30) is the latest release on the registry but is not bundled with any Node.js line yet.

Workspaces, npm link, npm pack and npm publish are npm features, not Node.js APIs, so the npm documentation carries no stability index for them. The statuses that do exist are these. "exports" and "engines" are long-standing, stable package metadata (conditional exports have been unflagged since v13.7.0 and v12.17.0). Trusted publishing has been generally available since 2025-07-31. Staged publishing and the npm trust command are newer (npm 11.15.0 and 11.10.0) and are documented without a maturity label, so expect their details to keep moving. Corepack was Stability 1 Experimental in the Node.js 24 documentation and is no longer distributed with Node.js from v25.0.0. Node.js package maps (--experimental-package-map, added in v26.4.0) are Stability 1 Experimental.

The running scenario is the Bookshelf application of this section, split into two packages: a small library, @bookshelf/core, and an application package, @bookshelf/cli, that uses it. The basics of publishing a library (accounts, npm login, scoped packages, tokens, bundlers) are in Bundling & npm Publishing; this page adds what is specific to Node.js packages and to the 2025-2026 changes in how npm authenticates a publish. Dependency management itself is in npm & Package Management, and the exports and imports fields are explained in package.json, exports & imports.

npm workspaces

Workspaces let one top-level package, the root, manage several packages that live in folders of the same repository. When npm install runs in the root, npm creates a symbolic link in the root node_modules for each workspace, so that every package can be required or imported by its declared name, exactly as if it had been installed from the registry. The npm documentation describes this as automating the linking that you would otherwise do by hand with npm link.

Node.js itself knows nothing about workspaces. It only sees a symlink in node_modules, and by default it follows the link and uses the real path of the target as the identity and the root of the module (the --preserve-symlinks flag turns that off). The packaging layer (Arborist, inside npm) builds the links; the runtime’s normal node_modules resolution then does the rest. That division explains most behaviours below.

Declaring a workspace

A workspace is declared in the root package.json through the workspaces array, with folder paths or globs. The root of a monorepo is usually "private": true, which makes npm refuse to publish it.

A root package with two workspaces
{
  "name": "bookshelf-monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "test": "npm run test --workspaces --if-present"
  }
}

The library has its own package.json, with the fields that make it publishable (they are explained in What publishing adds for Node packages), and the application depends on it with an ordinary version range.

packages/core/package.json
{
  "name": "@bookshelf/core",
  "version": "1.0.0",
  "description": "Bookshelf domain logic",
  "license": "MIT",
  "type": "module",
  "exports": {
    ".": "./src/index.js",
    "./package.json": "./package.json"
  },
  "engines": { "node": ">=22.12" },
  "files": ["src"],
  "scripts": { "test": "node --test" }
}
packages/cli/package.json
{
  "name": "@bookshelf/cli",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "bin": { "bookshelf": "./cli.js" },
  "dependencies": { "@bookshelf/core": "^1.0.0" }
}
The workspace layout after npm install
npm install
readlink node_modules/@bookshelf/core node_modules/@bookshelf/cli
npm ls --workspaces
added 2 packages, and audited 5 packages in 92ms
../../packages/core
../../packages/cli
+-- @bookshelf/cli@1.0.0 -> ./packages/cli
| `-- @bookshelf/core@1.0.0 deduped -> ./packages/core
`-- @bookshelf/core@1.0.0 -> ./packages/core

The first line of npm ls output, which prints the absolute path of the root, was omitted. The same result was obtained on Node.js 26 with npm 11.19.1, on Node.js 24 with npm 11.19.0 and on Node.js 22 with npm 10.9.9. The @bookshelf/cli workspace is satisfied by the local @bookshelf/core (deduped), not by a copy from the registry, because the range ^1.0.0 matches the local version.

Running commands with -w and --workspaces

Almost every npm command accepts the workspace (short -w) and workspaces configuration options. --workspace selects one or more workspaces by name, by path, or by the path of a parent folder, and may be repeated; --workspaces selects all of them. Commands that work on the node_modules tree (install, update) link workspaces by default, while commands that do something else (test, exec, publish) operate on the root project unless workspaces are selected.

Running one workspace, all workspaces, and scripts that may be missing
npm run test --workspace=@bookshelf/core
npm run build --workspaces --if-present
npm run build --workspaces
> @bookshelf/core@1.0.0 test
> node --test

✔ slugify (0.277125ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
...
npm error Lifecycle script `build` failed with error:
npm error workspace @bookshelf/cli@1.0.0
npm error Missing script: "build"

The first command ran only the test script of @bookshelf/core. The second printed nothing and exited with code 0, because --if-present makes npm skip the workspaces that do not define the script. The third failed on the first workspace without a build script. On Node.js 22 the node:test reporter prints # pass 1 where Node.js 26 and 24 print ℹ pass 1; that is the test runner, not npm.

Dependencies are added to one workspace with the same option. npm hoists the package to the root node_modules when there is no conflict, and writes the range in the workspace’s own package.json.

Adding a registry dependency to one workspace
npm install abbrev --workspace=@bookshelf/core
ls node_modules
npm ls abbrev
added 1 package, and audited 6 packages in 380ms
@bookshelf
abbrev
`-- @bookshelf/core@1.0.0 -> ./packages/core
  `-- abbrev@5.0.0

The package.json of @bookshelf/core gained "abbrev": "^5.0.0" under dependencies, and the package was installed in the root node_modules (the ls shows it there, and there is no packages/core/node_modules). The tree shows the version that resolved on 2026-10-03; use any small package you like. The same command was run on Node.js 24 and 22 with the same layout.

Table 1. Workspace options at a glance
Option Effect Notes

-w <name-or-path>, --workspace

Run the command in the selected workspace(s). May be repeated.

Accepts a workspace name, the path of a workspace folder, or the path of a parent folder (all workspaces inside it). With npm init, it may point to a folder that does not exist yet and creates the new workspace.

--workspaces

Run the command in all configured workspaces.

Setting it to false makes commands such as install ignore workspaces altogether. Commands run in each workspace in the order they appear in the workspaces array of the root package.json.

--include-workspace-root

Include the root project when workspaces are selected.

Off by default, so selecting workspaces skips the root. Be careful with a root script that itself runs --workspaces, because the command recurses.

--if-present

Skip workspaces that do not define the script.

Useful for build and test across packages of different kinds.

--install-strategy=linked

Install into an isolated layout (node_modules/.store) with links.

The npm link documentation recommends it during development to catch undeclared ("phantom") dependencies, because only declared dependencies are visible.

What changed in the flags

The documented spelling is --workspaces. The short form -ws was tolerated with a warning in npm 11.19.1 (measured below) and is rejected in npm 12: the changelog of npm 12.0.0 (2026-07-08) says that unknown flags, abbreviated flags and single-hyphen multi-character shorthands now throw instead of warning. Scripts and CI files that still use -ws must be updated before the npm bundled with a Node.js line moves to npm 12.

-ws on npm 11.19.1 and on npm 12.2.0
npm pkg get name -ws
npm warn -ws is not a valid single-hyphen cli flag and will be removed in the future

On npm 12.2.0 the same command exits with -ws is not a valid single-hyphen cli flag. Did you mean --ws?. That message shows that --ws is accepted (running npm run test --ws --if-present worked on both versions), but only --workspaces is documented, so use that one. npm 12.0.0 also declares the Node.js versions it supports: ^22.22.2 || ^24.15.0 || >=26.0.0.

Workspace dependencies, versions and TypeScript

Two details separate npm from the other package managers. First, npm writes an ordinary range (^1.0.0) for a workspace dependency, and resolves it to the local workspace as long as the version satisfies it; npm does not support the workspace: protocol that pnpm and Yarn use (see pnpm and Yarn as alternatives). A package.json that contains "@bookshelf/core": "workspace:^" fails npm install with EUNSUPPORTEDPROTOCOL (measured on npm 11.19.1 and 10.9.9), which matters when you move a repository between package managers.

Second, because Node.js resolves the symlink to its real path, a workspace is not under node_modules from the runtime’s point of view. That is why a workspace can export a .ts file and a sibling can import it with type stripping, while the same file installed from a tarball cannot be loaded (see Publishing a TypeScript package). It also means that peer dependencies of a linked package are looked up from its real location, the problem described under --preserve-symlinks in the CLI documentation.

Publishing from a workspace

npm publish --workspace=<name> publishes one workspace and npm publish --workspaces publishes all of them. A workspace marked "private": true is skipped, as the dry run in the next section shows. Workspaces give you one lockfile (package-lock.json at the root), one install and linked packages, but no task graph: npm does not compute a build order or cache results, and it runs scripts in the order of the workspaces array. If a package must be built before another, order the array accordingly or write the order in a root script. npm pack can also select a workspace through configuration since npm 12.1.0.

pnpm and Yarn as alternatives

npm is the only package manager bundled with Node.js. pnpm and Yarn implement the same idea, many packages in one repository, with a few differences that show up when you move between them.

Table 2. The three workspace implementations compared
Aspect npm (11/12) pnpm 12 Yarn 4

Declaring workspaces

"workspaces": ["packages/*"] in package.json

packages: list in pnpm-workspace.yaml

"workspaces": ["packages/*"] in package.json

Dependency on a sibling

A normal range such as ^1.0.0; the workspace: protocol is not supported

"workspace:^" (also workspace:*, workspace:~, or a plain range)

"workspace:^" (the protocol accepts a range or ^, ~, *)

On pack and publish

The range is written as is

workspace: specs are rewritten to the real version range (measured: workspace:^ became ^1.0.0)

Same rewrite (measured: workspace:^ became ^1.0.0)

Lockfile

package-lock.json

pnpm-lock.yaml

yarn.lock

Run in all or some

npm run test --workspaces, -w <name>

pnpm -r test, pnpm --filter <name> …​

yarn workspaces foreach --all run test, yarn workspace <name> …​

Install layout

Hoisted node_modules (default), linked optional

Symlinked node_modules with hard links to a content-addressable store

Plug’n’Play by default (nodeLinker: pnp); node-modules and pnpm linkers available

Getting pnpm or Yarn without Corepack

Corepack, the tool that created pnpm and yarn shims and pinned the version in the packageManager field, was distributed with Node.js from version 14.19.0 up to, but not including, 25.0.0, according to the Corepack project. Node.js 25.0.0 (2025-10-15) stopped distributing it, and the Corepack page of the Node.js API documentation now redirects to the Corepack repository. The Node.js 24 documentation already marked Corepack as Stability 1 Experimental and announced that it would no longer be distributed from v25. In practice, on 2026-10-03: corepack is not found on Node.js 26.10.0, while versions 24.21.0 and 22.23.3 still have it. See Corepack, pnpm and Yarn for the install options in detail. Corepack still exists as a separate project (npm install --global corepack), and the Yarn installation page continues to start with that step; this page does not depend on it. The options that work the same on every line are:

  • install the package manager as a development dependency of the repository, with npm install --save-dev pnpm (pnpm 12 installs through npm on Node.js 22.13 or newer) or the @yarnpkg/cli-dist package from the Yarn repository, and run it with npx or an npm script;

  • use pnpm’s standalone installer, or pnpm self-update when pnpm is already installed (pnpm 12 is a native executable that does not need Node.js after installation);

  • install Corepack yourself if you want its shims and its version pinning: the Yarn installation page does exactly that (npm install -g corepack) before yarn init -2.

The examples below used the first option inside a scratch folder, with pnpm 12.9.1 and Yarn 4.18.1, and ran on Node.js 26, 24 and 22.

pnpm: the workspace file, the workspace protocol, and running scripts
# pnpm-workspace.yaml
packages:
  - "packages/*"
{ "name": "@bookshelf/cli", "version": "1.0.0", "private": true, "type": "module",
  "bin": { "bookshelf": "./cli.js" },
  "dependencies": { "@bookshelf/core": "workspace:^" } }
pnpm install
pnpm -r test
pnpm --filter @bookshelf/cli exec node cli.js "Hello Pnpm"
Scope: 2 of 3 workspace projects
$ node --test
✔ slugify (0.284083ms)
ℹ tests 1
ℹ pass 1
...
hello-pnpm

The Scope: line shows that pnpm -r ran in 2 of the 3 projects and skipped the root, and the output was filtered. The bookshelf binary is not linked into the @bookshelf/cli workspace itself, so the command runs the script with node.

Source: pnpm: Workspace

Yarn: workspaces in package.json, with the node_modules linker
# .yarnrc.yml
nodeLinker: node-modules
yarn install
yarn workspaces foreach --all run test
yarn workspace @bookshelf/cli node cli.js "Hello Yarn"
ℹ tests 1
ℹ pass 1
hello-yarn

The test output was filtered to the summary lines.

nodeLinker: node-modules was set because Yarn 4 uses Plug’n’Play by default, which does not create a node_modules folder and needs the tools around it to be PnP-aware. Yarn 4 also has an experimental nodeExperimentalPackageMap setting that works with Node.js package maps (--experimental-package-map, Stability 1 Experimental, added in v26.4.0).

npm link is the manual version of what workspaces do automatically: it connects a package under development to the project that uses it, without publishing. It works in two steps. Running npm link in a package folder creates a symlink in the global folder ({prefix}/lib/node_modules/<package>) and links the package’s bin entries into {prefix}/bin. Running npm link <package-name> in another project creates a symlink from the global installation to that project’s node_modules. The name is the one in package.json, not the folder name. npm link ../core does both steps from the consumer.

Because the first step writes to the global prefix, the example below points npm to a throw-away prefix with npm_config_prefix. Without it, npm link writes into the real global folder of the active Node.js installation.

Linking a library into a consumer
export npm_config_prefix="$PWD/.global"      # keep the global link out of the real prefix
(cd core && npm link)
readlink .global/lib/node_modules/@bookshelf/core
cd consumer
npm link @bookshelf/core
grep -c bookshelf package.json                # not recorded in package.json
echo "import { slugify } from '@bookshelf/core'; console.log(slugify('Linked Package'))" > main.js
node main.js
npm link @bookshelf/core --save
grep -A2 '"dependencies"' package.json
../../../../core
0
linked-package
  "dependencies": {
    "@bookshelf/core": "file:../core"
  }

By default the link is not saved in package.json, on the assumption that it stands in for a regular dependency (a ^3.0.1 range would otherwise be replaced by a file: path that only works on your machine); --save records the file: reference. The result was identical on Node.js 26 (npm 11.19.1) and Node.js 22 (npm 10.9.9). npm link --workspace <name> links a workspace instead.

Table 3. Choosing between links, workspaces and installing a tarball
Technique Use it when Watch out for

npm link

You work on a library in one repository and test it in an unrelated project, once.

It uses the global prefix, the link is not saved by default, and it breaks when the global link is removed or replaced by another npm install. Linked packages resolve peer dependencies from their real folder.

Workspaces

Both packages belong in one repository and are developed together.

Hoisting hides undeclared dependencies unless you use --install-strategy=linked.

npm pack and install the tarball

You want to test what consumers get: the files list, exports and compiled output.

The tarball is a copy, so rebuild and reinstall after each change.

What publishing adds for Node packages

A package that is only used from its own repository can get away with a loose package.json. A published package is consumed by projects with different module systems, Node.js versions and TypeScript settings, so a few fields decide whether it works for them. The generic parts of publishing (an account, npm login, scopes, --access public, dist-tags) are covered in the existing JavaScript page; these are the Node-specific ones.

Table 4. The package.json fields that matter to consumers
Field What it does for consumers What happens if it is wrong or missing

exports

Defines the public entry points, and hides everything else.

Without it every file is reachable, and refactoring becomes a breaking change. With it, a path that is not listed fails with ERR_PACKAGE_PATH_NOT_EXPORTED.

type

Says whether .js files are ESM ("module") or CommonJS.

The package is treated as CommonJS, so import and export statements fail to parse.

engines

States the supported Node.js (and npm) versions.

Only advisory by default (a warning), unless the installing project sets engine-strict.

files

Allow-list of what goes into the tarball.

Tests, fixtures and local configuration ship to everybody, or, with a too-strict list, code is missing.

bin

Maps command names to executables.

npm may auto-correct paths when publishing (it warns "auto-corrected some errors").

private

true makes npm refuse to publish the package.

Accidental publication of an application or of the monorepo root.

publishConfig

Config values applied only at publish time (tag, registry, access, provenance).

A scoped package published as private, or a pre-release tagged latest.

repository

Links the package to its source.

Provenance requires that it matches, case-sensitively, the repository where the package is published from.

See exactly what will be published

npm pack --dry-run lists the files that would go into the tarball without writing it, and npm publish --dry-run runs everything except the upload. The npm documentation recommends npm pack --dry-run to see what is included. Neither command publishes anything, which makes them safe in a scratch folder or CI job.

The tarball contents of the library
cd packages/core
npm pack --dry-run
npm notice package: @bookshelf/core@1.0.0
npm notice Tarball Contents
npm notice 29B LICENSE
npm notice 18B README.md
npm notice 319B package.json
npm notice 122B src/index.js
npm notice Tarball Details
npm notice name: @bookshelf/core
npm notice version: 1.0.0
npm notice filename: bookshelf-core-1.0.0.tgz
npm notice package size: 475 B
npm notice unpacked size: 488 B
npm notice total files: 4

The test folder is not in the list because of "files": ["src"], while package.json, README.md and LICENSE are always included. The shasum and integrity lines were omitted. The output was identical on Node.js 26, 24 and 22.

A dry-run publish reports what would happen
npm publish --dry-run --access public
npm warn publish This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access (dry-run)
+ @bookshelf/core@1.0.0

Run from the root, npm publish --dry-run --workspaces printed Skipping workspace @bookshelf/cli, marked as private and then the report of @bookshelf/core. On npm 10.9.9 (Node.js 22) the warning reads npm warn This command requires you to be logged in…​ without the publish word. The output of the machine-readable form changed in npm 12.0.0, whose changelog says that the --json output of npm pack and npm publish is now always consistent and in the same format: on npm 11.19.1, npm pack --dry-run --json prints an array with one object per package, and on npm 12.2.0 it prints an object keyed by package name. Scripts that parse that JSON need to handle both.

Test the package like a consumer

The dry run shows the files, not whether the package works. The reliable check is to pack a real tarball into a scratch folder, install it in a throw-away project and import it, the way a consumer would. Packing is local and does not publish.

Install the tarball and import it, with and without a deep path
mkdir ../consume && npm pack --pack-destination ../consume
cd ../consume && mkdir app && cd app
npm init -y && npm pkg set type=module
npm install ../bookshelf-core-1.0.0.tgz
node -e "import('@bookshelf/core').then(m => console.log(m.slugify('Packed Tarball')))"
node -e "import('@bookshelf/core/src/index.js')"
node -e "console.log(typeof require('@bookshelf/core').slugify)"
packed-tarball
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/index.js' is not defined by "exports" in .../node_modules/@bookshelf/core/package.json
function

The package entry point works, the private src/index.js path is rejected by exports, and require() of an ESM-only package works because the package has no top-level await. That last result was measured on Node.js 26, 24 and 22.23.3, and it is why the library can be ESM-only and set "engines": { "node": ">=22.12" }: require(esm) is unflagged from v22.12.0 and v20.19.0 and Stable since v25.4.0. The Node.js Learn article on publishing a package was written for older lines (it says that requiring ESM works on "22.x and 23.x" and uses >=12.22.7 in its engines examples); its advice to publish one format only and to describe the entry points in exports still holds, and the dual-package decisions are explained in package.json, exports & imports and ESM & CommonJS Interop.

engines and engine-strict

engines is advisory unless the installing project sets engine-strict. A package that requires a newer Node.js than the current one installs with a warning by default and fails with --engine-strict.

A package that requires Node.js 99 or later
npm install ../future
npm install ../future --engine-strict
npm warn EBADENGINE Unsupported engine {
npm warn EBADENGINE   package: 'future-only@1.0.0',
npm warn EBADENGINE   required: { node: '>=99' },
npm warn EBADENGINE   current: { node: 'v26.10.0', npm: '11.19.1' }
npm warn EBADENGINE }
...
npm error code EBADENGINE
npm error engine Not compatible with your version of node/npm: future-only@1.0.0

Both commands behave the same on Node.js 22 with npm 10.9.9. Declare the range you actually test, and use a lower bound that matches the oldest maintained line you support, today >=22.12 for ESM-only packages that must be required from CommonJS. Node.js 22 reaches end of life on 2027-04-30.

Publishing a TypeScript package

Node.js 22.18.0 and later run .ts files directly, which makes TypeScript convenient for applications. For a package that other projects install, the guidance in the Learn article is the opposite: publish JavaScript and declaration files. The fields that point at published content (exports, main) must name the compiled .js files, while scripts that run on the source (test) name the .ts files, and Node.js refuses to strip types inside node_modules, so a package that ships raw TypeScript cannot be loaded.

A published .ts file is rejected under node_modules (the tarball was installed, not linked)
Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported for files under node_modules, for ".../node_modules/raw-ts/index.ts"

That failure was measured on Node.js 26, 24 and 22 (npm pack in a folder whose package.json has "exports": "./index.ts", then npm install of the tarball, then import 'raw-ts'). When the same package was installed as a link (npm install ../pkg, which creates a symlink), the file loaded, because the real path is outside node_modules: the behaviour that makes .ts workspaces convenient is also why you cannot judge a TypeScript package from a link.

The recipe below follows the Learn article: compile in prepack, publish dist/ with its .d.ts files, and add a types condition first in exports.

packages/slug/package.json
{
  "name": "@bookshelf/slug",
  "version": "1.0.0",
  "license": "MIT",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist"],
  "engines": { "node": ">=22.12" },
  "scripts": {
    "test": "node --test 'src/**/*.test.ts'",
    "types:check": "tsc --noEmit",
    "prepack": "tsc"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

rewriteRelativeImportExtensions turns the ./normalize.ts import written in the source into ./normalize.js in the output, and erasableSyntaxOnly (TypeScript 5.8 and later) makes the compiler reject syntax that type stripping cannot run, such as enums and namespaces. The source files use the .ts extension in their imports, as Node.js requires when it runs them directly.

Compiling happens during packing
npm test
npm run types:check
npm pack --dry-run
> @bookshelf/slug@1.0.0 prepack
> tsc
npm notice 133B dist/index.d.ts
npm notice 207B dist/index.d.ts.map
npm notice 112B dist/index.js
npm notice 96B dist/normalize.d.ts
npm notice 164B dist/normalize.d.ts.map
npm notice 124B dist/normalize.js
npm notice 520B package.json
npm notice total files: 7

This was run with TypeScript 7.0.2 (the version npm resolved on 2026-10-03) on Node.js 26, 24 and 22. npm pack --dry-run ran prepack, so it created dist/ in the working folder; npm publish runs it too, which is what you want. The Learn article warns that node --run does not run the pre and post scripts, so do not use it for the packing step. The test files are excluded by the files allow-list and by the exclude setting. The details of the compiler options are in tsconfig and Compiler Options, and the runtime side of TypeScript is in TypeScript in Node.js.

Provenance, trusted publishing and staged publishing

Until 2025 a publish was authenticated by a token that lived on a laptop or in CI settings, and a leaked token let anyone publish. The npm registry has since moved to short-lived credentials and to attestations of where a package was built. Three features, which are independent but designed to work together:

Table 5. Three layers of supply-chain protection for a publish
Feature What it gives you Requirements

Provenance

A signed statement (Sigstore) linking the package to its source repository and build instructions, shown on the package page and checkable by consumers with npm audit signatures.

A supported cloud CI/CD provider on a cloud-hosted runner (GitHub Actions and GitLab CI/CD), npm CLI 9.5.0 or later, and a repository field that matches. It does not prove the package has no malicious code.

Trusted publishing (OIDC)

The registry accepts a publish from one specific workflow, with a short-lived OpenID Connect token instead of a stored npm token. Provenance is generated automatically.

npm 11.5.1 or later and Node.js 22.14.0 or later. GitHub Actions (GitHub-hosted runners), GitLab CI/CD (GitLab.com shared runners) and CircleCI (cloud). Self-hosted runners are not supported.

Staged publishing

npm stage publish uploads the package to a staging area, where it is invisible to consumers until a maintainer approves it with two-factor authentication.

npm 11.15.0 or later and Node.js 22.14.0 or later; 2FA enabled on the maintainer’s account.

Provenance

Provenance has two parts. The provenance attestation links the package to its source and build instructions, and is built from the identity token of the CI run. The publish attestation is generated by the registry when an authorised user publishes. Both are signed with Sigstore and logged in a public transparency ledger. In a GitHub Actions job it takes the id-token: write permission and the --provenance flag:

npm publish --provenance --access public

Setting NPM_CONFIG_PROVENANCE=true, an entry provenance=true in .npmrc, or "publishConfig": { "provenance": true } in package.json has the same effect for tools that do not call npm publish directly. The option is documented in the npm publish page, where the provenance-file option takes precedence over automatic generation (a fix in npm 12.1.0 made that the case for trusted publishing too). Consumers check attestations with npm audit signatures. Outside a supported CI provider there is nothing to attest, so provenance is a CI feature; this page does not run it. The --dry-run publish on a laptop above printed no provenance output.

Trusted publishing

Trusted publishing replaces the stored token with a trust relationship configured on the package: you tell npm which repository, which workflow file and, optionally, which environment may publish it. At publish time the CI provider issues a short-lived OIDC token for that workflow, the npm CLI detects the environment automatically and uses the token before falling back to other credentials, and no secret has to be created, rotated or hidden from logs. This implements the trusted publishers standard of the Open Source Security Foundation, as in other registries. npm announced it as generally available on 2025-07-31; with trusted publishing, provenance attestations are generated without the --provenance flag.

The relationship is configured on npmjs.com, in the package settings, or with the npm trust command (added in npm 11.10.0; it requires npm 11.15.0 or later according to its documentation, an account with 2FA enabled, and a package that already exists). For GitHub Actions the settings are the organisation or user, the repository, the workflow filename (only the file name, with .yml or .yaml, in .github/workflows/), an optional environment, and the allowed actions. Those allowed actions are tied to staged publishing: npm stage publish is always allowed, and the relationship can also allow a direct npm publish and npm dist-tag (OIDC authentication for dist-tag arrived in npm 12.2.0). Configurations created before 2026-05-20 allow only npm publish; the dates are described on the npm page.

The command line equivalent of the web form (usage lines only, because configuring a real package needs an account)
npm trust --help
Create a trusted relationship between a package and a OIDC provider

Usage:
npm trust github [package] --file [--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]
npm trust gitlab [package] --file [--project|--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]
npm trust circleci [package] --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> --vcs-origin <origin> ...
npm trust list [package]
npm trust revoke [package] --id=<trust-id>

On Node.js 26 (npm 11.19.1) and Node.js 24 (npm 11.19.0) the command exists. On Node.js 22.23.3, which bundles npm 10.9.9, it prints Unknown command: "trust", and npm stage is unknown too. That is the practical effect of bundling: a pipeline that runs on Node.js 22 must upgrade npm (npm install --global npm@latest) before it can use trusted or staged publishing, while the 24 and 26 lines already include a sufficient npm.

The workflow that publishes is an ordinary one with two requirements: the id-token: write permission, and a GitHub-hosted runner. It contains no token or secret. It follows the example in the npm documentation.

A tag-triggered publish workflow for GitHub Actions (checked only as valid YAML; it runs on GitHub)
name: Publish package
on:
  push:
    tags:
      - 'v*'

permissions:
  id-token: write   # lets the job request an OIDC token from GitHub
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest   # GitHub-hosted runner (self-hosted runners are not supported)
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
          registry-url: 'https://registry.npmjs.org'
          package-manager-cache: false   # never use caches in release builds
      - run: npm ci
      - run: npm test
      - run: npm stage publish   # or: npm publish, if the trusted publisher allows it

The workflow does not set NODE_AUTH_TOKEN: with trusted publishing the CLI exchanges the OIDC token itself. The actions/checkout@v6 and actions/setup-node@v6 major versions are those shown in the npm documentation on 2026-10-03; pin or update them according to your own policy. Because the install step is npm ci, the lockfile decides what runs in the release job, which is the point of release builds.

Staged publishing

Trusted publishing removes the token, and with it the human who used to type a one-time password. Staged publishing puts a human back in the loop without putting a secret back in the pipeline. The pipeline runs npm stage publish (no 2FA prompt, usable with any token type), and the package waits in a staging area. A maintainer then reviews it and approves it with 2FA, either on npmjs.com in the Staged Packages tab or with the CLI.

Table 6. The staged publishing commands
Command Purpose 2FA

npm stage publish [<package-spec>]

Pack the current folder and place it in staging. It honours "private": true.

No

npm stage list [<package-spec>]

List staged versions.

No

npm stage view <stage-id>

Show one staged package.

No

npm stage download <stage-id>

Download the tarball to inspect it.

No

npm stage approve <stage-id>

Publish the staged package to the live registry.

Yes

npm stage reject <stage-id>

Permanently remove the staged package.

Yes

Staged versions share the version uniqueness of published ones, so a version that is staged cannot be published again; several versions of the same package can be staged; and the dist-tag is fixed when staging (to change it you reject and stage again). When the package does not exist yet, the npm page says that npm publishes a placeholder version 0.0.0-stage, with the staged contents hidden until approval. The documentation itself notes that staged publishing by itself does not make an account or an organisation more secure; the best practices still apply.

Staging, as a dry run
npm stage publish --dry-run
npm warn stage This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)
npm notice Staging to https://registry.npmjs.org/ with tag latest and default access (dry-run)
+ @bookshelf/core@1.0.0 (staged)
npm notice stage package @bookshelf/core@1.0.0 has been staged with tag latest

This was run on Node.js 26 (npm 11.19.1) and, as above, does not exist on Node.js 22’s npm. The (dry-run) markers show that nothing was uploaded. Staged-package status display (npm stage) and read-write-stage-only granular tokens arrived in npm 12.1.0.

Which credential for which job

Table 7. Credentials for publishing, from the weakest to the strongest posture
Situation What to use Status on 2026-10-03

A maintainer publishing from a laptop

npm login, which now creates a session token that expires after two hours and enforces 2FA for publishing.

Current since 2025-12-09.

CI that cannot use OIDC (an unsupported provider or a self-hosted runner)

A granular access token stored as a CI secret, with an expiry (write tokens are limited to 90 days) and, for a non-interactive job, the option to bypass 2FA.

Allowed; avoid it where OIDC is available. Never commit a token: read it from the CI secret store, and use placeholders in documentation.

CI on GitHub Actions, GitLab CI/CD or CircleCI

Trusted publishing with OIDC, optionally with npm stage publish and a human approval.

Recommended by npm.

Any token created before December 2025 as a "classic" token

Nothing: all classic tokens were permanently revoked on 2025-12-09.

Does not work.

What changed recently

Table 8. Changes that affect workspaces and publishing, with versions
When Change

Node.js 22.12.0 (and 23.0.0)

require() of ES modules works without a flag, so ESM-only packages are consumable from CommonJS. The feature is documented as Stable, and "no longer experimental" in v25.4.0.

Node.js 22.18.0 and 23.6.0 (Stable in 24.12.0 and 25.2.0)

Type stripping is on by default; Node.js still refuses to strip types under node_modules on every line (measured on 26, 24 and 22).

Node.js 25.0.0 (2025-10-15)

Corepack is no longer distributed with Node.js. Node.js 24 and 22 still ship it on 2026-10-03.

npm 11.5.1 (2025-07-24) and 2025-07-31

Minimum npm for trusted publishing, and its general availability announcement; provenance is automatic with it.

2025-12-09

npm classic tokens permanently revoked; npm login issues two-hour session tokens; granular tokens managed from the CLI.

npm 11.10.0 (2026-02-11)

npm trust and the min-release-age config were added.

npm 11.15.0 (2026-05-20)

Minimum npm for staged publishing (npm stage) and, per its documentation, for npm trust.

Node.js 26.0.0

--experimental-transform-types was removed. The Learn article on TypeScript packages still tells readers that TypeScript-specific features such as enum need that flag.

Node.js 26.4.0

--experimental-package-map (Stability 1 Experimental) was added.

npm 12.0.0 (2026-07-08)

-ws style shorthands and unknown flags throw; the --json output of pack and publish is consistent; dependency lifecycle scripts are blocked unless allowed by the root package’s allowScripts policy; allow-git and allow-remote default to none; supported Node.js is ^22.22.2 || ^24.15.0 || >=26.0.0; npm init no longer defaults the licence to ISC; shrinkwrap and adduser are removed.

npm 12.1.0 (2026-09-21) and 12.2.0 (2026-09-30)

Staged-package status display and stage-only tokens; OIDC authentication for npm dist-tag.

Where the books are outdated

Efficient Node.js (Samer Buna, O’Reilly 2025) builds and publishes a small package in its chapter on package management: it authenticates with npm login using a username and a password, publishes with npm publish, and uses npm link for testing changes. The concepts are still right, but the authentication is not: since 2025-12-09 npm login creates a two-hour session token with 2FA, and the recommended route for automation is trusted publishing with provenance, neither of which the book covers. The book also says its ESM package can be used only by ESM projects. That is no longer true on Node.js 22.12.0 and later, where require() loads ES modules, so the dual-format publishing it suggests Babel or TypeScript for is not needed for most new packages. It never mentions exports, engines or a dry run, which are the checks this page uses.

Node.js Projects (Jonathan Wexler, O’Reilly 2025) discusses Yarn and tells the reader to start it with Corepack, which it describes as bundled with Node.js. Corepack is not distributed with Node.js from v25.0.0, so on Node.js 26 corepack enable fails with "command not found" until you install Corepack yourself, or install the package manager another way (see pnpm and Yarn as alternatives). Its appendix notes exports, files and workspaces among the package.json fields, which remain valid; this page shows how they behave when a package is actually packed and consumed.

References