## Summary
Migrates CI workflows from `pnpm/action-setup` + manual `pn runtime set node …` + `pn install` to the new combined `pnpm/setup` action (see https://github.com/pnpm/setup/pull/1).
`pnpm/setup` installs pnpm and the JS runtime in one step. It also runs `pnpm install` automatically when a `package.json` is present, so per-workflow install steps are dropped. When the `runtime` input is set, the action passes `--no-runtime` to `pnpm install` so the matrix-selected Node version isn't shadowed by a different `devEngines.runtime` pin.
## What changed
| Workflow | Migration |
|---|---|
| `test.yml` | `pnpm/setup` with `runtime: node@${{ inputs.node }}`. Verify-Node step asserts the matrix version stayed active. Verify-npm step retained as canary (npm comes from the runner image, not the pnpm-installed runtime). |
| `ci.yml` | `pnpm/setup` (no `runtime` input — `devEngines.runtime` in package.json handles the Node pin). |
| `release.yml` | `pnpm/setup` with `runtime: node@26.0.0`. |
| `benchmark.yml` | `pnpm/setup` with `runtime: node@26.0.0`. |
| `audit.yml` | `pnpm/setup` with `install: false` — audit only needs pnpm itself, not `node_modules`. |
| `update-lockfile.yml` | `pnpm/setup` with `install: false` — the job deletes `pnpm-lock.yaml` and regenerates it via `--lockfile-only`, so the action's auto-install would be wasted. |
| `update-latest.yml` | Untouched — it only uses npm, no pnpm setup needed. |
## Caveats / things to watch
- **npm availability.** `pnpm runtime set node` does not extract npm. The runner image's pre-installed Node toolchain provides `npm` on PATH; if a future runner image change removes that, dlx-style git-hosted dependency tests in `test.yml` will fail. The `Verify npm` step in `test.yml` is the canary.
## Related upstream change
- [pnpm/setup#3](https://github.com/pnpm/setup/pull/3) — added the `install` input so callers like `audit.yml` and `update-lockfile.yml` can opt out of the action's auto-install.
Adds `devEngines.runtime` to pin the Node.js version (24.6.0, `onFail: download`) the project uses for development, so contributors don't have to manage Node versions manually.
CI changes that come with it:
- Bumps pnpm to **11.1.1** and `pnpm/action-setup` to a bootstrap that ships `@zkochan/cmd-shim` 9.0.3. The cmd-shim update is required because the previous shim's `exec cmd /C` got mangled by Git Bash's MSYS path conversion (`/C` → Windows path), which broke any `pn …` invocation from `shell: bash` on Windows.
- Switches the install step to `pn install --no-runtime` so the per-test-matrix Node version chosen by `pn runtime -g set node …` isn't overridden by the project-pinned 24.6.0.
- Adds a `Verify Node version` step that asserts `pn node -v` matches the matrix's Node.
## Summary
- The local resolver's path-shape match was claiming any specifier containing `/` as a local directory, so `pnpm add bit:@teambit/bit` (with `bit` configured under `namedRegistries`) installed a bogus link to `bit:@teambit/bit/` instead of resolving from the configured registry.
- Split the local resolver into two exports: `resolveFromLocalScheme` (handles `file:`/`link:`/`workspace:`/`path:`) and `resolveFromLocalPath` (path-shape match — tarball extension, `path.sep`, `isFilespec`). `resolveFromLocal` is removed.
- Re-order the default-resolver chain so the scheme pass runs *before* `resolveFromNamedRegistry` and the path pass runs *after*. Explicit local protocols still win even when a user configures a colliding `namedRegistries` alias; named-registry aliases reach their configured URL.
Repro before the fix:
```
$ cat pnpm-workspace.yaml
namedRegistries:
bit: https://node-registry.bit.cloud/
$ pnpm add bit:@teambit/bit
[WARN] Installing a dependency from a non-existent directory: /private/tmp/.../bit:@teambit/bit
dependencies:
+ bit 0.0.0 <- bit:@teambit/bit
```
After the fix, the same command resolves `@teambit/bit 1.13.173` from `https://node-registry.bit.cloud/` and writes `"@teambit/bit": "bit:^1.13.173"` to `package.json`.
Picks up the MSYS path-translation fix from pnpm/cmd-shim#55: the sh shim
written for `.cmd` / `.bat` targets now escapes the `/C` switch as `//C`
so Git Bash passes it through to cmd.exe unchanged. Without this, a bare
`/C` was rewritten to `C:\` before reaching cmd.exe — cmd started
interactively and the calling script saw cmd's banner instead of the
wrapped command's output. Affects any cmd-shim-wrapped batch script
invoked from Git Bash / MSYS / Cygwin on Windows.
* fix(deps.status): skip engine check when scanning workspace projects
checkDepsStatus (run by verifyDepsBeforeRun) called findWorkspaceProjects
without a nodeVersion, so the engine check fell back to the system Node from
PATH and emitted spurious "Unsupported engine" warnings before scripts ran.
The actual install still does engine validation; a status check shouldn't.
* test(pnpm): cover engine warning regression for verifyDepsBeforeRun
* docs(changeset): clarify scope of the skipped validation
* test(pnpm): also check stderr for unsupported engine warning
* fix: allow `pnpm runtime set` in workspace root
`pnpm runtime set <name> <version>` previously failed in the root of a
multi-package workspace with the `ADDING_TO_ROOT` error. Since installing
a runtime is workspace-wide configuration, pass `--workspace-root` to the
underlying `pnpm add` call when not running globally.
* fix: use --ignore-workspace-root-check instead of --workspace-root
The previous fix forced every non-global runtime install to land in
the workspace root, which broke running the command from a workspace
subproject. Switch to --ignore-workspace-root-check, which only
suppresses the safety warning and leaves the install target as the
current directory.
* fix: install each global package in its own isolated directory by default (#11587)
`pnpm add -g foo bar` now installs `foo` and `bar` as separate isolated
globals — removing one no longer wipes out the other. Packages can still
be bundled into a single isolated install with a comma-separated list:
`pnpm add -g foo,bar qar` keeps foo+bar together and qar separate.
* chore: downgrade changeset to patch
* fix: do not split commas inside local paths or URL selectors
`splitCommaSeparated` now detects path-like params (`./`, `/`, `~`,
`file:`, `link:`, Windows drive paths) and URLs (anything containing
`://`), and skips splitting when the param as a whole resolves to an
existing local path. Plain package specs like `foo,bar` are still
split as before. Adds an e2e regression test using a local package
whose directory contains commas.
Also reword the changeset bullet so the example sentence doesn't end
abruptly at the issue link.
* fix: consolidate global add summary so every installed package is listed
`pnpm add -g foo bar` runs each space-separated arg as its own isolated
install, but the default-reporter's summary pipeline takes the first
`summary` log event and unsubscribes — so only the first group's
"global: + X" block was printed and later groups disappeared from the
summary even though they had been installed correctly.
Adds an `omitSummaryLog` install option that suppresses the per-install
summary log inside `mutateModules`. `handleGlobalAdd` enables it for
each group and emits a single consolidated summary log at the very end,
so the reporter prints one "global:" block listing every package that
was added across all groups.
* chore: update tsconfig refs after adding @pnpm/core-loggers dep
* fix: show per-prefix stats and progress when global add installs multiple groups
When `pnpm add -g` is given more than one CLI param (and so installs
several isolated groups), force the reporter to use its prefixed
progress/stats output. Without that, the single-prefix stats pipeline
limits emissions to one install via `take(2)`, so only the first
group's "Packages: +N" line is printed and later groups' stats are
silently dropped. Each group now shows its own progress and stats line
labelled with the install dir, and the consolidated "global:" summary
still prints once at the end.
Single-package `pnpm add -g foo` output is unchanged.
* chore: bump @pnpm/installing.deps-installer in changeset
The new omitSummaryLog install option is consumed by global.commands,
so deps-installer needs a version bump alongside it.
- `pnpm publish` failed to complete the web-based authentication flow when an HTTP/HTTPS proxy was configured. `libnpmpublish` (used for the initial publish request) routes through the proxy, but the subsequent `doneUrl` polling went through `@pnpm/network.fetch` without forwarding any proxy/TLS settings. The registry rejected the poll with `403` because the source IP differed from the initial request, so publish hung on the QR-code prompt forever.
- Adds `createDispatchedFetch(opts)` to `@pnpm/network.fetch` — a curried `fetchWithDispatcher` that pre-binds proxy / TLS / local-address / `configByUri`-derived client certificates. `publishPackedPkg` uses it to build an `OtpContext` whose `fetch` honors the same network configuration as the publish request.
- `extractTlsConfigs` is now performed automatically inside `createDispatchedFetch` (and hoisted out of the per-request loop in `createFetchFromRegistry`), so callers only have to pass `configByUri` once.
Fixes#11561.
`pnpm` could hang for the lifetime of the worker pool after a command logically finished whenever the code path returned through `main` without `process.exit(...)`.
`main.ts` only ran `finishWorkers()` inside the command-handler `finally` block. Short-circuit returns that came earlier — `--version`, `--help`, fatal config errors that surface before a command runs — bypassed it and left the integrity-resolution worker pool active.
The CLI entry (`pnpm/src/pnpm.ts`) now runs `finishWorkers()` in its own `finally`, so every exit path tears down the pool. Cleanup rejections are swallowed so the `finally` never masks the real command result.
## Reproducer
```bash
echo '{"devEngines":{"packageManager":{"name":"pnpm","version":"11.0.9","onFail":"download"}}}' > package.json
time pnpm --version
# before (Linux): prints 11.0.9, then hangs for the worker-pool lifetime
# after: prints 11.0.9, exits in ~1-3 s
```
`switchCliVersion` resolves the integrity (spawning workers), finds nothing to swap (range/version already matches running pnpm), and returns through main's `--version` short-circuit. The unterminated workers used to keep the event loop alive.
Surfaced in pnpm/action-setup#254 — bumping the action's bootstrap to 11.0.9 made every CI job using `devEngines.packageManager` with `onFail: "download"` hang.
* **New Features**
* `pnpm view` now shows publish age (e.g., "published 2 hours ago") and, when available, publisher attribution ("by …"); invalid or future timestamps fall back to "just now".
* Console styling for package metadata (name/version, license, counts, links, dependencies, maintainers) was improved for readability.
* **Tests**
* Added tests verifying the published timestamp and publisher attribution appear in `pnpm view` output.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Adds a `--no-runtime` flag (config: `runtime: boolean`, default `true`) that suppresses install of runtime entries declared via `devEngines.runtime` (the `runtime:` protocol) **without modifying the lockfile**.
The lockfile keeps the runtime entry, so frozen-lockfile validation still passes; only the runtime fetch and `.bin` linking are skipped. Useful in CI matrices where the runtime is provisioned externally (e.g. via `pnpm runtime -g set node <version>`) before `pnpm install` runs.
The existing `--runtime-on-fail=ignore` is unsuitable for this case: it mutates the manifest and regenerates the lockfile to drop the runtime entry, which trips frozen-lockfile validation. The two flags are orthogonal and serve different purposes.
### Implementation
The hook lives in the lockfile filter stage:
- `lockfile/filtering/src/filterImporter.ts` — strips `runtime:` refs from the importer's deps maps when `skipRuntimes` is set.
- `lockfile/filtering/src/filterLockfileByImportersAndEngine.ts` — new `skipRuntimes?: boolean` option; runtime-protocol direct deps are dropped before they enter `pickedPackages`, so they never reach the dep graph or bin-linker. Applies to all runtimes (`node`, `deno`, `bun`) since they share the `runtime:` protocol prefix.
The option is plumbed through `installing/deps-restorer`, `installing/deps-installer`, and `installing/commands` to the user-facing `pnpm install --no-runtime` flag.
### Example
```json
// package.json
{
"devEngines": {
"runtime": {
"name": "node",
"version": "22.13.0",
"onFail": "download"
}
}
}
```
Local dev: `pnpm install` — installs node 22.13.0 as before.
CI matrix entry:
```yaml
- run: pn runtime -g set node ${{ matrix.node }}
- run: pn install --no-runtime
```
The lockfile is unchanged; the matrix's externally-provisioned node is used.
- Upgrade `@pnpm/semver-diff` and `@pnpm/colorize-semver-diff` to v2, which expose the helpers as named exports.
- Update the call sites in `@pnpm/deps.inspection.commands` and `@pnpm/installing.commands` from `semverDiff.default(...)` / `colorizeSemverDiff.default(...)` to plain `semverDiff(...)` / `colorizeSemverDiff(...)`.
- Refactor `buildPkgChoice` in `getUpdateChoices.ts` to build the row as a `string[]`. Previously the row was an object whose values relied on `nextVersion` being inferred as `any` (a side effect of the broken `.default` access poisoning the type) — that masked `outdatedPkg.current` and `outdatedPkg.workspace` being `string | undefined`. With the v2 named imports the types tighten up, and `Object.values(lineParts)` would no longer assign cleanly to `string[]`.
The previous v1 packages exported their helpers as `module.exports.default = fn`, so `.default(...)` only worked through the legacy CJS interop — and it broke under Node.js ESM (which is what the Jest runner uses with `--experimental-vm-modules`). Most of the `deps/inspection/commands` outdated tests had been silently failing on `main` with `TypeError: semverDiff.default is not a function`; this change brings them back.
* fix(git-resolver): avoid encoded slash in GitLab tarball URL
hosted-git-info's default GitLab tarball URL routes through
`/api/v4/projects/<user>%2F<project>/...`. The `%2F` survives into the
virtual store directory name (depPathToFilename only escapes raw `/`,
not `%`), and Node refuses to import any module whose path contains an
encoded slash. The same URL is also intermittently rejected by GitLab
with a 406.
Override the GitLab tarballtemplate to the `/-/archive/` URL, which works
for both public and private repos and contains no encoded slashes.
Closes#11533
* test: avoid cspell-flagged words
* test: keep existing gitlab assertions, only add new ones
Restore the skipped tests' original API-URL assertions; they document the
old expected shape and weren't running anyway. Add the new `/-/archive/`
URL to the pick-fetcher fixture as an additional case so both shapes are
exercised.
- Fixes [#11519](https://github.com/pnpm/pnpm/issues/11519): `pnpm pack` in pnpm 11 silently dropped every package listed in `bundleDependencies` / `bundledDependencies`, producing tarballs that no longer contained the bundled `node_modules/<dep>` files that v10 produced.
- Root cause: the npm-packlist v10 upgrade ([#10658](https://github.com/pnpm/pnpm/pull/10658)) changed its API to require the caller to pre-populate the dependency tree's `edgesOut` Map. The wrapper in `fs/packlist` passed an empty Map, so npm-packlist's `gatherBundles()` looked up each declared name, found nothing, and skipped them all.
- Fix: `fs/packlist` now reads each bundled dep's `package.json` (walking up parent `node_modules` to support hoisted layouts), recursively populates `edgesOut` for transitive deps of bundled packages, and normalizes `bundleDependencies: true` to an explicit array (npm-packlist iterates the field directly).
The static `import` of `dist/pnpm.mjs` in `bin/pnpm.mjs` was hoisted by
the ES module loader and parsed before the version check below it,
causing pnpm to crash with `SyntaxError: Invalid regular expression
flags` on Node.js versions older than the bundle's syntax target instead
of printing a clear "requires Node.js v22.13" error. Switching to a
dynamic `await import()` lets the version check run first.
* fix(config): honor NPM_CONFIG_USERCONFIG as a low-priority fallback
Restores compatibility with environments that point npm at a custom
.npmrc via NPM_CONFIG_USERCONFIG (e.g. actions/setup-node writing to
${runner.temp}/.npmrc), which silently broke after the v11 env var
prefix change. PNPM-prefixed env vars and npmrcAuthFile from the
global config.yaml continue to take precedence.
Closes#11539
* fix(config): treat empty NPM_CONFIG_USERCONFIG as unset
`??` accepts an empty string as a defined value, so an exported but
unset NPM_CONFIG_USERCONFIG would short-circuit the fallback chain and
make normalizePath('') resolve to process.cwd(). Mirror readEnvVar's
empty-string-to-undefined coercion via a readNpmEnvVar helper so the
fallback to ~/.npmrc works as expected.
- Adds a `pnpm-render` bin to `@pnpm/cli.default-reporter` that reads pnpm-shaped NDJSON from stdin and pipes it through the default reporter, so external tools that emit `pnpm:*` log records can reuse pnpm's renderer.
- Optional first positional arg sets the command name (defaults to `install`), e.g. `pnpm-render add` for piping output from `pacquet add`.
## Motivation
[Pacquet](https://github.com/pnpm/pacquet) emits pnpm-shaped `--reporter=ndjson` output for forward-compatibility with pnpm's renderer, but there was no way to actually render it. With this bin:
```sh
pacquet install --reporter=ndjson 2>&1 >/dev/null | pnpm-render
```
(pacquet writes NDJSON to stderr, so the redirect is needed.)
When a published version contained a `+<build>` segment (e.g.
`1.0.0-canary.0+abc1234`), `pnpm publish --provenance` was rejected by
the registry with a 422 verifying the sigstore provenance bundle.
`libnpmpublish.publish()` runs `semver.clean()` on `manifest.version`,
which strips build metadata, before computing the provenance subject.
pnpm was packing the tarball with the original version, so the version
embedded in the packed `package.json` no longer matched the version in
the metadata payload and the bundle's subject — causing the registry to
reject the publish.
Strip build metadata from the published version after creating the
publish manifest, then derive both the tarball filename and the
manifest packed inside the tarball from that cleaned version.
Closes#11518.
* fix(lockfile): keep non-reconstructable tarball URLs when lockfileIncludeTarballUrl is false
`lockfile-include-tarball-url` defaults to `false`, so for the vast
majority of users the early return added by #10621 silently dropped
tarball URLs that cannot be reconstructed from registry+name+version —
breaking `pnpm install --frozen-lockfile` from an empty store on
GitHub Packages (`https://npm.pkg.github.com/download/<scope>/<name>/<version>/<hash>`),
JSR, and similar registries.
`false` now matches the historical (v10) heuristic: tarball URLs are
written when they are non-reconstructable, otherwise omitted.
`true` continues to force every tarball URL into the lockfile.
Refs #11276, #11407.
* chore: appease cspell
Replace "reconstructable" with "derivable" and avoid the cspell-flagged
"mypkg" placeholder in the new test fixture.
* docs(changeset): use camelCase setting name
* fix(lockfile): guard against missing tarball field in toLockfileResolution
`TarballResolution.tarball` is typed as required, but callers that
deserialize resolutions from external state can violate that. Return
early with just `integrity` if the tarball URL is missing instead of
asserting non-null at the use site (which previously paired a
`as string | undefined` cast with `tarball!.replaceAll(...)` —
contradictory signals that confused both readers and review tools).
* fix: skip Content-Length check when response is content-encoded
Per the HTTP spec, Content-Length refers to the encoded payload when
Content-Encoding is set, but undici fetch yields decoded bytes — so the
strict size check rejected legitimate downloads with
ERR_PNPM_BAD_TARBALL_SIZE. Closes#11506.
* fix(tarball-fetcher): match v10's no-compression behavior
Verified the user's report against v10 source: v10 worked because it
called node-fetch with `compress: false` (network/fetch/src/fetchFromRegistry.ts
on the v10 branch), which suppressed Accept-Encoding and prevented
auto-decompression. v11's switch to undici fetch lost that opt-out — undici
sends Accept-Encoding: gzip, deflate, br by default and transparently
decodes the body, while keeping Content-Length pointed at the encoded
payload (confirmed empirically). The strict size check then rejects the
download.
Restore v10's effective behavior by sending Accept-Encoding: identity for
tarball requests, and as defense in depth against misbehaving servers
that stamp Content-Encoding regardless, skip the strict size check when
the response declares a non-identity Content-Encoding.
* fix(tarball-fetcher): parse Content-Encoding as a coding list
Per RFC 9110 §8.4 the header is a comma-separated, case-insensitive list
that may include whitespace and mixed codings (e.g. `gzip, identity`).
The previous string-equality check misclassified those — the response is
now treated as encoded iff any coding is non-`identity`.
@changesets/read treats every directory inside .changeset/ as a legacy
v1 changeset and tries to read changes.md from it, which made
`changeset version` fail with ENOENT on .changeset/.released/changes.md.
Move the per-branch ledger to .changeset-released/ at the repo root.
* test(exe): add Windows-only repro for #11486 (pn/pnpx/pnx aliases)
Captures the user-reported failure on a fresh Windows CI: when the
@pnpm/exe install rewrites bin entries to point at .cmd files,
@zkochan/cmd-shim's Bash shim does `exec cmd /C ...target.cmd`, MSYS2
mangles the lone `/C` into a Windows path, and cmd.exe falls into
interactive mode (printing its banner instead of running the alias).
These tests will fail on `windows-latest` until the follow-up commit
points the bin entries at .exe hardlinks of the SEA binary.
* fix(exe): route pn/pnpx/pnx through .exe hardlinks on Windows (#11486)
The @pnpm/exe install rewrote bin to point pn/pnpx/pnx at .cmd files,
which cmd-shim wraps as `exec cmd /C ...target.cmd "$@"` in its Bash
shim. MSYS2 / Git Bash mangles the lone `/C` into a Windows path
before cmd.exe sees it, so cmd.exe finds no /C or /K and falls into
interactive mode — the user sees its banner instead of `pnpm dlx`.
Hardlink pn.exe / pnpx.exe / pnx.exe to the SEA pnpm.exe (in setup.js
preinstall and in self-update's linkExePlatformBinary) and rewrite
those bin entries to the .exe names. cmd-shim emits a direct exec for
.exe sources, taking cmd.exe out of the chain entirely. The SEA reads
process.execPath's basename and prepends `dlx` when launched as
pnpx / pnx.
* test(exe): make Windows alias tests robust to local-dev environments
Two follow-ups from Copilot review on #11501:
* Use `'junction'` instead of `'dir'` for the detect-libc symlink on
Windows. Non-junction directory symlinks need Developer Mode or
admin, which the existing failure-path tests already skip on Windows
for; junctions don't.
* Probe \`bash --version\` before running the Git Bash / MSYS2 alias
test, and skip cleanly if it isn't on PATH (local Windows dev
machines often lack it; CI windows-latest ships it). Fold the status
check into the assertion so a non-zero exit surfaces in the diff.
* test(exe): wire @pnpm/exe into the recursive test runner
The setup.test.ts in this package wasn't running in CI — `@pnpm/exe`
had no `.test` script, so `pn -r .test` (what `test-pkgs-all` runs)
silently skipped it. The existing tests there have apparently been
dead since they were added; the Windows alias repro added in 1e93a1d
inherited the same gap.
Add `.test` (jest invocation, matching every other workspace
package's shape) and a `test` alias so it's picked up by the
recursive runner. meta-updater's @pnpm/exe / artifacts branch
short-circuits before adding test scripts; preserve that behavior by
hand-writing them rather than restructuring the rule.
## Summary
`pnpm publish` will now let an OIDC-derived token from npm's trusted publishing flow take precedence over a statically configured `_authToken` (e.g. one written from an `NPM_TOKEN` CI secret), mirroring the [npm CLI's behavior](https://github.com/npm/cli/blob/7d900c46/lib/utils/oidc.js).
### Background
We noticed that `pnpm@11.0.0-alpha.5` was published with trusted publishing on npm (its registry metadata has `_npmUser.trustedPublisher` and a SLSA `attestations.provenance` block) — but `pnpm@11.0.6` was not (`_npmUser: pnpmuser`, no attestations). The two were published by different clients: alpha.5 went out via `npm publish` (its metadata carries `_npmVersion: 11.6.2`, which `pnpm publish` never sets), while 11.0.6 went out via `pn release` from `.github/workflows/release.yml`.
Both paths run in CI with `id-token: write` granted, and both have the same `pn config set //registry.npmjs.org/:_authToken "\${NPM_TOKEN}"` step before publish. The difference is purely in how each client orders the auth precedence:
- **npm CLI**: tries the OIDC exchange first; on success **overwrites** the configured `_authToken`. The static token only acts as a fallback when OIDC isn't applicable (no trusted publisher configured, exchange fails, etc.).
- **pnpm publish (before this PR)**: bailed out of OIDC entirely as soon as a token was already configured (`releasing/commands/src/publish/publishPackedPkg.ts`, the old `fetchTokenAndProvenanceByOidcIfApplicable`). Worse, the call site used `??=` so OIDC could never overwrite the static token even if the bail-out had been removed.
So even though pnpm has a complete, working OIDC implementation, any release workflow that still wired in `NPM_TOKEN` silently downgraded to legacy token-based publishing — no `trustedPublisher` on the metadata, no provenance attestation. That's the fix here.
## Changes
- \`fetchTokenAndProvenanceByOidcIfApplicable\` → \`fetchTokenAndProvenanceByOidc\`. Dropped the now-unused \`targetPublishOptions\` parameter and removed the early bail-out — OIDC is always attempted when running in a supported CI environment with \`id-token: write\`.
- At the call site in \`createPublishOptions\`: when OIDC returns an authToken, it now overwrites \`publishOptions.token\` instead of nullish-assigning. The static token still wins when OIDC isn't applicable (no CI env, no trusted publisher configured, exchange fails).
- \`appendAuthOptionsForRegistry\` propagates the (possibly OIDC-overridden) token to the registry-scoped \`\${configKey}:_authToken\`, so libnpmpublish picks it up correctly.
- A \`ProvenanceError\` from \`determineProvenance\` no longer discards the freshly-fetched OIDC authToken — the publish itself can still go through with the OIDC token, again matching npm CLI behavior.
- Exported \`fetchTokenAndProvenanceByOidc\` (marked \`@internal\`) so the precedence rules are unit-testable.
### Recursive publish
The recursive publish loop in \`recursivePublish.ts\` calls \`publishPackedPkg\` once per workspace package, and OIDC token exchange is package-scoped on the npm side (\`/-/npm/v1/oidc/token/exchange/package/\${name}\`). With this fix, every workspace package independently attempts trusted publishing and only falls back to the static token if its own exchange fails. No structural change needed there.
The previous "Publish Packages" step ran `pn release` after writing
NPM_TOKEN into pnpm's config. With a static `_authToken` configured,
`pnpm publish` bails out of OIDC entirely (see #11495 for the longer-
term fix), so every package — including `pnpm` and `@pnpm/exe` — was
silently being published with the legacy token instead of using npm's
trusted publishing. The result: published metadata showed
`_npmUser: pnpmuser` and no provenance attestation.
Until #11495 ships, work around the precedence bug by structuring the
job so the packages we *want* trusted publishing for never see a
static token at all:
1. `@pnpm/exe` — published in a step with no NPM_TOKEN. pnpm has no
token to short-circuit on, performs OIDC, gets a `trustedPublisher`
entry on npm.
2. Internal workspace packages — these don't have trusted publishing
configured on npm, so they still need the static token. The token
is written, the publish runs, then `pn config delete` removes the
token before the next step.
3. `pnpm` — published in a step with no NPM_TOKEN, same rationale as
step 1.
CI-only change; no changeset needed.
Fixes [#11492](https://github.com/pnpm/pnpm/issues/11492).
In pnpm v11 a scoped registry resolved to different URLs depending on which command read it:
- `pnpm config get @<scope>:registry` returned the value from `.npmrc`
- `pnpm publish` used the value from `pnpm-workspace.yaml`'s `registries` block
When the two sources disagreed, `pnpm publish` silently targeted the URL from `pnpm-workspace.yaml`, even though `pnpm config get` reported a different (and seemingly authoritative) URL — so users could publish to the wrong registry without any indication.
This PR makes `pnpm config get @<scope>:registry` read from the merged `Config.registries` map (the same map `publish` and the resolvers use) before falling back to `authConfig`. Both commands now report and use the same URL.
Adds a native `pnpm bugs` command (npm-compatible) that opens a package's bug tracker URL in the browser.
- With no arguments, reads `bugs` / `repository` from the current project's `package.json`.
- With one or more package names, fetches each package's metadata from the registry (via `fetchPackageInfo`, so auth, scoped names, and proxies are handled) and opens its bug tracker.
- Falls back to `<repository>/issues` when `bugs` is missing, normalizing `git+`, `.git`, `git://`, and `git@github.com:` forms first.
- Implemented in `@pnpm/deps.inspection.commands` alongside `docs`, reusing the `open` package for cross-platform browser launching.
Picked up from #11279 (kairosci's branch) and reworked per @zkochan's review:
- moved out of `pnpm/src/cmd/` into the inspection commands package
- supports `pnpm bugs [<pkgname> [<pkgname> ...]]` per npm
- proper scoped-name encoding via the npm resolver
- changeset split (no more `bins.linker` bump)
- dropped unrelated test/build noise
Closes#11279.
Closes#11488.
`pnpm fetch` writes forced-empty `hoistPattern: []` and `publicHoistPattern: []` into `.modules.yaml` (because its `virtualStoreOnly` install path skips hoisting). In v10 the follow-up `pnpm install` ignored these unless the user had explicitly set a hoist-pattern in their config. v11's [#11199](https://github.com/pnpm/pnpm/pull/11199) removed that explicit-config gate, so `validateModules` now always sees the empty patterns as a hoist-pattern change and purges `node_modules` — slow on every CI run, and per the bug report sometimes leaves the modules dir in an `ERR_MODULE_NOT_FOUND` state on subsequent runs.
The fix marks `.modules.yaml` with a new `virtualStoreOnly: true` field after a fetch. `validateModules` recognizes this flag as "incomplete install state" and skips the `PUBLIC_HOIST_PATTERN_DIFF` / `HOIST_PATTERN_DIFF` comparisons. The next install then completes the missing post-import linking in place rather than purging. The flag is dropped from `.modules.yaml` once a normal install runs.
A genuine hoist-pattern change (without a fetch in between) still triggers the purge as before — verified manually with `publicHoistPattern` in `pnpm-workspace.yaml`.
The CLI argument parser short-circuits `--help` and `--version` and was discarding every other parsed option in the process — including universal flags like `--pm-on-fail`. So `pnpm audit --pm-on-fail=ignore --help` and `pnpm --pm-on-fail=ignore --version` failed with the strict `packageManager` mismatch error instead of doing what was asked. Users had no documented way out: the suggested escape hatch in the error message itself didn't work.
The fix plucks universal options back out of the exploratory `nopt` parse and surfaces them through both short-circuits. They were already typed correctly there; only the regular per-command parse adds command-specific options. Command-specific options (e.g. `--frozen-lockfile`) stay dropped, since the matching command isn't being executed.
Closes [#11487](https://github.com/pnpm/pnpm/issues/11487).
For git-hosted tarballs (`codeload.github.com` / `gitlab.com` / `bitbucket.org`) the fetcher dropped the integrity it computed while downloading, so the lockfile only ever stored the URL. A compromised git host or man-in-the-middle could serve a substituted tarball on subsequent installs and pnpm would install it — the lockfile had no hash to compare against.
This pins the SHA-512 SRI of the raw tarball in the lockfile, in the same `sha512-<base64>` form npm-registry tarballs use. The only difference is the source: for npm we pass through `dist.integrity`, for git we compute it locally from the downloaded buffer. Subsequent installs validate the download against that integrity in the worker (`addTarballToStore` → `parseIntegrity` → hash compare), so a tampered tarball fails with `TarballIntegrityError`.
## Why git-hosted stays on `gitHostedStoreIndexKey`
The lockfile pins integrity for security, but the *store key* for git-hosted resolutions stays on `gitHostedStoreIndexKey(pkgId, { built })` rather than collapsing under the integrity-based key. Reason: git-hosted tarballs are post-processed (`preparePackage` / `packlist`), so the cached file set depends on whether build scripts ran during fetch. The integrity-only key would fold the built and not-built variants into a single slot, letting one overwrite the other and serving the wrong content if `ignoreScripts` was toggled between runs. Keeping git-hosted on the existing key shape preserves that dimension; the integrity is still validated on every fresh download.
## How the routing stays clean
The naive way to express "use gitHostedStoreIndexKey for git-hosted, integrity key for npm" is to call `isGitHostedPkgUrl(resolution.tarball)` everywhere a store key is computed — fragile, scattered, and easy to forget when adding new readers (Copilot caught two of those during review). Instead, a typed annotation: `TarballResolution` gets an optional `gitHosted: boolean` field. The git resolver sets it; the lockfile loader (`convertToLockfileObject`) backfills it for entries written by older pnpm versions; `toLockfileResolution` carries it through on serialize. Every consumer reads `resolution.gitHosted` directly. URL detection lives in exactly two places — the resolver and the loader — instead of seven.
## Changes
### Security fix
- `fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts` — return the `integrity` that the inner remote-tarball fetch already computed (was being silently dropped by the destructure).
### Lockfile schema (additive)
- `@pnpm/lockfile.types` and `@pnpm/resolving.resolver-base` — `TarballResolution` gains optional `gitHosted: boolean`.
- `@pnpm/resolving.git-resolver` — sets `gitHosted: true` on every git-hosted tarball it produces.
- `@pnpm/lockfile.fs` (`convertToLockfileObject`) — backfills the field on load for older lockfiles via inlined URL detection.
- `@pnpm/lockfile.utils` (`toLockfileResolution`, `pkgSnapshotToResolution`) — preserve / read the field.
### Store-key consumers (now one-line typed reads, dropped the URL-sniffing dep)
- `installing/package-requester` (`getFilesIndexFilePath`)
- `store/pkg-finder` (`readPackageFileMap`)
- `modules-mounter/daemon` (`createFuseHandlers`)
- `building/after-install` (side-effects-cache lookup + write)
- `store/commands/storeStatus`
- `installing/deps-installer` (agent-mode store-controller wrapper)
### Fetcher routing
- `fetching/pick-fetcher` — `pickFetcher` prefers `resolution.gitHosted`; URL fallback retained for ad-hoc resolutions.
### Tests
- New integrity-validation test in `tarball-fetcher` (mismatched `integrity` on the resolution must throw `TarballIntegrityError`).
- New git-hosted lookup test in `pkg-finder` asserting routing through `gitHostedStoreIndexKey` even when integrity is present.
- New `toLockfileResolution` test asserting `gitHosted: true` flows through serialization.
- `fromRepo.ts` lockfile snapshot updated for the now-pinned integrity + `gitHosted: true`.
- `git-resolver` tests updated to assert `gitHosted: true` in produced resolutions.
- Closes#11483.
- `@pnpm/exe@11.x` was packing `dist/node-gyp-bin/node-gyp`, `dist/node-gyp-bin/node-gyp.cmd`, and `dist/node_modules/node-gyp/bin/node-gyp.js` with mode `0644` instead of `0755`. Any install whose lifecycle script invokes `node-gyp rebuild` while pnpm has prepended `dist/node-gyp-bin/` to `PATH` failed with `sh: 1: node-gyp: Permission denied` — most visibly via `pnpm/action-setup@v6`'s standalone path on GitHub Actions runners with system Node < 22.13.
- The regular `pnpm` package's `package.json` already declares `publishConfig.executableFiles` for these three shims, which `pnpm publish` honors by packing matching tar entries with mode `0755` (see `releasing/commands/src/publish/pack.ts:273` and the `bins`-driven mode selection at `pack.ts:360-361`). `@pnpm/exe`'s `package.json` was missing the same field, so the shims it copies from `pnpm/dist/` shipped with `0644`. This PR mirrors the field into `@pnpm/exe`.
- To prevent the two manifests from drifting again, meta-updater is now the single source of truth for the executable-files list and writes it into both `pnpm/package.json` and `pnpm/artifacts/exe/package.json`.
* fix: include workspace root only when --filter is positive
PR #10465 stopped the implicit workspace-root exclusion for `pnpm -r
run/exec/test/add` whenever any --filter was provided. That gate was
too broad: with a negative-only filter set (e.g. `--filter '!a'`) the
workspace root started showing up in the matched projects, contradicting
the documented default behavior.
Only suppress the implicit root exclusion when the user provided at
least one positive (non-`!`) filter that could have been intended to
select the root. Negative-only filter sets keep the documented default,
and `--include-workspace-root` continues to opt the root in explicitly.
Close#11341.
* test: fix root-exclusion assertion to match --stream prefix
The --stream reporter prefixes output lines with the project's relative
directory, so the workspace root appears as `. which$`, not
`root which$`. The original assertion would have passed even if the
regression were still present. Verified the corrected assertion fails
against the buggy code and passes against the fix.
Moves 20 user-level preference settings from the workspace-only exclusion list into the global config allowlist (`config/reader/src/configFileKey.ts`):
- Shell / scripts: `scriptShell`, `shellEmulator`
- Notifications & UI: `updateNotifier`, `useStderr`
- Trust policy (already DLX-inherited as user-level posture): `trustPolicy`, `trustPolicyExclude`, `trustPolicyIgnoreAfter`
- Store / virtual store: `globalVirtualStoreDir`, `virtualStoreDir`, `virtualStoreDirMaxLength`, `verifyStoreIntegrity`, `sideEffectsCache`, `sideEffectsCacheReadonly`
- Build / dep verification: `strictDepBuilds`, `verifyDepsBeforeRun`
- Misc personal/system prefs: `stateDir`, `registrySupportsTimeField`, `initPackageManager`, `initType`, `agent`
These are personal/system preferences rather than workspace structure. In v10 they could be set in `~/.npmrc`. v11 silently dropped them from both `~/.npmrc` and the new global `config.yaml`, leaving `pnpm-workspace.yaml` as the only working location — which the issue author rightly points out is impractical for system-level defaults like `scriptShell`.
After this change:
- Settings in `~/.config/pnpm/config.yaml` are applied instead of being filtered out by `isConfigFileKey` (`config/reader/src/index.ts:296`).
- `pnpm config set --location global scriptShell <path>` succeeds instead of throwing `ConfigSetUnsupportedYamlConfigKeyError` (same predicate used in `config/commands/src/configSet.ts:237`).
`pmOnFail` and `runtimeOnFail` are intentionally left workspace-only because they would cause lockfile divergence between contributors when set globally. `~/.npmrc` support for non-auth/non-network keys is also intentionally not restored — the team has moved those settings to YAML config.
Closes#11474.
* chore(release): wrap changeset version with cross-branch consumed-id ledger
When a fix is cherry-picked from main to a release branch (or vice
versa), the changeset file ends up on both branches. The release
branch's release consumes and deletes its copy, but the cherry-picked
copy on main survives the merge back and would be re-applied on the
next main release.
Introduce a small wrapper around `changeset version` that maintains a
per-branch ledger at .changeset/.released/<branch>.txt. Each entry is a
consumed changeset id; the file is written only by the branch it is
named after, so the records merge across branches without conflicts.
Before running `changeset version` the wrapper reads the union of every
ledger file, hides matching .changeset/<id>.md files (rename to
.md.released), then runs `changeset version` against the remaining set.
Newly consumed ids are appended to the current branch's ledger; hidden
files are removed afterward (their consumption is already on record
elsewhere). On failure the hidden files are restored to keep the
working tree clean.
* docs: move release-ledger explanation out of AGENTS.md
AGENTS.md is for instructions to AI agents working on the codebase, but
the cross-branch ledger is release machinery that the maintainer running
`pnpm bump` interacts with — agents authoring changesets do not need to
know about it. Move the explanation to where someone runs into it:
- .changeset/.released/README.md — discovered by anyone exploring the
directory.
- A short doc-comment header at the top of __utils__/scripts/src/bump.ts
pointing readers there.
* fix(scripts): harden bump wrapper edge cases from PR review
- Use url.pathToFileURL(realpathSync(...)) to compare against
import.meta.url so the direct-invocation guard works on Windows
paths and through symlinks (Copilot review).
- hideReleased() now iterates the changeset directory and filters by
the released set instead of iterating the (potentially long) ledger
and probing existsSync per entry (Copilot review).
- hideReleased() restores already-renamed files if a later rename
throws, so a partial failure leaves the .changeset directory in its
original state (CodeRabbit review).
- Move deleteHidden() into a finally so the .md.released files are
cleaned up even if appendReleased() throws after a successful
changeset version run (CodeRabbit review).
- Add a unit test that forces hideReleased() to fail mid-loop and
asserts the rollback.
pnpm 10 transitively emitted an npm-CLI-shaped per-package JSON object on stdout for
`pnpm publish --json` because the publish command shelled out to npm. pnpm 11 (#10591)
reimplemented publish natively and the new handler returned undefined, so `--json` now
produces 0 bytes on stdout on success — silently breaking downstream tooling that grepped
that contract. The most impactful regression is `nx release publish`, which parses
stdout JSON to confirm success; under `--nx-bail=true` it aborts the rest of a release
run when JSON is missing (see nrwl/nx#35575).
This restores the contract for both single-package and recursive publish, and aligns
`--report-summary` to use the same per-package shape:
- `pnpm publish --json` → single object `{ id, name, version, size,
unpackedSize, shasum, integrity, filename, files,
entryCount, bundled }`, mirroring `npm publish --json`.
- `pnpm publish -r --json` → array of those objects, mirroring `pnpm pack --json`'s
shape choice for single vs multi.
- `pnpm publish -r --report-summary` → existing `pnpm-publish-summary.json` envelope
`{ publishedPackages: [...] }` is preserved, but
each entry is upgraded to the same per-package shape
(additive — `name` and `version` are still present).
Implementation:
- `pack.api()` computes `unpackedSize` while it still has the filesMap.
- `publishPackedPkg` returns a `PublishSummary` (SHA-1 `shasum` + SHA-512 `integrity`
via `node:crypto`, no new deps) instead of `void`.
- `bundled` normalizes `bundledDependencies` / `bundleDependencies` per npm's semantics
(including `true` → all dep names).
- `recursivePublish` collects per-package `PublishSummary` objects and returns them; the
existing manifest-pick fallback is kept for paths that don't produce a summary.
- `publish.handler` returns `{ output, exitCode }` with the serialized summary (object
or array) when `opts.json` is set; main.ts already writes `output` to stdout.
Refs #11476
Refs nrwl/nx#35575
Amp-Thread-ID: https://ampcode.com/threads/T-019df90f-8f75-763f-b528-4602e870a972
Co-authored-by: Charlie Croom <charlie+amp@noreply.com>
Co-authored-by: Amp <amp@ampcode.com>
* chore: update Node.js to 26.0.0
* fix(jest-config): use amaro for type stripping on Node.js 26
Node.js v26 removed the `transform` mode and `sourceMap` option from
`module.stripTypeScriptTypes`. Switch the Jest transform to call
`amaro.transformSync` directly (the same wasm transformer Node.js wraps)
so we keep inline source maps for tests.
Add a "Working with GitHub PRs, Issues, and Comments" section to AGENTS.md covering three rules:
Keep PR title and description current when pushing new changes.
Reply to resolved review comments with the resolution + commit hash and close the conversation.
Sign all agent-authored comments, issues, and PRs with a footer that names the agent and model.