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.
{
"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.
{
"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" }
}
{
"name": "@bookshelf/cli",
"version": "1.0.0",
"private": true,
"type": "module",
"bin": { "bookshelf": "./cli.js" },
"dependencies": { "@bookshelf/core": "^1.0.0" }
}
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.
Source: npm Docs: Workspaces
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.
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.
Source: npm Docs: Workspaces
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.
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.
Source: npm Docs: Workspaces
| Option | Effect | Notes |
|---|---|---|
|
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 |
|
Run the command in all configured workspaces. |
Setting it to |
|
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 |
|
Skip workspaces that do not define the script. |
Useful for |
|
Install into an isolated layout ( |
The |
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.
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.
Source: npm Docs: Changelog
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.
Source: npm Docs: npm publish
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.
| Aspect | npm (11/12) | pnpm 12 | Yarn 4 |
|---|---|---|---|
Declaring workspaces |
|
|
|
Dependency on a sibling |
A normal range such as |
|
|
On pack and publish |
The range is written as is |
|
Same rewrite (measured: |
Lockfile |
|
|
|
Run in all or some |
|
|
|
Install layout |
Hoisted |
Symlinked |
Plug’n’Play by default ( |
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-distpackage from the Yarn repository, and run it withnpxor an npm script; -
use pnpm’s standalone installer, or
pnpm self-updatewhen 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) beforeyarn 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-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
# .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).
Source: Yarn: Workspaces, Yarn: .yarnrc.yml
npm link
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.
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.
| Technique | Use it when | Watch out for |
|---|---|---|
|
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 |
Workspaces |
Both packages belong in one repository and are developed together. |
Hoisting hides undeclared dependencies unless you use |
|
You want to test what consumers get: the |
The tarball is a copy, so rebuild and reinstall after each change. |
Source: npm Docs: npm link
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.
| Field | What it does for consumers | What happens if it is wrong or missing |
|---|---|---|
|
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 |
|
Says whether |
The package is treated as CommonJS, so |
|
States the supported Node.js (and npm) versions. |
Only advisory by default (a warning), unless the installing project sets |
|
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. |
|
Maps command names to executables. |
npm may auto-correct paths when publishing (it warns "auto-corrected some errors"). |
|
|
Accidental publication of an application or of the monorepo root. |
|
Config values applied only at publish time ( |
A scoped package published as private, or a pre-release tagged |
|
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.
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.
Source: npm Docs: npm pack
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.
Source: npm Docs: npm publish
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.
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.
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.
Source: npm Docs: package.json (engines)
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.
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.
{
"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"
}
}
{
"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.
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:
| 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 |
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 |
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 11.15.0 or later and Node.js 22.14.0 or later; 2FA enabled on the maintainer’s account. |
Source: npm Docs: Generating provenance statements, npm Docs: Trusted publishing, npm Docs: Staged publishing
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.
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.
Source: npm Docs: npm trust
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.
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.
Source: npm Docs: Trusted publishing
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.
| Command | Purpose | 2FA |
|---|---|---|
|
Pack the current folder and place it in staging. It honours |
No |
|
List staged versions. |
No |
|
Show one staged package. |
No |
|
Download the tarball to inspect it. |
No |
|
Publish the staged package to the live registry. |
Yes |
|
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.
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
| Situation | What to use | Status on 2026-10-03 |
|---|---|---|
A maintainer publishing from a laptop |
|
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 |
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
| When | Change |
|---|---|
Node.js 22.12.0 (and 23.0.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.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 11.10.0 (2026-02-11) |
|
npm 11.15.0 (2026-05-20) |
Minimum npm for staged publishing ( |
Node.js 26.0.0 |
|
Node.js 26.4.0 |
|
npm 12.0.0 (2026-07-08) |
|
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 |
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
-
npm Docs: Workspaces, https://docs.npmjs.com/cli/v12/using-npm/workspaces/
-
npm Docs: npm link, https://docs.npmjs.com/cli/v12/commands/npm-link/
-
npm Docs: npm pack, https://docs.npmjs.com/cli/v12/commands/npm-pack/
-
npm Docs: npm publish, https://docs.npmjs.com/cli/v12/commands/npm-publish/
-
npm Docs: npm stage, https://docs.npmjs.com/cli/v12/commands/npm-stage/
-
npm Docs: npm trust, https://docs.npmjs.com/cli/v12/commands/npm-trust/
-
npm Docs: package.json, https://docs.npmjs.com/cli/v12/configuring-npm/package-json/
-
npm Docs: Changelog, https://docs.npmjs.com/cli/v12/using-npm/changelog/
-
npm Docs: Generating provenance statements, https://docs.npmjs.com/generating-provenance-statements/
-
npm Docs: Trusted publishing for npm packages, https://docs.npmjs.com/trusted-publishers/
-
npm Docs: Staged publishing for npm packages, https://docs.npmjs.com/staged-publishing/
-
npm Docs: About access tokens, https://docs.npmjs.com/about-access-tokens/
-
GitHub Changelog: npm trusted publishing with OIDC is generally available (2025-07-31), https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/
-
GitHub Changelog: npm classic tokens revoked (2025-12-09), https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-token-management-now-available/
-
Node.js Learn: Publishing a package, https://nodejs.org/learn/modules/publishing-a-package
-
Node.js Learn: Publishing a TypeScript package, https://nodejs.org/learn/typescript/publishing-a-ts-package
-
Node.js API: Modules: Packages, https://nodejs.org/docs/latest/api/packages.html
-
Node.js API: Modules: TypeScript, https://nodejs.org/docs/latest/api/typescript.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Modules: CommonJS modules, https://nodejs.org/docs/latest/api/modules.html
-
Corepack, https://github.com/nodejs/corepack
-
pnpm: Workspace, https://pnpm.io/workspaces
-
pnpm: Installation, https://pnpm.io/installation
-
Yarn: Workspaces, https://yarnpkg.com/features/workspaces
-
Yarn: Installation, https://yarnpkg.com/getting-started/install
-
Sigstore, https://www.sigstore.dev/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/