Merge branch 'main' into feat/view-command-support-omitted-package-name

This commit is contained in:
btea
2026-05-28 15:14:05 +08:00
committed by GitHub
1126 changed files with 101846 additions and 8613 deletions

View File

@@ -0,0 +1,17 @@
attestation-first-min-release-age
auto-collect-minimum-release-age-exclude
cache-aware-minimum-release-age-gate
clear-password-padding
fix-11655-self-update-minimum-release-age
fix-global-allow-builds
fix-verify-deps-silent-install
floppy-parents-teach
gvs-engine-name-shell-node
gvs-engine-per-snapshot-runtime-pin
lockfile-verification-progress-logs
oidc-unresolved-env-placeholder
pmonfail-default-devengines-11676
record-locally-resolved-lockfile-verified
revalidate-minimum-release-age
sync-env-lockfile-when-missing-11674
warn-deprecated-pnpm-field-11677

View File

@@ -1,7 +1,10 @@
audit-signatures
check-deps-status-skip-engine-check
clear-resolutions-warning
codeql-alert-159-followup
codeql-security-fixes
credential-rebind-defense
deploy-skip-config-dependencies
fix-11529-view-published-time
fix-11561-publish-web-auth-proxy
fix-11587-global-isolated-per-arg
@@ -11,12 +14,24 @@ fix-named-catalog-upgrade
fix-named-registry-vs-local-resolver
fix-optimistic-lockfile-conflict
gh-packages-prefix
git-fetcher-reject-non-sha-commits
honest-moose-grin
integrity-mismatch-fails-by-default
no-runtime-flag
olive-spies-listen
patch-path-traversal
pnpm-bugs-command
pnpm-owner-command
policy-handlers-loose-mode-message
preserve-published-at-in-fast-path
registry-access-client-helper
reject-traversal-dependency-aliases
require-tarball-integrity
runtime-onfail-node-check
runtime-set-default-devengines
runtime-set-workspace-root
split-local-resolver
tidy-global-update-summary
tidy-trust-publishers
update-cmd-shim-9-0-3
version-exit-finishes-workers

View File

@@ -0,0 +1,10 @@
dlx-fall-back-to-alias-when-manifest-missing
fix-cyclic-peer-determinism
fix-unaliased-deps-dropped-from-manifest
native-pkg-command
native-repo-command
native-set-script-command
prune-env-lockfile-on-config-dep-update
skip-manifest-obfuscation-opt-in
stage-publish
trust-lockfile-and-verifier-memory

View File

@@ -0,0 +1,3 @@
config-deps-optional-subdep-snapshot-flag
pick-registry-unscoped-npm-alias
quiet-config-deps

View File

@@ -0,0 +1,2 @@
fix-pacquet-outdated-lockfile-on-update
forward-install-flags-to-pacquet

View File

@@ -0,0 +1,11 @@
cafile-resolve-against-npmrc-dir
config-deps-optional-subdeps
config-reader-registry-sync-npmrc
global-minimum-release-age-policy
injectworkspacepackages-prune-crashes
login-scope-flag
login-workspace-registry
minimum-release-age-modified-shortcut-inclusive
outdated-runtimes
pacquet-frozen-install-delegation
publish-config-access

View File

@@ -1,12 +0,0 @@
---
"@pnpm/resolving.resolver-base": minor
"@pnpm/resolving.npm-resolver": minor
"@pnpm/resolving.default-resolver": minor
"@pnpm/installing.client": minor
"@pnpm/store.connection-manager": minor
"@pnpm/testing.temp-store": minor
"@pnpm/installing.deps-installer": minor
"pnpm": patch
---
Restructured the `minimumReleaseAge` lockfile revalidation gate around a generic `ResolutionVerifier` interface. Each resolver may now export a sibling verifier factory (today: `createNpmResolutionVerifier`) that re-checks an already-resolved lockfile entry against its policies; `createResolver`'s companion `createResolutionVerifier` combines them and the `Client` exposes the combined `verifyResolution` for the install layer to consume. The npm verifier reuses the same on-disk metadata mirror the resolver writes to, so steady-state installs pay only a headers-only conditional GET per locked package [#11675](https://github.com/pnpm/pnpm/issues/11675).

View File

@@ -0,0 +1,5 @@
---
"@pnpm/releasing.commands": patch
---
Fix scoped packages without a publishConfig.access setting being published with public access.

View File

@@ -1,14 +0,0 @@
---
"@pnpm/config.version-policy": minor
"@pnpm/deps.inspection.outdated": patch
"@pnpm/engine.pm.commands": patch
"@pnpm/exec.commands": patch
"@pnpm/installing.deps-resolver": patch
"pnpm": patch
---
Make `pnpm self-update` respect `minimumReleaseAge` (and `minimumReleaseAgeExclude`) when resolving which pnpm version to install.
When the `latest` dist-tag points to a version newer than the configured age threshold, `self-update` now selects the newest mature version instead unless excluded by `minimumReleaseAgeExclude`.
Also makes `dlx` and `outdated` surface invalid `minimumReleaseAgeExclude` patterns under the same `ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE` error code already used by `install`, instead of leaking the internal `ERR_PNPM_INVALID_VERSION_UNION` / `ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION` codes.

View File

@@ -1,13 +0,0 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
**fix**: global installs respect global config build policy (e.g., `dangerouslyAllowAllBuilds` from config.yaml) when GVS is enabled [#9249](https://github.com/pnpm/pnpm/issues/9249).
The global virtual-store (GVS) default `allowBuilds = {}` was applied before workspace manifest settings were read and before global config values (stripped by `extractAndRemoveDependencyBuildOptions`) were re-applied via `globalDepsBuildConfig`. This caused `hasDependencyBuildOptions` to return `true` (because `{}` is not null), blocking restoration of global config values like `dangerouslyAllowAllBuilds`. As a result, global installs skipped all build scripts even when the config explicitly allowed them.
This fix moves the GVS default to **after** workspace manifest reading and `globalDepsBuildConfig` re-application, so that:
1. Workspace manifest `allowBuilds` takes precedence (if present)
2. Global config `dangerouslyAllowAllBuilds` is properly restored (if set and no workspace policy exists)
3. Empty `{}` is only applied as a last resort when no policy is configured anywhere

View File

@@ -0,0 +1,6 @@
---
"@pnpm/resolving.npm-resolver": patch
"pnpm": patch
---
Fix `minimumReleaseAgeExclude` handling in npm resolution fast paths so excluded packages do not get pinned to stale versions. Excludes are honored consistently during `publishedBy` metadata selection and cache-mtime shortcuts.

View File

@@ -1,6 +0,0 @@
---
"@pnpm/lockfile.fs": patch
"pnpm": patch
---
Fix lockfile parsing failures when `pnpm-lock.yaml` contains CRLF line endings and multiple YAML documents [#11612](https://github.com/pnpm/pnpm/issues/11612).

View File

@@ -1,31 +0,0 @@
---
"@pnpm/building.after-install": patch
"@pnpm/building.during-install": patch
"@pnpm/deps.graph-builder": patch
"@pnpm/deps.graph-hasher": patch
"@pnpm/engine.runtime.system-node-version": minor
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.deps-resolver": patch
"@pnpm/installing.deps-restorer": patch
"pnpm": patch
---
**fix**: anchor the side-effects-cache key and global-virtual-store hash to the project's script-runner Node — `engines.runtime` pin when present, shell `node` otherwise — instead of pnpm's own runtime.
`ENGINE_NAME` (the `<platform>;<arch>;node<major>` prefix used as the side-effects-cache key and the engine portion of the GVS hash) was computed from `process.version` — the Node that runs pnpm itself. That was wrong in two situations:
1. **`@pnpm/exe` SEA bundle.** The bundle has its own embedded Node, not the `node` on the user's `PATH` that actually spawns lifecycle scripts. Two pnpm installations on the same machine (one SEA, one npm-package) therefore disagreed on the cache key, partitioning the side-effects cache and the global virtual store across two Node majors even though both installs would run scripts on the same shell `node`.
2. **`engines.runtime` / `devEngines.runtime` pin.** When a project pins a Node version via `devEngines.runtime` (pnpm v11+), pnpm downloads that Node into `node_modules/node/` and uses it to run lifecycle scripts. But the hash still anchored to whichever Node ran pnpm itself, not to the pinned Node — so two installs of the same project with two different runner Nodes would still disagree on the GVS slot path even though scripts run on the same pinned Node.
Three changes:
- `@pnpm/engine.runtime.system-node-version` now exports `engineName(nodeVersion?)` and `findRuntimeNodeVersion(snapshotKeys)`. `engineName()` resolves the version in this order: explicit override → `getSystemNodeVersion()` (which already prefers `node --version` over `process.version` in SEA contexts) → `process.version`. `findRuntimeNodeVersion` scans an iterable of lockfile snapshot keys for a `node@runtime:<version>` entry and returns its bare version string.
- `@pnpm/deps.graph-hasher`'s `calcDepState` and `calcGraphNodeHash`/`iterateHashedGraphNodes` now accept a `nodeVersion?` (in the options bag for the first, as a trailing parameter / ctx field for the others), forwarded to `engineName()`. The default (no override) preserves the pre-change behaviour. The legacy `ENGINE_NAME` constant in `@pnpm/constants` is unchanged so external consumers and existing tests keep working; in non-SEA, non-pinned contexts every value lines up.
- Every install-side caller of the graph-hasher (`@pnpm/installing.deps-resolver`, `@pnpm/installing.deps-restorer`, `@pnpm/installing.deps-installer`, `@pnpm/building.during-install`, `@pnpm/building.after-install`, `@pnpm/deps.graph-builder`) now derives the project's pinned runtime via `findRuntimeNodeVersion(Object.keys(graph))` once per invocation and threads it through.
On upgrade, two one-time GVS slot churns are possible:
- **SEA-pnpm users** without a runtime pin: slots that previously hashed under the embedded-Node major (e.g. `node26`) now hash under the shell-Node major (e.g. `node24`), matching what pacquet, the npm-published `pnpm` package, and any other pnpm-compatible tool already produce.
- **Projects with a `devEngines.runtime` pin**: slots that previously hashed under the runner's Node major now hash under the pinned Node major, matching what the lifecycle scripts will actually run on.
In both cases the old slots become prune-eligible.

View File

@@ -1,6 +0,0 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Fixed `pnpm publish` failing with a 404 when authentication relied on OIDC trusted publishing alongside an `.npmrc` written by `actions/setup-node` (`_authToken=${NODE_AUTH_TOKEN}`) without `NODE_AUTH_TOKEN` being set. Unresolved `${VAR}` placeholders in auth values are now treated as empty rather than passed through verbatim, so the literal placeholder no longer surfaces as a bearer token when OIDC fallback is the intended auth source [#11513](https://github.com/pnpm/pnpm/issues/11513).

View File

@@ -0,0 +1,9 @@
---
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.context": patch
"pnpm": patch
---
Skip dependency re-resolution when `pnpm-lock.yaml` is missing but `node_modules/.pnpm/lock.yaml` exists and still satisfies the manifest. `pnpm install` now reuses the materialized snapshot to regenerate `pnpm-lock.yaml` instead of walking the registry to rebuild it from scratch, turning the cache+node_modules variation into a near-no-op for users who deleted the lockfile but kept the install [#11993](https://github.com/pnpm/pnpm/issues/11993).
`--frozen-lockfile` still refuses to proceed when `pnpm-lock.yaml` is absent — the regenerated lockfile must be committed, so failing loudly is the correct behavior for CI.

View File

@@ -1,6 +0,0 @@
---
"@pnpm/installing.deps-installer": minor
"pnpm": patch
---
`minimumReleaseAge` is now re-checked against `pnpm-lock.yaml` before any tarball is installed, so a freshly-published version pinned in the lockfile (e.g. by a developer who bypassed the policy locally) is no longer installed silently by other consumers or CI. Violating entries abort the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored. [#10438](https://github.com/pnpm/pnpm/issues/10438).

View File

@@ -7,27 +7,21 @@ inputs:
clippy:
default: false
required: false
type: boolean
fmt:
default: false
required: false
type: boolean
docs:
default: false
required: false
type: boolean
restore-cache:
default: true
required: false
type: boolean
save-cache:
default: false
required: false
type: boolean
shared-key:
default: 'warm'
required: false
type: string
runs:
using: composite

View File

@@ -18,10 +18,23 @@ on:
required: false
default: '1'
type: string
push:
# Build Bencher's continuous baseline for the `pnpm` testbed.
# Every merge to main re-runs the bench so PR comparisons have
# an up-to-date reference; cancel-in-progress stays off below
# so we never throw away a partial run.
branches: [main]
permissions:
contents: read
pull-requests: write
checks: write
# Don't cancel-in-progress — killing a bench mid-run wastes a long
# CI job and produces no usable data.
concurrency:
group: benchmark-${{ inputs.pr_number || github.ref }}
cancel-in-progress: false
jobs:
benchmark:
@@ -52,13 +65,15 @@ jobs:
with:
runtime: node@26.0.0
- name: Install hyperfine
run: |
wget -q https://github.com/sharkdp/hyperfine/releases/download/v1.18.0/hyperfine_1.18.0_amd64.deb
sudo dpkg -i hyperfine_1.18.0_amd64.deb
- name: Install Rust Toolchain
uses: ./.github/actions/rustup
with:
shared-key: pnpm-benchmark
- name: Compile
run: pnpm run compile
- name: Install hyperfine
uses: ./.github/actions/binstall
with:
packages: hyperfine@1.18.0
- name: Run benchmarks
id: bench
@@ -72,6 +87,68 @@ jobs:
RUNS: ${{ inputs.runs }}
WARMUP: ${{ inputs.warmup }}
- name: Install Bencher CLI
if: steps.bench.outputs.bench_dir != ''
uses: bencherdev/bencher@50fb1e138651a46d2fb704fab1adab38c181552e # v0.6.6
- name: Upload results to Bencher
if: steps.bench.outputs.bench_dir != ''
env:
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
BENCH_DIR: ${{ steps.bench.outputs.bench_dir }}
EVENT_NAME: ${{ github.event_name }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
REF_NAME: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ -z "${BENCHER_API_TOKEN:-}" ]; then
echo "::notice::BENCHER_API_TOKEN not set, skipping Bencher upload"
exit 0
fi
if [ ! -f "$BENCH_DIR/bencher-results.json" ]; then
echo "::warning::bencher-results.json not found, skipping upload"
exit 0
fi
# `bencher run --file` takes hyperfine JSON via the
# shell_hyperfine adapter. Branch policy:
# - push to main → record into the `main` branch (baseline)
# - workflow_dispatch with pr_number → record into `pr/<n>`,
# forked from main at the latest baseline
# - workflow_dispatch without pr_number → record into the
# ref's branch name (e.g. a feature branch), forked from main
args=(
--project pnpm
--testbed pnpm
--adapter shell_hyperfine
--file "$BENCH_DIR/bencher-results.json"
--github-actions "$GITHUB_TOKEN"
)
# `--start-point-clone-thresholds` so the forked branch inherits
# the threshold configured on main; `--err` so the workflow fails
# when a sample breaches the upper boundary. Main pushes skip
# both — by then the regression has already landed.
if [ "$EVENT_NAME" = "push" ] || [ "$REF_NAME" = "main" ]; then
args+=(--branch main)
elif [ -n "$INPUT_PR_NUMBER" ]; then
args+=(
--branch "pr/$INPUT_PR_NUMBER"
--start-point main
--start-point-reset
--start-point-clone-thresholds
--err
)
else
args+=(
--branch "$REF_NAME"
--start-point main
--start-point-reset
--start-point-clone-thresholds
--err
)
fi
bencher run "${args[@]}"
- name: Comment on PR
if: steps.bench.outputs.bench_dir != ''
env:

View File

@@ -15,9 +15,6 @@ jobs:
name: Compile & Lint
runs-on: ubuntu-latest
env:
pnpm_config_verify_deps_before_run: false
steps:
- name: Checkout Commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -25,10 +22,6 @@ jobs:
persist-credentials: false
- name: Install pnpm
uses: pnpm/setup@b1cac37306e39c21283b9dd6cb0ac288fb35ba6b
with:
install: false
- name: pacquet install
run: pnx --config.minimum-release-age=0 pacquet install --frozen-lockfile
- name: Compile TypeScript
run: pn compile-only
- name: Lint

View File

@@ -48,7 +48,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -73,4 +73,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5

View File

@@ -27,7 +27,7 @@ jobs:
uses: ./.github/actions/rustup
- name: Install cargo-unused-features
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cargo-unused-features
@@ -51,7 +51,7 @@ jobs:
- name: Install cargo-udeps
if: steps.filter.outputs.src == 'true'
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cargo-udeps

View File

@@ -1,5 +1,12 @@
name: Pacquet CI
# Despite the name, this workflow covers every Rust crate in the
# repo — pacquet and pnpm-registry alike. They share the same cargo
# workspace, so `just test`, dylint, doc, etc. all see both stacks
# in one pass; running two workflows would just duplicate the work.
# Trigger paths intentionally include `registry/**` for the same
# reason.
permissions:
contents: read
@@ -9,6 +16,7 @@ on:
types: [opened, synchronize]
paths:
- 'pacquet/**'
- 'registry/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
@@ -27,6 +35,7 @@ on:
- main
paths:
- 'pacquet/**'
- 'registry/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
@@ -83,10 +92,10 @@ jobs:
continue-on-error: true
- name: Clippy
run: cargo clippy --locked -- -D warnings
run: cargo clippy --locked --workspace --all-targets -- -D warnings
- name: Install just
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: just
@@ -94,7 +103,7 @@ jobs:
run: just install
- name: Install cargo-nextest
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cargo-nextest
@@ -140,7 +149,7 @@ jobs:
- uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef # v1.46.1
with:
files: pacquet
files: pacquet registry
deny:
name: Cargo Deny
@@ -160,7 +169,7 @@ jobs:
- name: Install cargo-deny
if: steps.filter.outputs.src == 'true'
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cargo-deny
@@ -256,7 +265,13 @@ jobs:
- name: Install cargo-dylint and dylint-link
uses: ./.github/actions/binstall
with:
packages: cargo-dylint dylint-link
# Pin to 6.0.0 until trailofbits/dylint cuts a 6.0.2. The
# 6.0.1 binstall artifacts (published 2026-05-26 17:51 UTC)
# fail at driver bootstrap with `failed to read
# /home/runner/work/dylint/dylint/driver/Cargo.toml`, the
# path baked in by dylint's own CI checkout. Downstream
# runners don't have that workspace so the step aborts.
packages: cargo-dylint@6.0.0 dylint-link@6.0.0
- name: Run dylint
# `-D warnings` must come in via `RUSTFLAGS`, not as a trailing

View File

@@ -11,11 +11,13 @@ on:
types: [opened, synchronize]
paths:
- 'pacquet/**/*.rs'
- 'registry/**/*.rs'
push:
branches:
- main
paths:
- 'pacquet/**/*.rs'
- 'registry/**/*.rs'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -36,12 +38,12 @@ jobs:
uses: ./.github/actions/rustup
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cargo-llvm-cov
- name: Install cargo-nextest
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cargo-nextest
@@ -64,7 +66,7 @@ jobs:
continue-on-error: true
- name: Install just
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: just

View File

@@ -26,6 +26,7 @@ permissions:
actions: read
issues: write
pull-requests: write
checks: write
jobs:
comment:
@@ -131,3 +132,41 @@ jobs:
edit-mode: replace
comment-id: ${{ steps.fc.outputs.comment-id }}
body-file: benchmark-artifact/SUMMARY.md
- name: Install Bencher CLI
if: hashFiles('benchmark-artifact/bencher-results.json') != ''
uses: bencherdev/bencher@50fb1e138651a46d2fb704fab1adab38c181552e # v0.6.6
- name: Upload PR results to Bencher
# workflow_run runs in the base-repo privilege context, so
# BENCHER_API_TOKEN is available even for fork PRs. PR number
# comes from the trusted workflow_run payload (resolved in
# `meta` above), never from the artifact body. `--ci-number`
# is required here because Bencher can't infer the PR from a
# workflow_run event the way it does on pull_request.
if: hashFiles('benchmark-artifact/bencher-results.json') != ''
env:
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.meta.outputs.pr }}
shell: bash
run: |
if [ -z "${BENCHER_API_TOKEN:-}" ]; then
echo "::notice::BENCHER_API_TOKEN not set, skipping Bencher upload"
exit 0
fi
# `--start-point-clone-thresholds` so the PR branch inherits the
# threshold configured on main; `--err` so the action surfaces
# a regression on the PR check.
bencher run \
--project pnpm \
--testbed pacquet \
--branch "pr/$PR_NUMBER" \
--start-point main \
--start-point-reset \
--start-point-clone-thresholds \
--err \
--adapter shell_hyperfine \
--file benchmark-artifact/bencher-results.json \
--ci-number "$PR_NUMBER" \
--github-actions "$GITHUB_TOKEN"

View File

@@ -2,6 +2,25 @@ name: Pacquet Integrated-Benchmark
on:
workflow_dispatch:
push:
# Update Bencher's baseline for the `pacquet` testbed whenever
# something that could move pacquet's install perf lands on main.
# Paths mirror the pull_request filter below.
branches: [main]
paths:
- 'pacquet/**/*.rs'
- 'pacquet/**/Cargo.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'justfile'
- 'pacquet/tasks/registry-mock/package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'pacquet/tasks/integrated-benchmark/src/fixtures/**'
- '.github/actions/rustup/**'
- '.github/actions/binstall/**'
- '.github/workflows/pacquet-integrated-benchmark.yml'
pull_request:
types: [opened, synchronize]
paths:
@@ -29,6 +48,7 @@ concurrency:
# the base-repo privilege context via `workflow_run`.
permissions:
contents: read
checks: write
jobs:
benchmark:
@@ -38,6 +58,13 @@ jobs:
os: [ubuntu-latest] # windows is skipped because of complexity, macos is skipped because of inconsistency
name: Run benchmark on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
env:
# On main, HEAD *is* main — comparing them is wasted work, so the
# benchmark runs a single revision and the result is uploaded to
# Bencher as the new baseline. On every other ref (PRs and
# workflow_dispatch from a non-main branch), compare HEAD against
# main so the report shows the relative shift.
BENCHMARK_TARGETS: ${{ github.ref_name == 'main' && 'pacquet@HEAD' || 'pacquet@HEAD pacquet@main' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -67,14 +94,21 @@ jobs:
subsection 'Inspecting branches...'
git branch
- name: Cache verdaccio
- name: Cache pnpm-registry storage
# Persists pnpm-registry's `--storage` dir across CI runs so
# cold-cache benchmark scenarios don't refetch ~2.3k unscoped
# packages from npmjs on every run. Replaces the older
# `~/.local/share/verdaccio/storage` cache from when the mock
# was Node + Verdaccio. Path matches `runtime_storage()` in
# `pacquet/tasks/registry-mock/src/dirs.rs` (defaults to
# `$HOME/.cache/pnpm-registry/storage`).
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
key: integrated-benchmark-verdaccio-${{ hashFiles('pacquet/tasks/integrated-benchmark/src/fixtures/pnpm-lock.yaml') }}
key: integrated-benchmark-pnpm-registry-${{ hashFiles('pacquet/tasks/integrated-benchmark/src/fixtures/pnpm-lock.yaml') }}
restore-keys: |
integrated-benchmark-verdaccio-
integrated-benchmark-pnpm-registry-
path: |
~/.local/share/verdaccio/storage
~/.cache/pnpm-registry/storage
timeout-minutes: 1
continue-on-error: true
@@ -122,7 +156,7 @@ jobs:
packages: hyperfine@1.18.0
- name: Install just
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: just
@@ -135,9 +169,9 @@ jobs:
- name: Precompile benchmark revisions
shell: bash
timeout-minutes: 15
run: just integrated-benchmark --build-only HEAD main
run: just integrated-benchmark --build-only $BENCHMARK_TARGETS
- name: 'Benchmark: Frozen Lockfile'
- name: 'Benchmark: Isolated linker: fresh restore, cold cache + cold store'
shell: bash
# Hyperfine has no per-command timeout, so a single hanging
# install takes the whole step down with the GHA default job
@@ -145,30 +179,44 @@ jobs:
# headroom, and a failure surfaces fast enough to iterate on.
timeout-minutes: 10
run: |
just integrated-benchmark --scenario=frozen-lockfile --verdaccio --with-pnpm HEAD main
cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE.md
cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE.json
just integrated-benchmark --scenario=isolated-linker.fresh-restore.cold-cache.cold-store --registry=verdaccio $BENCHMARK_TARGETS
cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE.md
cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE.json
- name: 'Benchmark: Frozen Lockfile (Hot Cache)'
- name: 'Benchmark: Isolated linker: fresh restore, hot cache + hot store'
shell: bash
# Same timeout rationale as the cold-cache step above.
# Mirrors pnpm's "Headless install (frozen lockfile, warm
# store+cache)" benchmark: each timed run wipes only
# `node_modules`, the per-revision store survives, and
# hyperfine's warmup run is what populates it on the first
# Same timeout rationale as the cold-cache step above. Each
# timed run wipes only `node_modules`; the per-revision store
# survives, and hyperfine's warmup populates it on the first
# iteration so timed runs all see a hot store.
timeout-minutes: 10
run: |
just integrated-benchmark --scenario=frozen-lockfile-hot-cache --verdaccio --with-pnpm HEAD main
cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE_HOT_CACHE.md
cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE_HOT_CACHE.json
just integrated-benchmark --scenario=isolated-linker.fresh-restore.hot-cache.hot-store --registry=verdaccio $BENCHMARK_TARGETS
cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_HOT_CACHE_HOT_STORE.md
cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_HOT_CACHE_HOT_STORE.json
# - name: 'Benchmark: Clean Install'
# shell: bash
# run: |
# just integrated-benchmark --scenario=clean-install --verdaccio --with-pnpm HEAD main
# cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_CLEAN_INSTALL.md
# cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_CLEAN_INSTALL.json
- name: 'Benchmark: Isolated linker: fresh install, cold cache + cold store'
shell: bash
# No-lockfile scenario: pacquet does fresh resolution against
# the registry (pnpm/pnpm#11832), so a single iteration runs
# longer than the restore steps. 20 min leaves headroom for the
# default hyperfine 1 warmup + 10 timed runs across both
# benchmark targets without losing the per-command timeout
# safety net the other steps document.
timeout-minutes: 20
run: |
just integrated-benchmark --scenario=isolated-linker.fresh-install.cold-cache.cold-store --registry=verdaccio $BENCHMARK_TARGETS
cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_COLD_CACHE_COLD_STORE.md
cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_COLD_CACHE_COLD_STORE.json
- name: 'Benchmark: Isolated linker: fresh install, hot cache + hot store'
shell: bash
# Same timeout rationale as the cold-cache install step above.
timeout-minutes: 20
run: |
just integrated-benchmark --scenario=isolated-linker.fresh-install.hot-cache.hot-store --registry=verdaccio $BENCHMARK_TARGETS
cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_HOT_CACHE_HOT_STORE.md
cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_HOT_CACHE_HOT_STORE.json
- name: Generate summary
shell: bash
@@ -176,52 +224,94 @@ jobs:
(
echo '## Integrated-Benchmark Report (${{ runner.os }})'
echo
echo '### Scenario: Frozen Lockfile'
echo '### Scenario: Isolated linker: fresh restore, cold cache + cold store'
echo
cat bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE.md
cat bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE.md
echo
echo '<details><summary>BENCHMARK_REPORT.json</summary>'
echo
echo '```json'
jq 'del(.results[].exit_codes)' bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE.json
jq 'del(.results[].exit_codes)' bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE.json
echo '```'
echo
echo '</details>'
echo
echo '### Scenario: Frozen Lockfile (Hot Cache)'
echo '### Scenario: Isolated linker: fresh restore, hot cache + hot store'
echo
cat bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE_HOT_CACHE.md
cat bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_HOT_CACHE_HOT_STORE.md
echo
echo '<details><summary>BENCHMARK_REPORT.json</summary>'
echo
echo '```json'
jq 'del(.results[].exit_codes)' bench-work-env/BENCHMARK_REPORT_FROZEN_LOCKFILE_HOT_CACHE.json
jq 'del(.results[].exit_codes)' bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_HOT_CACHE_HOT_STORE.json
echo '```'
echo
echo '</details>'
echo
echo '### Scenario: Isolated linker: fresh install, cold cache + cold store'
echo
cat bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_COLD_CACHE_COLD_STORE.md
echo
echo '<details><summary>BENCHMARK_REPORT.json</summary>'
echo
echo '```json'
jq 'del(.results[].exit_codes)' bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_COLD_CACHE_COLD_STORE.json
echo '```'
echo
echo '</details>'
echo
echo '### Scenario: Isolated linker: fresh install, hot cache + hot store'
echo
cat bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_HOT_CACHE_HOT_STORE.md
echo
echo '<details><summary>BENCHMARK_REPORT.json</summary>'
echo
echo '```json'
jq 'del(.results[].exit_codes)' bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_INSTALL_HOT_CACHE_HOT_STORE.json
echo '```'
echo
echo '</details>'
# echo
# echo '### Scenario: Clean Install'
# echo
# cat bench-work-env/BENCHMARK_REPORT_CLEAN_INSTALL.md
# echo
# echo '<details><summary>BENCHMARK_REPORT.json</summary>'
# echo
# echo '```json'
# jq 'del(.results[].exit_codes)' bench-work-env/BENCHMARK_REPORT_CLEAN_INSTALL.json
# echo '```'
# echo
# echo '</details>'
) > bench-work-env/SUMMARY.md
- name: Build Bencher-shaped report
# Combine the four scenario JSONs into one hyperfine-shaped
# file that Bencher's shell_hyperfine adapter accepts. We
# keep only the @HEAD result from each scenario and rename
# `.command` to the scenario name, so Bencher names the
# benchmark after the scenario instead of `pacquet@HEAD`.
shell: bash
run: |
scenarios=(
'ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE:isolated-linker.fresh-restore.cold-cache.cold-store'
'ISOLATED_FRESH_RESTORE_HOT_CACHE_HOT_STORE:isolated-linker.fresh-restore.hot-cache.hot-store'
'ISOLATED_FRESH_INSTALL_COLD_CACHE_COLD_STORE:isolated-linker.fresh-install.cold-cache.cold-store'
'ISOLATED_FRESH_INSTALL_HOT_CACHE_HOT_STORE:isolated-linker.fresh-install.hot-cache.hot-store'
)
inputs=()
for entry in "${scenarios[@]}"; do
file_tag="${entry%%:*}"
name="${entry#*:}"
src="bench-work-env/BENCHMARK_REPORT_${file_tag}.json"
dst="bench-work-env/${name}-bencher.json"
jq --arg s "$name" \
'.results |= [.[] | select(.command == "pacquet@HEAD") | .command = $s]' \
"$src" > "$dst"
inputs+=("$dst")
done
jq -s '{results: map(.results) | add}' \
"${inputs[@]}" > bench-work-env/bencher-results.json
- name: Stage artifact contents
# The artifact carries only the rendered report. The PR number
# and runner OS are derived from the trusted workflow_run
# event payload in the comment-posting workflow, not from
# fork-controlled artifact contents.
# The artifact carries the rendered report plus the
# Bencher-shaped JSON consumed by the comment-posting
# workflow. The PR number and runner OS are derived from the
# trusted workflow_run event payload in that workflow, not
# from fork-controlled artifact contents.
shell: bash
run: |
mkdir -p benchmark-artifact
cp bench-work-env/SUMMARY.md benchmark-artifact/SUMMARY.md
cp bench-work-env/bencher-results.json benchmark-artifact/bencher-results.json
- name: Upload benchmark report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -230,3 +320,47 @@ jobs:
path: benchmark-artifact/
if-no-files-found: error
retention-days: 14
- name: Install Bencher CLI
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: bencherdev/bencher@50fb1e138651a46d2fb704fab1adab38c181552e # v0.6.6
- name: Upload results to Bencher
# Runs on push and workflow_dispatch — both execute in the
# base-repo privilege context where secrets are available.
# PR runs (including from forks) skip this step and upload
# via the workflow_run comment workflow instead.
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
env:
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REF_NAME: ${{ github.ref_name }}
shell: bash
run: |
if [ -z "${BENCHER_API_TOKEN:-}" ]; then
echo "::notice::BENCHER_API_TOKEN not set, skipping Bencher upload"
exit 0
fi
args=(
--project pnpm
--testbed pacquet
--adapter shell_hyperfine
--file bench-work-env/bencher-results.json
--github-actions "$GITHUB_TOKEN"
)
# `--start-point-clone-thresholds` so the forked branch inherits
# the threshold configured on main; `--err` so the workflow fails
# when a sample breaches the upper boundary. Main pushes skip
# both — by then the regression has already landed.
if [ "$REF_NAME" = "main" ]; then
args+=(--branch main)
else
args+=(
--branch "$REF_NAME"
--start-point main
--start-point-reset
--start-point-clone-thresholds
--err
)
fi
bencher run "${args[@]}"

View File

@@ -86,7 +86,7 @@ jobs:
steps:
- name: Install critcmp
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: critcmp

View File

@@ -60,7 +60,7 @@ jobs:
persist-credentials: false
- name: Install cross
uses: taiki-e/install-action@e1c4cd42111751368541a7cb5db3522bd1f846a4 # v2.78.0
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cross
@@ -72,6 +72,28 @@ jobs:
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Validate version input
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid version: '$VERSION' (expected semver like 0.2.3 or 0.2.3-rc.1)"
exit 1
fi
- name: Inject version into pacquet/crates/cli/src/cli_args.rs
# The version reported by `pacquet --version` is a hardcoded clap
# attribute. Patch it here so the binary built for the matrix leg
# reports the version we're about to publish.
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
file=pacquet/crates/cli/src/cli_args.rs
perl -i -pe 's/#\[clap\(version = "[^"]*"\)\]/#[clap(version = "'"$VERSION"'")]/' "$file"
grep -F "version = \"$VERSION\"" "$file"
- name: Build with cross
run: cross build -p pacquet-cli --bin pacquet --release --target=${{ matrix.target }}
@@ -166,7 +188,7 @@ jobs:
run: |
node pacquet/npm/pacquet/scripts/generate-packages.mjs
cat pacquet/npm/pacquet/package.json
for package in pacquet/npm/pacquet*; do cat $package/package.json ; echo ; done
for package in pacquet/npm/pacquet* pacquet/npm/pnpm-pacquet; do cat $package/package.json ; echo ; done
- name: Publish npm packages as latest
# Auth is via npm's trusted publishing: `id-token: write` above grants
@@ -174,7 +196,12 @@ jobs:
# so no NPM_TOKEN is needed. `--provenance` attaches the same OIDC
# token to a provenance attestation on each tarball.
# The trailing slash on $package/ changes it to publishing the directory.
# `pnpm-pacquet` is the `@pnpm/pacquet` scoped alias mirror —
# same shim and same `@pacquet/<plat>-<arch>` optional deps as the
# unscoped `pacquet`. Published alongside so users can adopt
# either name; the per-platform binary sub-packages stay under
# the `@pacquet/` scope (no need to mirror those).
run: |
for package in pacquet/npm/pacquet*; do
for package in pacquet/npm/pacquet* pacquet/npm/pnpm-pacquet; do
pnpm publish "$package/" --tag latest --access public --provenance --no-git-checks
done

View File

@@ -0,0 +1,206 @@
name: Release @pnpm/pnpr
# Manual-trigger only. Type the version to publish — the workflow patches
# registry/npm/pnpr/package.json with it before generating per-platform
# packages and publishing. No git tag is created and no GitHub release
# asset is uploaded; npm is the authoritative artifact store.
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g. 0.2.3 or 0.2.3-rc.1)'
required: true
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
build:
permissions:
contents: read
id-token: write # needed for actions/attest-build-provenance
attestations: write
strategy:
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
code-target: win32-x64
- os: windows-latest
target: aarch64-pc-windows-msvc
code-target: win32-arm64
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
code-target: linux-x64
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
code-target: linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
code-target: darwin-x64
- os: macos-latest
target: aarch64-apple-darwin
code-target: darwin-arm64
name: Package ${{ matrix.code-target }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install cross
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1 # v2.78.1
with:
tool: cross
- name: Cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: release-${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Validate version input
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid version: '$VERSION' (expected semver like 0.2.3 or 0.2.3-rc.1)"
exit 1
fi
- name: Inject version into registry/crates/pnpm-registry/Cargo.toml
# `pnpm-registry --version` reads CARGO_PKG_VERSION via clap's
# derive `version` attribute. Patch the crate's `version = "..."`
# field so the binary built for the matrix leg reports the
# version we're about to publish.
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
file=registry/crates/pnpm-registry/Cargo.toml
perl -i -pe 's/^(version\s*=\s*)"[^"]*"/$1"$ENV{VERSION}"/' "$file"
grep -E '^version\s*=\s*"'"$VERSION"'"' "$file"
- name: Build with cross
run: cross build -p pnpm-registry --bin pnpm-registry --release --target=${{ matrix.target }}
# The binary is zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
# The Rust crate is `pnpm-registry`, but the npm wrapper exposes
# the command as `pnpr` — rename here so the archive that
# generate-packages.mjs picks up is already named `pnpr-<target>`.
- name: Archive Binary
if: runner.os == 'Windows'
shell: bash
run: |
BIN_NAME=pnpr-${{ matrix.code-target }}
mv target/${{ matrix.target }}/release/pnpm-registry.exe $BIN_NAME.exe
7z a $BIN_NAME.zip $BIN_NAME.exe
# The binary is zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
- name: Archive Binary
if: runner.os != 'Windows'
run: |
BIN_NAME=pnpr-${{ matrix.code-target }}
mv target/${{ matrix.target }}/release/pnpm-registry $BIN_NAME
tar czf $BIN_NAME.tar.gz $BIN_NAME
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
pnpr-${{ matrix.code-target }}*
- name: Upload Binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
if-no-files-found: error
name: binaries-${{ matrix.code-target }}
path: |
*.zip
*.tar.gz
publish:
name: Publish
runs-on: ubuntu-latest
permissions:
# Required for npm provenance attestations via OIDC.
id-token: write
needs:
- build
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Validate version input
env:
VERSION: ${{ inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid version: '$VERSION' (expected semver like 0.2.3 or 0.2.3-rc.1)"
exit 1
fi
- name: Inject version into registry/npm/pnpr/package.json
# The committed package.json has no `version` field; the version comes
# from the workflow_dispatch input. Patching it here means
# generate-packages.mjs (which copies the version into each
# per-platform manifest) and `pnpm publish` (which reads from
# package.json) both see the right value.
env:
VERSION: ${{ inputs.version }}
run: |
jq --arg v "$VERSION" '.version = $v' registry/npm/pnpr/package.json > registry/npm/pnpr/package.json.tmp
mv registry/npm/pnpr/package.json.tmp registry/npm/pnpr/package.json
cat registry/npm/pnpr/package.json
- name: Install pnpm and Node
uses: pnpm/setup@b1cac37306e39c21283b9dd6cb0ac288fb35ba6b
with:
runtime: node@22
install: false
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-*
merge-multiple: true
- name: Unzip
uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28 # v1.0.0
with:
args: unzip -qq *.zip -d .
- name: Untar
run: ls *.gz | xargs -i tar xf {}
- name: Generate npm packages
run: |
node registry/npm/pnpr/scripts/generate-packages.mjs
cat registry/npm/pnpr/package.json
for package in registry/npm/pnpr*; do cat $package/package.json ; echo ; done
- name: Publish npm packages as latest
# Auth is via npm's trusted publishing: `id-token: write` above grants
# this job an OIDC token that pnpm/npm exchange with the registry,
# so no NPM_TOKEN is needed. `--provenance` attaches the same OIDC
# token to a provenance attestation on each tarball.
# The trailing slash on $package/ changes it to publishing the directory.
run: |
for package in registry/npm/pnpr*; do
pnpm publish "$package/" --tag latest --access public --provenance --no-git-checks
done

View File

@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: garnet-org/action@9e819143e63d6dda04bca2e90ac85e3cf0e5289d # v2
- uses: garnet-org/action@2b7fc9d79b54f551b43358c27424a36064b3e078 # v2
with:
api_token: ${{ secrets.GARNET_API_TOKEN }}
- name: Install pnpm and Node
@@ -97,7 +97,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
# `gh release create` could replace this, but the release pipeline is
# sensitive and softprops/action-gh-release is widely battle-tested.
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2.5.0 # zizmor: ignore[superfluous-actions]
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 # zizmor: ignore[superfluous-actions]
with:
draft: true
files: dist/*

View File

@@ -29,9 +29,6 @@ jobs:
runs-on: ${{ inputs.platform }}
env:
pnpm_config_verify_deps_before_run: false
steps:
- name: Configure Git
run: |
@@ -43,16 +40,13 @@ jobs:
with:
persist-credentials: false
- if: ${{ inputs.garnet }}
uses: garnet-org/action@9e819143e63d6dda04bca2e90ac85e3cf0e5289d # v2
uses: garnet-org/action@2b7fc9d79b54f551b43358c27424a36064b3e078 # v2
with:
api_token: ${{ secrets.GARNET_API_TOKEN }}
- name: Install pnpm and Node
uses: pnpm/setup@b1cac37306e39c21283b9dd6cb0ac288fb35ba6b
with:
install: false
runtime: node@${{ inputs.node }}
- name: pacquet install
run: pnx --config.minimum-release-age=0 pacquet install --frozen-lockfile --no-runtime
- name: Verify Node version
shell: bash
env:

View File

@@ -18,7 +18,7 @@ jobs:
environment: release
runs-on: ubuntu-latest
steps:
- uses: garnet-org/action@9e819143e63d6dda04bca2e90ac85e3cf0e5289d # v2
- uses: garnet-org/action@2b7fc9d79b54f551b43358c27424a36064b3e078 # v2
with:
api_token: ${{ secrets.GARNET_API_TOKEN }}
- name: Setup Node

View File

@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@a16621b09c6db4281f81a93cb393b05dcd7b7165 # v0.5.5
with:
# Fork PRs run with a read-only GITHUB_TOKEN, so SARIF upload to
# Code scanning would fail. In that case, run zizmor anyway and

View File

@@ -1,5 +1,42 @@
# @pnpm-private/updater
## 1100.0.15
### Patch Changes
- Updated dependencies [e8b3ae1]
- Updated dependencies [35d2355]
- @pnpm/workspace.projects-reader@1101.0.8
- @pnpm/types@1101.2.0
- @pnpm/lockfile.fs@1100.1.2
- @pnpm/workspace.workspace-manifest-reader@1100.0.5
## 1100.0.14
### Patch Changes
- @pnpm/workspace.projects-reader@1101.0.7
## 1100.0.13
### Patch Changes
- Updated dependencies [9cb48bb]
- Updated dependencies [64afc92]
- @pnpm/lockfile.fs@1100.1.1
- @pnpm/types@1101.1.1
- @pnpm/workspace.projects-reader@1101.0.6
- @pnpm/workspace.workspace-manifest-reader@1100.0.4
## 1100.0.12
### Patch Changes
- Updated dependencies [6e93f35]
- Updated dependencies [2a9bd89]
- @pnpm/lockfile.fs@1100.1.0
- @pnpm/workspace.projects-reader@1101.0.5
## 1100.0.11
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm-private/updater",
"version": "1100.0.11",
"version": "1100.0.15",
"private": true,
"type": "module",
"scripts": {

View File

@@ -17,7 +17,7 @@ When you change one side, do the equivalent change on the other in the same PR i
"User-visible" means anything that affects the CLI surface or the on-disk contract: command-line flags and defaults, environment-variable handling, lockfile/manifest/state-file format, error codes and messages, log emissions parsed by `@pnpm/cli.default-reporter`, store layout, hook semantics. Pure internal refactors, perf wins, and TS-only test cleanups don't need mirroring.
**Scope caveat:** pacquet currently only implements `install`. Resolution and every other command (`update`, `add`, `remove`, `publish`, `exec`, `run`, `dlx`, `audit`, etc.) live only in the TypeScript code, so changes there don't need a pacquet-side port yet — they're outside pacquet's current surface area. The parity rule will widen as pacquet ports more commands; check what pacquet exposes before deciding whether your change is in scope.
**Scope caveat:** pacquet's current surface area is the dependency-management commands — `install`, `add`, `update`, and `remove`. Every other command (`publish`, `exec`, `run`, `dlx`, `audit`, etc.) lives only in the TypeScript code, so changes there don't need a pacquet-side port yet. The parity rule will widen as pacquet ports more commands; check what pacquet exposes before deciding whether your change is in scope.
The pacquet-side obligation — pnpm is the source of truth, pacquet ports from it, never the other way around — is spelled out at [`pacquet/AGENTS.md`](./pacquet/AGENTS.md#the-cardinal-rule).
@@ -186,6 +186,24 @@ To ensure your code adheres to the style guide, run:
pnpm run lint
```
### Comments
Write code that explains itself. A reader should understand what a function does from its name, parameters, and types — not from prose above the call site.
Defaults:
- **Do not write a comment** that restates what the code already says. If renaming a variable, splitting a helper, or moving a check to a more obvious place would carry the information, do that instead.
- **Do not repeat documentation** at call sites that already lives on the callee. If the function has a JSDoc, the call site shouldn't re-explain what calling it does. Update the JSDoc once; let every call site benefit.
- **JSDoc is for the function's contract** — preconditions, postconditions, edge cases, why the function exists. Not for re-narrating the body.
- **Do not record past implementation shape, refactor history, or "the previous code did X" framing.** That's what `git log` and `git blame` are for. Describe the current contract — what the code is and what it guarantees — not what it replaced. Phrasings like "used to", "previously", "the original X", or a parenthetical naming a removed type belong in the commit message, not in the source.
Write a comment only when:
- The reason for the code is non-obvious from reading it (a hidden invariant, a workaround for a known bug, a deliberate exception to the surrounding pattern).
- The right name doesn't fit — e.g., a temporary technical constraint that's worth flagging but doesn't justify a new symbol.
Before adding a comment, ask: "Could I rename, restructure, or extract instead?" If yes, do that. The bar for prose-in-code is high; the bar for prose-that-restates-code is "don't."
## Common Gotchas
### Error Type Checking in Jest (TypeScript only)

537
Cargo.lock generated
View File

@@ -148,9 +148,9 @@ dependencies = [
[[package]]
name = "assert_cmd"
version = "2.2.1"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd"
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
dependencies = [
"anstyle",
"bstr",
@@ -217,6 +217,54 @@ dependencies = [
"fs_extra",
]
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -253,6 +301,19 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c"
dependencies = [
"base64 0.22.1",
"blowfish",
"getrandom 0.3.4",
"subtle",
"zeroize",
]
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -268,6 +329,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -291,6 +362,12 @@ version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -365,6 +442,16 @@ dependencies = [
"half",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.1"
@@ -1409,6 +1496,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "insta"
version = "1.47.2"
@@ -1668,6 +1764,12 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1727,6 +1829,12 @@ dependencies = [
"syn",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1971,6 +2079,37 @@ version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "pacquet-catalogs-config"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-catalogs-types",
"pacquet-workspace",
]
[[package]]
name = "pacquet-catalogs-protocol-parser"
version = "0.0.1"
dependencies = [
"pacquet-catalogs-types",
]
[[package]]
name = "pacquet-catalogs-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-catalogs-protocol-parser",
"pacquet-catalogs-types",
]
[[package]]
name = "pacquet-catalogs-types"
version = "0.0.1"
[[package]]
name = "pacquet-cli"
version = "0.0.1"
@@ -2012,6 +2151,7 @@ version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-fs",
"pipe-trait",
"rayon",
"serde_json",
@@ -2028,6 +2168,7 @@ dependencies = [
"indexmap",
"libc",
"miette 7.6.0",
"node-semver",
"pacquet-network",
"pacquet-package-is-installable",
"pacquet-patching",
@@ -2037,11 +2178,57 @@ dependencies = [
"pretty_assertions",
"serde",
"serde-saphyr",
"serde_json",
"smart-default",
"tempfile",
"tracing",
]
[[package]]
name = "pacquet-config-parse-overrides"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-catalogs-resolver",
"pacquet-catalogs-types",
"pacquet-resolving-parse-wanted-dependency",
]
[[package]]
name = "pacquet-crypto-hash"
version = "0.0.1"
dependencies = [
"sha2",
]
[[package]]
name = "pacquet-crypto-shasums-file"
version = "0.0.1"
dependencies = [
"base64 0.22.1",
"derive_more",
"miette 7.6.0",
"pacquet-network",
"pretty_assertions",
"reqwest",
"tokio",
]
[[package]]
name = "pacquet-deps-path"
version = "0.0.1"
dependencies = [
"pacquet-crypto-hash",
]
[[package]]
name = "pacquet-detect-libc"
version = "0.0.1"
dependencies = [
"pretty_assertions",
]
[[package]]
name = "pacquet-diagnostics"
version = "0.0.1"
@@ -2068,6 +2255,63 @@ dependencies = [
"tracing",
]
[[package]]
name = "pacquet-engine-runtime-bun-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-crypto-shasums-file",
"pacquet-lockfile",
"pacquet-network",
"pacquet-resolving-npm-resolver",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"ssri",
"tokio",
]
[[package]]
name = "pacquet-engine-runtime-deno-resolver"
version = "0.0.1"
dependencies = [
"base64 0.22.1",
"derive_more",
"miette 7.6.0",
"pacquet-lockfile",
"pacquet-network",
"pacquet-resolving-npm-resolver",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"ssri",
"tokio",
]
[[package]]
name = "pacquet-engine-runtime-node-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"node-semver",
"pacquet-crypto-shasums-file",
"pacquet-lockfile",
"pacquet-network",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"ssri",
"tokio",
]
[[package]]
name = "pacquet-executor"
version = "0.0.1"
@@ -2082,14 +2326,27 @@ dependencies = [
"tracing",
]
[[package]]
name = "pacquet-exportable-manifest"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-package-manifest",
"serde_json",
"tempfile",
]
[[package]]
name = "pacquet-fs"
version = "0.0.1"
dependencies = [
"derive_more",
"dunce",
"junction",
"miette 7.6.0",
"pathdiff",
"sha2",
"tempfile",
]
@@ -2120,6 +2377,7 @@ name = "pacquet-graph-hasher"
version = "0.0.1"
dependencies = [
"base64 0.22.1",
"pacquet-detect-libc",
"pretty_assertions",
"serde_json",
"sha2",
@@ -2148,6 +2406,7 @@ version = "0.0.1"
dependencies = [
"derive_more",
"node-semver",
"pacquet-crypto-hash",
"pacquet-diagnostics",
"pacquet-package-manifest",
"pipe-trait",
@@ -2161,6 +2420,42 @@ dependencies = [
"text-block-macros",
]
[[package]]
name = "pacquet-lockfile-preferred-versions"
version = "0.0.1"
dependencies = [
"node-semver",
"pacquet-lockfile",
"pacquet-package-manifest",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"serde_json",
"tempfile",
]
[[package]]
name = "pacquet-lockfile-verification"
version = "0.0.1"
dependencies = [
"chrono",
"derive_more",
"futures-util",
"insta",
"miette 7.6.0",
"pacquet-diagnostics",
"pacquet-lockfile",
"pacquet-reporter",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"serde",
"serde-saphyr",
"serde_json",
"sha2",
"ssri",
"tempfile",
"tokio",
]
[[package]]
name = "pacquet-micro-benchmark"
version = "0.0.0"
@@ -2190,6 +2485,7 @@ dependencies = [
"httpdate",
"indexmap",
"pacquet-diagnostics",
"pacquet-fs",
"pacquet-testing-utils",
"pathdiff",
"pipe-trait",
@@ -2231,6 +2527,7 @@ name = "pacquet-package-manager"
version = "0.0.1"
dependencies = [
"async-recursion",
"chrono",
"dashmap",
"derive_more",
"dunce",
@@ -2240,14 +2537,24 @@ dependencies = [
"insta",
"miette 7.6.0",
"node-semver",
"pacquet-catalogs-config",
"pacquet-catalogs-types",
"pacquet-cmd-shim",
"pacquet-config",
"pacquet-config-parse-overrides",
"pacquet-crypto-hash",
"pacquet-deps-path",
"pacquet-directory-fetcher",
"pacquet-engine-runtime-bun-resolver",
"pacquet-engine-runtime-deno-resolver",
"pacquet-engine-runtime-node-resolver",
"pacquet-executor",
"pacquet-fs",
"pacquet-git-fetcher",
"pacquet-graph-hasher",
"pacquet-lockfile",
"pacquet-lockfile-preferred-versions",
"pacquet-lockfile-verification",
"pacquet-modules-yaml",
"pacquet-network",
"pacquet-package-is-installable",
@@ -2257,6 +2564,13 @@ dependencies = [
"pacquet-registry",
"pacquet-registry-mock",
"pacquet-reporter",
"pacquet-resolving-default-resolver",
"pacquet-resolving-deps-resolver",
"pacquet-resolving-git-resolver",
"pacquet-resolving-local-resolver",
"pacquet-resolving-npm-resolver",
"pacquet-resolving-resolver-base",
"pacquet-resolving-tarball-resolver",
"pacquet-store-dir",
"pacquet-tarball",
"pacquet-testing-utils",
@@ -2305,6 +2619,7 @@ dependencies = [
"pretty_assertions",
"sha2",
"tempfile",
"text-block-macros",
]
[[package]]
@@ -2344,13 +2659,14 @@ version = "0.0.0"
dependencies = [
"assert_cmd",
"clap",
"home",
"pipe-trait",
"reqwest",
"serde",
"serde_json",
"sysinfo",
"tokio",
"which",
"walkdir",
]
[[package]]
@@ -2365,6 +2681,152 @@ dependencies = [
"serde_json",
]
[[package]]
name = "pacquet-resolving-default-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-lockfile",
"pacquet-resolving-resolver-base",
"ssri",
"tokio",
]
[[package]]
name = "pacquet-resolving-deps-resolver"
version = "0.0.1"
dependencies = [
"async-recursion",
"derive_more",
"futures-util",
"miette 7.6.0",
"node-semver",
"pacquet-catalogs-resolver",
"pacquet-catalogs-types",
"pacquet-deps-path",
"pacquet-lockfile",
"pacquet-package-manifest",
"pacquet-patching",
"pacquet-resolving-parse-wanted-dependency",
"pacquet-resolving-resolver-base",
"pipe-trait",
"pretty_assertions",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
name = "pacquet-resolving-git-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"node-semver",
"pacquet-lockfile",
"pacquet-network",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"reqwest",
"tokio",
"tracing",
]
[[package]]
name = "pacquet-resolving-jsr-specifier-parser"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
]
[[package]]
name = "pacquet-resolving-local-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"home",
"miette 7.6.0",
"pacquet-lockfile",
"pacquet-package-manifest",
"pacquet-resolving-default-resolver",
"pacquet-resolving-resolver-base",
"pacquet-testing-utils",
"pathdiff",
"pretty_assertions",
"serde_json",
"ssri",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "pacquet-resolving-npm-resolver"
version = "0.0.1"
dependencies = [
"chrono",
"dashmap",
"derive_more",
"futures-util",
"indexmap",
"miette 7.6.0",
"mockito",
"node-semver",
"pacquet-config",
"pacquet-lockfile",
"pacquet-network",
"pacquet-registry",
"pacquet-resolving-default-resolver",
"pacquet-resolving-jsr-specifier-parser",
"pacquet-resolving-local-resolver",
"pacquet-resolving-resolver-base",
"pacquet-workspace-range-resolver",
"pacquet-workspace-spec",
"pipe-trait",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"sha2",
"ssri",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "pacquet-resolving-parse-wanted-dependency"
version = "0.0.1"
[[package]]
name = "pacquet-resolving-resolver-base"
version = "0.0.1"
dependencies = [
"chrono",
"derive_more",
"pacquet-config",
"pacquet-lockfile",
"serde",
"serde_json",
"ssri",
"tokio",
]
[[package]]
name = "pacquet-resolving-tarball-resolver"
version = "0.0.1"
dependencies = [
"mockito",
"pacquet-lockfile",
"pacquet-network",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"reqwest",
"tokio",
]
[[package]]
name = "pacquet-store-dir"
version = "0.0.1"
@@ -2373,6 +2835,7 @@ dependencies = [
"derive_more",
"dunce",
"miette 7.6.0",
"pacquet-crypto-hash",
"pacquet-fs",
"pipe-trait",
"pretty_assertions",
@@ -2438,7 +2901,9 @@ version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-catalogs-types",
"pacquet-package-manifest",
"pathdiff",
"pretty_assertions",
"serde",
"serde-saphyr",
@@ -2446,6 +2911,17 @@ dependencies = [
"wax",
]
[[package]]
name = "pacquet-workspace-range-resolver"
version = "0.0.1"
dependencies = [
"node-semver",
]
[[package]]
name = "pacquet-workspace-spec"
version = "0.0.1"
[[package]]
name = "pacquet-workspace-state"
version = "0.0.1"
@@ -2556,6 +3032,38 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "pnpm-registry"
version = "0.0.1"
dependencies = [
"axum",
"base64 0.22.1",
"bcrypt",
"clap",
"derive_more",
"futures-util",
"getrandom 0.3.4",
"indexmap",
"miette 7.6.0",
"mockito",
"pacquet-network",
"pipe-trait",
"reqwest",
"rusqlite",
"serde",
"serde-saphyr",
"serde_json",
"sha2",
"ssri",
"tempfile",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"wax",
]
[[package]]
name = "pori"
version = "0.0.0"
@@ -3179,6 +3687,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3238,6 +3757,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -3597,6 +4126,7 @@ dependencies = [
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -3667,6 +4197,7 @@ dependencies = [
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]

View File

@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["pacquet/crates/*", "pacquet/tasks/*"]
members = ["pacquet/crates/*", "pacquet/tasks/*", "registry/crates/*"]
[workspace.package]
authors = ["Yagiz Nizipli <yagiz@nizipli.com"]
@@ -13,39 +13,77 @@ repository = "https://github.com/pnpm/pacquet"
[workspace.dependencies]
# Crates
pacquet-cli = { path = "pacquet/crates/cli" }
pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" }
pacquet-fs = { path = "pacquet/crates/fs" }
pacquet-registry = { path = "pacquet/crates/registry" }
pacquet-tarball = { path = "pacquet/crates/tarball" }
pacquet-testing-utils = { path = "pacquet/crates/testing-utils" }
pacquet-package-manifest = { path = "pacquet/crates/package-manifest" }
pacquet-package-manager = { path = "pacquet/crates/package-manager" }
pacquet-package-is-installable = { path = "pacquet/crates/package-is-installable" }
pacquet-lockfile = { path = "pacquet/crates/lockfile" }
pacquet-modules-yaml = { path = "pacquet/crates/modules-yaml" }
pacquet-network = { path = "pacquet/crates/network" }
pacquet-config = { path = "pacquet/crates/config" }
pacquet-executor = { path = "pacquet/crates/executor" }
pacquet-directory-fetcher = { path = "pacquet/crates/directory-fetcher" }
pacquet-git-fetcher = { path = "pacquet/crates/git-fetcher" }
pacquet-diagnostics = { path = "pacquet/crates/diagnostics" }
pacquet-graph-hasher = { path = "pacquet/crates/graph-hasher" }
pacquet-store-dir = { path = "pacquet/crates/store-dir" }
pacquet-reporter = { path = "pacquet/crates/reporter" }
pacquet-patching = { path = "pacquet/crates/patching" }
pacquet-real-hoist = { path = "pacquet/crates/real-hoist" }
pacquet-workspace = { path = "pacquet/crates/workspace" }
pacquet-workspace-state = { path = "pacquet/crates/workspace-state" }
pacquet-catalogs-config = { path = "pacquet/crates/catalogs-config" }
pacquet-catalogs-protocol-parser = { path = "pacquet/crates/catalogs-protocol-parser" }
pacquet-catalogs-resolver = { path = "pacquet/crates/catalogs-resolver" }
pacquet-catalogs-types = { path = "pacquet/crates/catalogs-types" }
pacquet-cli = { path = "pacquet/crates/cli" }
pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" }
pacquet-crypto-hash = { path = "pacquet/crates/crypto-hash" }
pacquet-crypto-shasums-file = { path = "pacquet/crates/crypto-shasums-file" }
pacquet-engine-runtime-bun-resolver = { path = "pacquet/crates/engine-runtime-bun-resolver" }
pacquet-engine-runtime-deno-resolver = { path = "pacquet/crates/engine-runtime-deno-resolver" }
pacquet-engine-runtime-node-resolver = { path = "pacquet/crates/engine-runtime-node-resolver" }
pacquet-fs = { path = "pacquet/crates/fs" }
pacquet-registry = { path = "pacquet/crates/registry" }
pacquet-tarball = { path = "pacquet/crates/tarball" }
pacquet-testing-utils = { path = "pacquet/crates/testing-utils" }
pacquet-package-manifest = { path = "pacquet/crates/package-manifest" }
pacquet-package-manager = { path = "pacquet/crates/package-manager" }
pacquet-package-is-installable = { path = "pacquet/crates/package-is-installable" }
pacquet-lockfile = { path = "pacquet/crates/lockfile" }
pacquet-lockfile-preferred-versions = { path = "pacquet/crates/lockfile-preferred-versions" }
pacquet-lockfile-verification = { path = "pacquet/crates/lockfile-verification" }
pacquet-modules-yaml = { path = "pacquet/crates/modules-yaml" }
pacquet-network = { path = "pacquet/crates/network" }
pacquet-config = { path = "pacquet/crates/config" }
pacquet-config-parse-overrides = { path = "pacquet/crates/config-parse-overrides" }
pacquet-executor = { path = "pacquet/crates/executor" }
pacquet-exportable-manifest = { path = "pacquet/crates/exportable-manifest" }
pacquet-directory-fetcher = { path = "pacquet/crates/directory-fetcher" }
pacquet-git-fetcher = { path = "pacquet/crates/git-fetcher" }
pacquet-deps-path = { path = "pacquet/crates/deps-path" }
pacquet-detect-libc = { path = "pacquet/crates/detect-libc" }
pacquet-diagnostics = { path = "pacquet/crates/diagnostics" }
pacquet-graph-hasher = { path = "pacquet/crates/graph-hasher" }
pacquet-store-dir = { path = "pacquet/crates/store-dir" }
pacquet-reporter = { path = "pacquet/crates/reporter" }
pacquet-patching = { path = "pacquet/crates/patching" }
pacquet-real-hoist = { path = "pacquet/crates/real-hoist" }
pacquet-resolving-default-resolver = { path = "pacquet/crates/resolving-default-resolver" }
pacquet-resolving-deps-resolver = { path = "pacquet/crates/resolving-deps-resolver" }
pacquet-resolving-git-resolver = { path = "pacquet/crates/resolving-git-resolver" }
pacquet-resolving-jsr-specifier-parser = { path = "pacquet/crates/resolving-jsr-specifier-parser" }
pacquet-resolving-local-resolver = { path = "pacquet/crates/resolving-local-resolver" }
pacquet-resolving-npm-resolver = { path = "pacquet/crates/resolving-npm-resolver" }
pacquet-resolving-parse-wanted-dependency = { path = "pacquet/crates/resolving-parse-wanted-dependency" }
pacquet-resolving-resolver-base = { path = "pacquet/crates/resolving-resolver-base" }
pacquet-resolving-tarball-resolver = { path = "pacquet/crates/resolving-tarball-resolver" }
pacquet-workspace = { path = "pacquet/crates/workspace" }
pacquet-workspace-range-resolver = { path = "pacquet/crates/workspace-range-resolver" }
pacquet-workspace-spec = { path = "pacquet/crates/workspace-spec" }
pacquet-workspace-state = { path = "pacquet/crates/workspace-state" }
# Tasks
pacquet-registry-mock = { path = "pacquet/tasks/registry-mock" }
# Registry (sibling project — pnpm-compatible registry server)
pnpm-registry = { path = "registry/crates/pnpm-registry", version = "0.0.1" }
# Dependencies
async-recursion = { version = "1.1.1" }
axum = { version = "0.8.7", default-features = false, features = [
"http1",
"tokio",
"json",
"matched-path",
"original-uri",
] }
clap = { version = "4", features = ["derive", "string"] }
command-extra = { version = "1.0.0" }
base64 = { version = "0.22.1" }
bcrypt = { version = "0.17.1" }
chrono = { version = "0.4.44", default-features = false, features = ["clock"] }
dashmap = { version = "6.1.0" }
derive_more = { version = "2.1.1", features = ["full"] }
diffy = { version = "0.5.0" }
@@ -58,6 +96,7 @@ insta = { version = "1.47.2", features = ["yaml", "glob", "walkdir"] }
itertools = { version = "0.14.0" }
futures-util = { version = "0.3.32" }
gethostname = { version = "1" }
getrandom = { version = "0.3.4" }
miette = { version = "7.6.0", features = ["fancy"] }
num_cpus = { version = "1.17.0" }
os_display = { version = "0.1.4" }
@@ -89,9 +128,11 @@ strum = { version = "0.28.0", features = ["derive"] }
sysinfo = { version = "0.39.1" }
tar = { version = "0.4.45" }
text-block-macros = { version = "0.2.0" }
tower = { version = "0.5.3" }
tower-http = { version = "0.6.8", features = ["trace"] }
tracing = { version = "0.1.44" }
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "fs", "io-util", "net", "signal", "sync"] }
walkdir = { version = "2.5.0" }
wax = { version = "0.7.0" }
which = { version = "8.0.2" }
@@ -99,8 +140,7 @@ zip = { version = "8", default-features = false, features = ["def
zune-inflate = { version = "0.2.54" }
# Dev dependencies
assert_cmd = { version = "2.2.1" }
chrono = { version = "0.4.44", default-features = false, features = ["clock"] }
assert_cmd = { version = "2.2.2" }
criterion = { version = "0.8.2", features = ["async_tokio"] }
pretty_assertions = { version = "1.4.1" }
project-root = { version = "0.2.2" }
@@ -110,6 +150,25 @@ mockito = { version = "1.7.2" }
[workspace.metadata.workspaces]
allow_branch = "main"
# Declares the `dylint_lib = "perfectionist"` cfg used by the
# `cfg_attr(dylint_lib = "perfectionist", feature(register_tool))` /
# `register_tool(perfectionist)` lines at each pacquet crate's root.
# Those `cfg_attr`s register the perfectionist tool name under dylint's
# nightly toolchain (so `#[expect(perfectionist::lint, reason = "...")]`
# at use sites compiles cleanly without a wrapper); this `check-cfg`
# entry tells stable `cargo check` that the cfg name is expected even
# though it's never set off-dylint.
[workspace.lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(dylint_lib, values("perfectionist"))'] }
[workspace.lints.clippy]
clone_on_ref_ptr = "warn"
if_then_some_else_none = "warn"
needless_collect = "warn"
or_fun_call = "warn"
redundant_clone = "warn"
unnecessary_lazy_evaluations = "warn"
[profile.release]
opt-level = 3
lto = "fat"

View File

@@ -19,6 +19,15 @@ packages:
- '!workspace-has-shared-npm-shrinkwrap-json'
sharedWorkspaceLockfile: false
# The fixture lockfiles are pinned to packages from the local registry-mock
# (e.g. `@pnpm.e2e/*`). The v11 default of `minimumReleaseAge: 1440` would
# spin up the lockfile verifier here, which can't reach the mock from this
# install context and rejects the entries as un-checkable. Disable the
# policy for fixture installs — they're test scaffolding, not a real
# project. The actual minimumReleaseAge code paths are covered by the
# unit and e2e tests in their own packages.
minimumReleaseAge: 0
catalog:
# Used in has-outdated-deps-using-catalog-protocol fixture.
is-negative: ^1.0.0

View File

@@ -101,6 +101,16 @@ declare module '@pnpm/patch-package/dist/applyPatches.js' {
export function applyPatch (opts: any): boolean
}
declare module '@pnpm/patch-package/dist/patch/parse.js' {
export interface PatchFilePart {
type: 'file deletion' | 'file creation' | 'patch' | 'mode change' | 'rename'
path?: string
fromPath?: string
toPath?: string
}
export function parsePatchFile (file: string): PatchFilePart[]
}
declare module 'ramda/src/map' {
function map <K extends string | number | symbol, V, U> (fn: (x: V) => U, obj: Record<K, V>): Record<K, U>
export = map

View File

@@ -1,5 +1,32 @@
# @pnpm/assert-project
## 1100.0.11
### Patch Changes
- Updated dependencies [35d2355]
- @pnpm/types@1101.2.0
- @pnpm/installing.modules-yaml@1100.0.6
- @pnpm/lockfile.types@1100.0.8
- @pnpm/assert-store@1100.0.11
## 1100.0.10
### Patch Changes
- Updated dependencies [64afc92]
- @pnpm/types@1101.1.1
- @pnpm/lockfile.types@1100.0.7
- @pnpm/installing.modules-yaml@1100.0.5
- @pnpm/assert-store@1100.0.10
## 1100.0.9
### Patch Changes
- @pnpm/lockfile.types@1100.0.6
- @pnpm/assert-store@1100.0.9
## 1100.0.8
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@pnpm/assert-project",
"description": "Utils for testing projects that use pnpm",
"version": "1100.0.8",
"version": "1100.0.11",
"author": {
"name": "Zoltan Kochan",
"email": "z@kochan.io",

View File

@@ -1,5 +1,23 @@
# @pnpm/assert-store
## 1100.0.11
### Patch Changes
- @pnpm/store.cafs@1100.1.7
## 1100.0.10
### Patch Changes
- @pnpm/store.cafs@1100.1.6
## 1100.0.9
### Patch Changes
- @pnpm/store.cafs@1100.1.5
## 1100.0.8
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@pnpm/assert-store",
"description": "Utils for testing pnpm store",
"version": "1100.0.8",
"version": "1100.0.11",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},

View File

@@ -1,5 +1,25 @@
# @pnpm/jest-config
## 1100.0.11
### Patch Changes
- Updated dependencies [aa6149d]
- @pnpm/worker@1100.1.8
- @pnpm/testing.registry-mock@1100.0.1
## 1100.0.10
### Patch Changes
- @pnpm/worker@1100.1.7
## 1100.0.9
### Patch Changes
- @pnpm/worker@1100.1.6
## 1100.0.8
### Patch Changes

View File

@@ -1,16 +1,19 @@
{
"name": "@pnpm/jest-config",
"version": "1100.0.8",
"version": "1100.0.11",
"private": true,
"main": "jest-preset.js",
"type": "module",
"dependencies": {
"@babel/core": "catalog:",
"@babel/plugin-transform-explicit-resource-management": "catalog:",
"@pnpm/pnpr": "catalog:",
"@pnpm/registry-mock": "catalog:",
"@pnpm/testing.registry-mock": "workspace:*",
"@pnpm/worker": "workspace:*",
"amaro": "catalog:",
"get-port": "catalog:",
"read-yaml-file": "catalog:",
"tree-kill": "catalog:"
},
"devDependencies": {

View File

@@ -1,23 +1,50 @@
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { scheduler } from 'node:timers/promises'
import { promisify } from 'node:util'
import getPort from 'get-port'
import { promisify } from 'util'
import { readYamlFileSync } from 'read-yaml-file'
import treeKill from 'tree-kill'
const kill = promisify(treeKill)
const require = createRequire(import.meta.url)
export default async () => {
if (!process.env.PNPM_REGISTRY_MOCK_PORT) {
process.env.PNPM_REGISTRY_MOCK_PORT = (await getPort({ from: 7700, to: 7800 })).toString()
}
const { start, prepare } = await import('@pnpm/registry-mock')
// We still call `prepare()` from `@pnpm/registry-mock`: it copies
// the read-only fixture `storage-cache` into a tempy directory
// and writes `registry/runtime-config-${port}.yaml` with the
// tempy path under `storage:`. That yaml is what
// `locations.storage()` reads when `getIntegrity` (also from
// registry-mock) is called from tests. We just don't launch
// verdaccio against it — we launch pnpm-registry instead.
const { prepare, REGISTRY_MOCK_CREDENTIALS } = await import('@pnpm/registry-mock')
const { addUser } = await import('@pnpm/testing.registry-mock')
prepare()
const server = start({
// Verdaccio stopped working properly on Node.js 22.
// You can test the issue by running:
// pnpm --filter=core run test test/install/auth.ts
useNodeVersion: '20.16.0',
stdio: 'inherit',
listen: process.env.PNPM_REGISTRY_MOCK_PORT,
})
const storage = readStoragePath(process.env.PNPM_REGISTRY_MOCK_PORT)
const bin = resolvePnpmRegistryBin()
const server = spawn(
bin,
[
'--listen', `127.0.0.1:${process.env.PNPM_REGISTRY_MOCK_PORT}`,
'--storage', storage,
'--upstream', process.env.PNPM_REGISTRY_MOCK_UPLINK ?? 'https://registry.npmjs.org',
'--public-url', `http://localhost:${process.env.PNPM_REGISTRY_MOCK_PORT}`,
// Match registry-mock's verdaccio config: a one-year TTL so
// the fixture packuments (mtime: whenever the npm tarball was
// built) never look stale and never trigger a re-fetch to
// npmjs.org that would 404.
'--packument-ttl-secs', '31536000',
],
{ stdio: 'inherit' }
)
let killed = false
server.on('error', (err) => {
console.log(err)
@@ -33,14 +60,9 @@ export default async () => {
return kill(server.pid)
}
// Verdaccio can take a bit of time to come online on Windows and during its
// first startup. Some tests will fail immediately if they begin running
// before Verdaccio starts. Wait for Verdaccio to become online before running
// any tests.
await waitForServerOnline()
// Register the test user and store the auth token for bearer-based tests
const { addUser, REGISTRY_MOCK_CREDENTIALS } = await import('@pnpm/registry-mock')
const { token } = await addUser({
username: REGISTRY_MOCK_CREDENTIALS.username,
password: REGISTRY_MOCK_CREDENTIALS.password,
@@ -49,7 +71,53 @@ export default async () => {
process.env.REGISTRY_MOCK_TOKEN = token
}
const UNUSUAL_VERDACCIO_STARTUP_THRESHOLD = 15 // seconds
/**
* Read the `storage:` path that `@pnpm/registry-mock`'s `prepare()`
* just wrote into the runtime-config yaml. We can't import
* `locations.storage` from the registry-mock package — it isn't
* re-exported from its `index.ts` — but the file path is stable.
*/
function readStoragePath (port) {
const configPath = require.resolve(
`@pnpm/registry-mock/registry/runtime-config-${port}.yaml`
)
const { storage } = readYamlFileSync(configPath)
return storage
}
/**
* Locate the `pnpm-registry` binary. Lookup order:
*
* 1. `PNPM_REGISTRY_BIN` env var override (escape hatch for local
* Rust work — point it at `target/release/pnpm-registry` to test
* in-progress changes to the registry crate).
* 2. The platform binary that `pnpm install` pulled in as an
* optionalDependency of `@pnpm/pnpr` — i.e.
* `@pnpm/pnpr.<platform>-<arch>/pnpr[.exe]`. The resolution goes
* through the `@pnpm/pnpr` wrapper's path because the platform
* sub-package lives in the wrapper's own `node_modules`, not
* anywhere on the parent chain of this file.
*/
function resolvePnpmRegistryBin () {
if (process.env.PNPM_REGISTRY_BIN) {
return process.env.PNPM_REGISTRY_BIN
}
const ext = process.platform === 'win32' ? '.exe' : ''
const platformPkg = `@pnpm/pnpr.${process.platform}-${process.arch}`
try {
const wrapperRequire = createRequire(require.resolve('@pnpm/pnpr/bin/pnpr'))
return wrapperRequire.resolve(`${platformPkg}/pnpr${ext}`)
} catch {
throw new Error(
`pnpm-registry binary not found. The test suite expects ${platformPkg} ` +
'to be installed (it ships as an optionalDependency of @pnpm/pnpr — ' +
'run `pnpm install` at the repo root to pull it in), or set ' +
'PNPM_REGISTRY_BIN to an absolute path to a locally-built binary.'
)
}
}
const UNUSUAL_REGISTRY_STARTUP_THRESHOLD = 15 // seconds
async function waitForServerOnline () {
const start = performance.now()
@@ -59,17 +127,17 @@ async function waitForServerOnline () {
await fetch(`http://localhost:${process.env.PNPM_REGISTRY_MOCK_PORT}`, { method: 'HEAD' })
const totalWait = (performance.now() - start) / 1000
if (totalWait > UNUSUAL_VERDACCIO_STARTUP_THRESHOLD) {
console.warn(`Verdaccio required an unusually long amount of time to start: ${totalWait} seconds`)
if (totalWait > UNUSUAL_REGISTRY_STARTUP_THRESHOLD) {
console.warn(`pnpm-registry required an unusually long amount of time to start: ${totalWait} seconds`)
}
return
} catch (err) {
// If the Verdaccio process hasn't begun listening yet, attempts to
// If pnpm-registry hasn't begun listening yet, attempts to
// connect to the unbound port should throw ECONNREFUSED. If a different
// error is observed, throw an error.
if (err?.cause?.code !== 'ECONNREFUSED') {
throw new Error('Failed to bring Verdaccio online:', { cause: err })
throw new Error('Failed to bring pnpm-registry online:', { cause: err })
}
await scheduler.wait(delay)
@@ -77,7 +145,7 @@ async function waitForServerOnline () {
}
const totalWait = (performance.now() - start) / 1000
throw new Error(`Verdaccio did not come online after waiting ${totalWait} seconds`)
throw new Error(`pnpm-registry did not come online after waiting ${totalWait} seconds`)
}
function *exponentialBackoff (attempts = 15, base = 1.5, initialWait = 100) {
@@ -85,3 +153,4 @@ function *exponentialBackoff (attempts = 15, base = 1.5, initialWait = 100) {
yield initialWait * Math.pow(base, i)
}
}

View File

@@ -1,5 +1,27 @@
# @pnpm/prepare
## 1100.0.11
### Patch Changes
- Updated dependencies [35d2355]
- @pnpm/types@1101.2.0
- @pnpm/assert-project@1100.0.11
## 1100.0.10
### Patch Changes
- Updated dependencies [64afc92]
- @pnpm/types@1101.1.1
- @pnpm/assert-project@1100.0.10
## 1100.0.9
### Patch Changes
- @pnpm/assert-project@1100.0.9
## 1100.0.8
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/prepare",
"version": "1100.0.8",
"version": "1100.0.11",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"type": "module",

View File

@@ -1,5 +1,32 @@
# @pnpm/scripts
## 1100.0.11
### Patch Changes
- Updated dependencies [e8b3ae1]
- @pnpm/workspace.projects-reader@1101.0.8
- @pnpm/workspace.workspace-manifest-reader@1100.0.5
## 1100.0.10
### Patch Changes
- @pnpm/workspace.projects-reader@1101.0.7
## 1100.0.9
### Patch Changes
- @pnpm/workspace.projects-reader@1101.0.6
- @pnpm/workspace.workspace-manifest-reader@1100.0.4
## 1100.0.8
### Patch Changes
- @pnpm/workspace.projects-reader@1101.0.5
## 1100.0.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/scripts",
"version": "1100.0.7",
"version": "1100.0.11",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,30 @@
# @pnpm/agent.client
## 1.0.8
### Patch Changes
- Updated dependencies [aa6149d]
- @pnpm/worker@1100.1.8
- @pnpm/lockfile.types@1100.0.8
- @pnpm/store.cafs@1100.1.7
## 1.0.7
### Patch Changes
- @pnpm/lockfile.types@1100.0.7
- @pnpm/store.cafs@1100.1.6
- @pnpm/worker@1100.1.7
## 1.0.6
### Patch Changes
- @pnpm/lockfile.types@1100.0.6
- @pnpm/store.cafs@1100.1.5
- @pnpm/worker@1100.1.6
## 1.0.5
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/agent.client",
"version": "1.0.5",
"version": "1.0.8",
"description": "Client for pnpm agent server — sends store state, receives resolved lockfile and missing files",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,70 @@
# pnpm-agent
## 0.0.19
### Patch Changes
- Updated dependencies [aa6149d]
- Updated dependencies [35d2355]
- @pnpm/installing.deps-installer@1101.5.0
- @pnpm/types@1101.2.0
- @pnpm/installing.client@1100.2.3
- @pnpm/lockfile.fs@1100.1.2
- @pnpm/lockfile.types@1100.0.8
- @pnpm/store.cafs@1100.1.7
- @pnpm/store.controller@1101.0.9
## 0.0.18
### Patch Changes
- Updated dependencies [212315d]
- @pnpm/installing.deps-installer@1101.4.0
- @pnpm/installing.client@1100.2.2
- @pnpm/store.controller@1101.0.8
## 0.0.17
### Patch Changes
- @pnpm/installing.client@1100.2.1
- @pnpm/store.controller@1101.0.8
- @pnpm/installing.deps-installer@1101.3.1
## 0.0.16
### Patch Changes
- Updated dependencies [9cb48bb]
- Updated dependencies [1627943]
- Updated dependencies [b206a15]
- Updated dependencies [64afc92]
- @pnpm/lockfile.fs@1100.1.1
- @pnpm/installing.client@1100.2.0
- @pnpm/installing.deps-installer@1101.3.0
- @pnpm/types@1101.1.1
- @pnpm/store.controller@1101.0.8
- @pnpm/lockfile.types@1100.0.7
- @pnpm/store.cafs@1100.1.6
## 0.0.15
### Patch Changes
- Updated dependencies [4195766]
- Updated dependencies [31538bf]
- Updated dependencies [6e93f35]
- Updated dependencies [3ddde2b]
- Updated dependencies [4a79336]
- Updated dependencies [2a9bd89]
- Updated dependencies [31538bf]
- @pnpm/installing.client@1100.1.0
- @pnpm/installing.deps-installer@1101.2.0
- @pnpm/lockfile.fs@1100.1.0
- @pnpm/lockfile.types@1100.0.6
- @pnpm/store.controller@1101.0.7
- @pnpm/store.cafs@1100.1.5
## 0.0.14
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "pnpm-agent",
"version": "0.0.14",
"version": "0.0.19",
"description": "pnpm agent server for server-side resolution and store-aware downloads",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,54 @@
# @pnpm/auth.commands
## 1100.1.2
### Patch Changes
- ae21758: Refactor the dist-tag-add and login (classic adduser) handlers to delegate their PUTs to a new shared package `@pnpm/registry-access.client`. Downstream tests in this monorepo now use these helpers (via `@pnpm/testing.registry-mock`) instead of `addDistTag` / `addUser` from `@pnpm/registry-mock`, which relied on the unmaintained `anonymous-npm-registry-client`.
- Updated dependencies [a23956e]
- Updated dependencies [35d2355]
- @pnpm/config.reader@1101.4.1
- @pnpm/cli.utils@1101.0.8
- @pnpm/network.fetch@1100.0.7
- @pnpm/registry-access.client@1100.0.1
## 1100.1.1
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
- @pnpm/cli.utils@1101.0.7
## 1100.1.0
### Minor Changes
- 56f3851: Implement the documented `pnpm login --scope <scope>` flag. The scope is normalized (a leading `@` is added if missing; blank values are ignored) and an `@<scope>:registry=<registry>` mapping is written to the pnpm auth file alongside the auth token. Subsequent installs of `@<scope>/*` packages then route to the chosen registry. Previously `pnpm login --scope foo` errored with `Unknown option: 'scope'` despite the flag being listed in the online documentation [#11716](https://github.com/pnpm/pnpm/issues/11716).
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [d1b340f]
- @pnpm/config.reader@1101.3.3
- @pnpm/cli.utils@1101.0.6
- @pnpm/network.fetch@1100.0.6
## 1100.0.14
### Patch Changes
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/config.reader@1101.3.2
- @pnpm/network.fetch@1100.0.5
- @pnpm/cli.utils@1101.0.5
## 1100.0.13
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/auth.commands",
"version": "1100.0.13",
"version": "1100.1.2",
"description": "Commands for authentication with npm registries",
"keywords": [
"pnpm",
@@ -37,6 +37,7 @@
"@pnpm/error": "workspace:*",
"@pnpm/network.fetch": "workspace:*",
"@pnpm/network.web-auth": "workspace:*",
"@pnpm/registry-access.client": "workspace:*",
"enquirer": "catalog:",
"normalize-registry-url": "catalog:",
"read-ini-file": "catalog:",

View File

@@ -15,6 +15,7 @@ import {
type WebAuthFetchOptions,
withOtpHandling,
} from '@pnpm/network.web-auth'
import { addUser, AddUserHttpError, AddUserNoTokenError } from '@pnpm/registry-access.client'
import enquirer from 'enquirer'
import normalizeRegistryUrl from 'normalize-registry-url'
import { readIniFile } from 'read-ini-file'
@@ -24,7 +25,10 @@ import { writeIniFile } from 'write-ini-file'
import { getRegistryConfigKey, safeReadIniFile } from './shared.js'
export function rcOptionsTypes (): Record<string, unknown> {
return { registry: allTypes.registry }
return {
registry: allTypes.registry,
scope: allTypes.scope,
}
}
export function cliOptionsTypes (): Record<string, unknown> {
@@ -46,11 +50,15 @@ export function help (): string {
description: 'The registry to log in to',
name: '--registry <url>',
},
{
description: 'Associate an operation with a scope for a scoped registry. The scope-to-registry mapping is recorded so future installs in the same scope use the chosen registry.',
name: '--scope <scope>',
},
],
},
],
url: docsUrl('login'),
usages: ['pnpm login [--registry <url>]'],
usages: ['pnpm login [--registry <url>] [--scope <scope>]'],
})
}
@@ -65,6 +73,7 @@ export type LoginCommandOptions = Pick<Config,
| 'authConfig'
> & {
registry?: string
scope?: string
}
export async function handler (
@@ -101,19 +110,7 @@ export interface LoginFetchResponseHeaders {
export interface LoginFetchOptions {
method?: 'GET' | 'POST' | 'PUT'
headers?: {
accept: 'application/json'
'content-type': 'application/json'
// Q: Why does pnpm send this header unconditionally?
// A: This header doesn't say "I prefer web-based authentication";
// it only says "I am capable of web-based authentication".
// The npm CLI does the same:
// <https://github.com/npm/npm-registry-fetch/blob/844230f/lib/index.js#L196-L198>
'npm-auth-type': 'web'
'npm-otp'?: string
}
headers?: Record<string, string>
body?: string
retry?: {
factor?: number
@@ -202,11 +199,28 @@ export async function login ({ context = DEFAULT_CONTEXT, opts }: LoginParams):
const settings = await safeReadIniFile(readIniFile, configPath) as Record<string, unknown>
const registryConfigKey = getRegistryConfigKey(registry)
settings[`${registryConfigKey}:_authToken`] = token
// Persist the scope → registry mapping next to the auth token so subsequent
// installs for `@scope/*` packages route to this registry. `auth.ini` is
// already an allowed source of `@scope:registry=` (see config/reader).
const scopeKey = normalizeScope(opts.scope)
if (scopeKey != null) {
settings[`${scopeKey}:registry`] = registry
}
await writeIniFile(configPath, settings)
return `Logged in on ${registry}`
}
// `--scope foo` and `--scope @foo` should both produce `@foo`. Empty / blank
// values are treated as unset so accidental whitespace doesn't write a broken
// `@:registry=` entry.
function normalizeScope (scope: string | undefined): string | undefined {
if (scope == null) return undefined
const trimmed = scope.trim()
if (trimmed === '' || trimmed === '@') return undefined
return trimmed.startsWith('@') ? trimmed : `@${trimmed}`
}
interface WebLoginParams {
context: Pick<LoginContext, 'Date' | 'setTimeout' | 'createReadlineInterface' | 'fetch' | 'globalInfo' | 'globalWarn' | 'process'>
fetchOptions: WebAuthFetchOptions
@@ -291,45 +305,32 @@ async function classicLogin ({
throw new LoginMissingCredentialsError()
}
const loginUrl = new URL(`-/user/org.couchdb.user:${encodeURIComponent(username)}`, registry).href
const token = await withOtpHandling({
context,
fetchOptions,
operation: async (otp?: string) => {
const response = await fetch(loginUrl, {
method: 'PUT',
headers: {
'content-type': 'application/json',
accept: 'application/json',
'npm-auth-type': 'web',
// Conditionally include npm-otp: some HTTP implementations coerce
// `undefined` to the string "undefined", which would send a bad header
// on the initial attempt (before OTP is known).
...(otp != null ? { 'npm-otp': otp } : {}),
},
body: JSON.stringify({
_id: `org.couchdb.user:${username}`,
name: username,
try {
const result = await addUser({
username,
password,
email,
type: 'user',
}),
})
if (!response.ok) {
await throwIfOtpRequired(globalWarn, response)
const text = await response.text()
throw new ClassicLoginError(response.status, text)
otp,
registryUrl: registry,
fetch,
})
return result.token
} catch (err) {
if (err instanceof AddUserHttpError) {
if (err.status === 401 && err.responseHeaders.get('www-authenticate')?.includes('otp')) {
throw SyntheticOtpError.fromUnknownBody(globalWarn, err.responseJson)
}
throw new ClassicLoginError(err.status, err.responseText)
}
if (err instanceof AddUserNoTokenError) {
throw new LoginNoTokenError()
}
throw err
}
const body = await response.json() as { token?: string }
if (!body.token) {
throw new LoginNoTokenError()
}
return body.token
},
})
@@ -338,25 +339,6 @@ async function classicLogin ({
return token
}
/**
* Inspects a non-ok HTTP response for OTP requirements and throws an EOTP
* error when detected. This mirrors the behaviour of npm-registry-fetch,
* which checks the `www-authenticate` header for one-time password indicators.
*/
async function throwIfOtpRequired (globalWarn: LoginContext['globalWarn'], response: LoginFetchResponse): Promise<void> {
if (response.status !== 401) return
const wwwAuth = response.headers.get('www-authenticate')
if (!wwwAuth?.includes('otp')) return
let body: unknown
try {
body = await response.json()
} catch {}
throw SyntheticOtpError.fromUnknownBody(globalWarn, body)
}
class LoginNonInteractiveError extends PnpmError {
constructor () {
super('LOGIN_NON_INTERACTIVE', 'The login command requires an interactive terminal')

View File

@@ -141,6 +141,103 @@ describe('login', () => {
])
})
it('should persist a scope→registry mapping when --scope is provided', async () => {
let savedSettings: Record<string, unknown> = {}
const context = createMockContext({
globalInfo: jest.fn(),
readIniFile: async () => ({}),
writeIniFile: async (_configPath, settings) => {
savedSettings = settings
},
fetch: async url => {
if (url === 'https://my-org.example/-/v1/login') {
return createMockResponse({
ok: true,
status: 200,
json: {
loginUrl: 'https://my-org.example/auth/login',
doneUrl: 'https://my-org.example/auth/done',
},
})
}
if (url === 'https://my-org.example/auth/done') {
return createMockResponse({
ok: true,
status: 200,
json: { token: 'scoped-token' },
})
}
throw new Error(`Unexpected call to fetch: ${url}`)
},
})
// `--scope my-org` (no `@`) should be normalized to `@my-org` when written.
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://my-org.example', scope: 'my-org' }
const result = await login({ context, opts })
expect(result).toBe('Logged in on https://my-org.example/')
expect(savedSettings).toMatchObject({
'//my-org.example/:_authToken': 'scoped-token',
'@my-org:registry': 'https://my-org.example/',
})
})
it('should accept --scope with a leading @ and not double-prefix', async () => {
let savedSettings: Record<string, unknown> = {}
const context = createMockContext({
globalInfo: jest.fn(),
readIniFile: async () => ({}),
writeIniFile: async (_configPath, settings) => {
savedSettings = settings
},
fetch: async url => {
if (url === 'https://my-org.example/-/v1/login') {
return createMockResponse({
ok: true,
status: 200,
json: {
loginUrl: 'https://my-org.example/auth/login',
doneUrl: 'https://my-org.example/auth/done',
},
})
}
return createMockResponse({ ok: true, status: 200, json: { token: 'tok' } })
},
})
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://my-org.example', scope: '@my-org' }
await login({ context, opts })
expect(savedSettings['@my-org:registry']).toBe('https://my-org.example/')
expect(savedSettings['@@my-org:registry']).toBeUndefined()
})
it('should not write a scope mapping when --scope is omitted', async () => {
let savedSettings: Record<string, unknown> = {}
const context = createMockContext({
globalInfo: jest.fn(),
readIniFile: async () => ({}),
writeIniFile: async (_configPath, settings) => {
savedSettings = settings
},
fetch: async url => {
if (url === 'https://example.com/-/v1/login') {
return createMockResponse({
ok: true,
status: 200,
json: {
loginUrl: 'https://example.com/auth/login',
doneUrl: 'https://example.com/auth/done',
},
})
}
return createMockResponse({ ok: true, status: 200, json: { token: 'tok' } })
},
})
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://example.com' }
await login({ context, opts })
// No `@…:registry` key should be added when scope isn't passed.
for (const key of Object.keys(savedSettings)) {
expect(key.startsWith('@')).toBe(false)
}
})
it('should fall back to classic login when web login returns 404', async () => {
const fetchedUrls: string[] = []
const globalInfo = jest.fn()
@@ -266,10 +363,10 @@ describe('login', () => {
return createMockResponse({
ok: false,
status: 401,
json: {
text: JSON.stringify({
authUrl: 'https://example.org/auth/web',
doneUrl: 'https://example.org/auth/web/done',
},
}),
headers: { get: (name: string) => name === 'www-authenticate' ? 'OTP otp' : null },
})
}

View File

@@ -26,6 +26,9 @@
},
{
"path": "../../network/web-auth"
},
{
"path": "../../registry-access/client"
}
]
}

View File

@@ -1,52 +1,77 @@
# pnpm Benchmarks
Compares `pnpm install` performance between the current branch and `main`.
Compares `pnpm install` performance between the current branch (`HEAD`) and
`main`, across the six scenarios listed below.
This wrapper builds both pnpm revisions and runs hyperfine through the
shared Rust orchestrator at
[`pacquet/tasks/integrated-benchmark`](../pacquet/tasks/integrated-benchmark/),
so scenario / fixture / workspace / install-script / report generation
stay consistent with the pacquet benchmark.
## Prerequisites
- [hyperfine](https://github.com/sharkdp/hyperfine) — install via `brew install hyperfine`
- The current branch must be compiled (`pnpm run compile`)
- If providing a pre-existing main checkout path, it must also be compiled
- `cargo` (install Rust via [rustup](https://rustup.rs) if you don't have it).
- `hyperfine`, `pnpm`, `node`, `git` on `$PATH`.
## Usage
```sh
pnpm run compile
./benchmarks/bench.sh
```
If a git worktree with `main` already exists, the script finds and uses it automatically. Otherwise it creates one at `../.pnpm-bench-main` (a sibling of the repo), installs dependencies, and compiles.
The script:
You can also point to a specific checkout of main:
```sh
./benchmarks/bench.sh /path/to/main
```
1. Builds the `integrated-benchmark` binary in release mode.
2. Clones the current repo into the temp work-env once per revision
(`HEAD` and `main`) and runs `pnpm install && pnpm run compile-only`
in each to produce `pnpm/dist/pnpm.mjs`. `compile-only` skips the
`update-manifests` pass that the root `compile` script does — it
would rewrite tracked files and trigger a second install per
revision, neither of which the bench needs.
3. Runs hyperfine on each scenario with `--registry=npm` (hits
`registry.npmjs.org` directly, no proxy — same as before).
4. Writes a per-scenario `BENCHMARK_REPORT.md` / `.json` and a
consolidated `results.md` into the temp work-env. The path is printed
at the end of the run.
5. Emits `bencher-results.json` — a hyperfine-shaped file with one
result per scenario (the `@HEAD` revision only, `command` renamed to
the scenario name) that the `Benchmarks` GitHub Actions workflow
uploads to [Bencher](https://bencher.dev) for continuous tracking.
## Scenarios
| # | Name | Lockfile | Store + Cache | Description |
|---|---|---|---|---|
| 1 | Headless | ✔ frozen | warm | Repeat install with warm store |
| 2 | Re-resolution | ✔ + add dep | warm | Add a new dependency to an existing lockfile |
| 3 | Full resolution | ✗ | warm | Resolve everything from scratch with warm store and cache |
| 4 | Headless cold | ✔ frozen | cold | Typical CI install — fetch all packages with lockfile |
| 5 | Cold install | ✗ | cold | True cold start — nothing cached |
Slugs follow `<linker>.<action>.<cache state>.<store state>` so the
leading segment groups runs by linker mode. Today there are two
groups (`isolated-linker.*` and `gvs-linker.*`); future scenarios
will add `hoisted-linker.*` and `pnp-linker.*`.
All scenarios use `--ignore-scripts` and isolated store/cache directories per variant.
Every current scenario starts with `node_modules` wiped — "fresh"
names that target state; future variants that begin with a populated
`node_modules` will use a different action prefix.
## Output
| # | Slug | Lockfile | Cache | Store | Description |
|---|---|---|---|---|---|
| 1 | `isolated-linker.fresh-restore.hot-cache.hot-store` | ✔ frozen | hot | hot | Restore from lockfile with both directories hot (repeat-headless shape) |
| 2 | `isolated-linker.fresh-add-dep.hot-cache.hot-store` | ✔ + add dep | hot | hot | `pnpm add <dep>` against an existing lockfile |
| 3 | `isolated-linker.fresh-install.hot-cache.hot-store` | ✗ | hot | hot | Resolve from scratch with both directories hot |
| 4 | `isolated-linker.fresh-restore.cold-cache.cold-store` | ✔ frozen | cold | cold | Restore from lockfile with cold disks (typical CI shape) |
| 5 | `isolated-linker.fresh-install.cold-cache.cold-store` | ✗ | cold | cold | True cold start — no lockfile, nothing cached |
| 6 | `gvs-linker.fresh-restore.hot-cache.hot-store` | ✔ frozen | hot | hot + GVS | Frozen-lockfile restore with `enableGlobalVirtualStore: true`, pre-warmed GVS |
Results are printed to the terminal and saved as:
All scenarios use `--ignore-scripts` and isolated store/cache directories per revision.
- `results.md` — consolidated markdown table
- `<scenario>-main.json` / `<scenario>-branch.json` — raw hyperfine data
## Fixture
All files are written to a temp directory printed at the end of the run.
The fixture lives at [`fixture/`](./fixture/) — a synthetic
`package.json` with ~80 typical front-end dependencies, plus a committed
`pnpm-lock.yaml` (generated once with `pnpm install --lockfile-only`).
The lockfile is checked in so every CI run starts from the same
resolution graph regardless of registry drift.
## Configuration
Edit the variables at the top of `bench.sh`:
Environment variables read by `bench.sh`:
- `WARMUP` — number of warmup runs before timing (default: 1)
- `RUNS` — number of timed runs per benchmark (default: 10)

View File

@@ -1,290 +1,153 @@
#!/bin/bash
set -euo pipefail
# Benchmark script for pnpm install performance.
# Compares the current (active) branch against a baseline checkout of main.
# Thin wrapper around `pacquet/tasks/integrated-benchmark`. Builds two
# pnpm revisions (the current branch and `main`) and runs hyperfine for
# each of the six scenarios that used to live in this script.
#
# Prerequisites:
# - hyperfine (https://github.com/sharkdp/hyperfine)
# - The current branch must be compiled (pnpm run compile)
# Scenarios, registry choice, and runner behaviour are preserved exactly
# as before; the orchestration logic is shared with the pacquet bench.
#
# Usage:
# ./benchmarks/bench.sh [path-to-main-checkout]
# Prerequisites: cargo, hyperfine, pnpm, node, git.
#
# If no path is given, a git worktree for main is created automatically,
# dependencies are installed, and pnpm is compiled in it.
# Env vars: WARMUP (default 1), RUNS (default 10).
#
# Examples:
# pnpm run compile
# ./benchmarks/bench.sh
# ./benchmarks/bench.sh /Volumes/src/pnpm/pnpm/main
# Usage: ./benchmarks/bench.sh
BRANCH_DIR="$(cd "$(dirname "$0")/.." && pwd)"
if [ -n "${1:-}" ]; then
MAIN_DIR="$1"
else
# Look for an existing worktree that has main checked out
EXISTING=$(git -C "$BRANCH_DIR" worktree list --porcelain \
| awk '/^worktree /{wt=$2} /^branch refs\/heads\/main$/{print wt}')
if [ -n "$EXISTING" ]; then
MAIN_DIR="$EXISTING"
echo "── Using existing main worktree at $MAIN_DIR ──"
else
MAIN_DIR="$BRANCH_DIR/../.pnpm-bench-main"
echo "── Creating main worktree at $MAIN_DIR ──"
git -C "$BRANCH_DIR" worktree add "$MAIN_DIR" main
fi
cd "$MAIN_DIR"
echo "Installing dependencies..."
pnpm install
echo "Compiling..."
pnpm run compile
echo ""
cd "$BRANCH_DIR"
fi
BENCH_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pnpm-bench.XXXXXX")"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FIXTURE_DIR="$REPO_ROOT/benchmarks/fixture"
WARMUP="${WARMUP:-1}"
RUNS="${RUNS:-10}"
BENCH_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pnpm-bench.XXXXXX")"
# ── Per-variant configuration ─────────────────────────────────────────────
resolve_pnpm_bin() {
local dir="$1"
if [ -f "$dir/pnpm/dist/pnpm.mjs" ]; then
echo "$dir/pnpm/dist/pnpm.mjs"
else
echo "$dir/pnpm/dist/pnpm.cjs"
for tool in cargo hyperfine pnpm node git; do
if ! command -v "$tool" >/dev/null; then
echo "error: $tool not on PATH" >&2
exit 1
fi
}
done
VARIANTS=("main" "branch")
VARIANT_DIRS=("$MAIN_DIR" "$BRANCH_DIR")
VARIANT_BINS=("$(resolve_pnpm_bin "$MAIN_DIR")" "$(resolve_pnpm_bin "$BRANCH_DIR")")
VARIANT_PROJECTS=("$BENCH_DIR/project-main" "$BENCH_DIR/project-branch")
VARIANT_STORES=("$BENCH_DIR/store-main" "$BENCH_DIR/store-branch")
VARIANT_CACHES=("$BENCH_DIR/cache-main" "$BENCH_DIR/cache-branch")
echo "── Building integrated-benchmark ──"
cargo build --release --bin=integrated-benchmark --manifest-path "$REPO_ROOT/Cargo.toml"
BIN="$REPO_ROOT/target/release/integrated-benchmark"
# ── Validation ──────────────────────────────────────────────────────────────
if ! command -v hyperfine &>/dev/null; then
echo "error: hyperfine is required. Install via: brew install hyperfine" >&2
exit 1
# Ensure `pnpm@main` resolves locally. `actions/checkout` only creates a
# local ref for the branch it checked out; on a workflow_dispatch run from
# a non-main branch (or after the optional PR-head checkout in
# `benchmark.yml`) there's no `refs/heads/main` for `git rev-parse` to
# hit. Skip the fetch entirely when the local ref already exists, and
# let the fetch surface its real error if it fails.
if ! git -C "$REPO_ROOT" rev-parse --verify --quiet refs/heads/main >/dev/null; then
echo "── Fetching main into local ref ──"
git -C "$REPO_ROOT" fetch --no-tags origin main:main
fi
for bin in "${VARIANT_BINS[@]}"; do
if [ ! -f "$bin" ]; then
echo "error: compiled pnpm not found at $bin" >&2
echo "Run 'pnpm run compile' in both repos first." >&2
exit 1
# Scenario list: `slug:Display label`. The slug matches the
# orchestrator's `--scenario` value (the clap-derived kebab-case name
# from `BenchmarkScenario`). All six start with `node_modules` wiped
# — "Fresh" names that target state. "Isolated linker" names the
# `nodeLinker` mode; alternatives (`hoisted`, `pnp`) and populated-
# node_modules counterparts are reserved for future scenarios.
SCENARIOS=(
"isolated-linker.fresh-restore.hot-cache.hot-store:Isolated linker: fresh restore, hot cache + hot store"
"isolated-linker.fresh-add-dep.hot-cache.hot-store:Isolated linker: fresh add new dep, hot cache + hot store"
"isolated-linker.fresh-install.hot-cache.hot-store:Isolated linker: fresh install, hot cache + hot store"
"isolated-linker.fresh-restore.cold-cache.cold-store:Isolated linker: fresh restore, cold cache + cold store"
"isolated-linker.fresh-install.cold-cache.cold-store:Isolated linker: fresh install, cold cache + cold store"
"gvs-linker.fresh-restore.hot-cache.hot-store:GVS linker: fresh restore, hot cache + hot store"
)
# Pre-build both revisions once. Subsequent scenario invocations still
# re-enter the orchestrator's build step (sync_bench_repo + pnpm install
# + pnpm run compile), but `pnpm install` is a no-op on the populated
# node_modules and `tsgo --build` is incremental. `pnpm run bundle`
# (which produces pnpm/dist/pnpm.mjs) does run each time and is not
# incremental — accepted overhead in exchange for keeping the build
# path in one consistent place across pacquet and pnpm benches.
echo "── Pre-building pnpm revisions ──"
"$BIN" \
--pnpm-repository "$REPO_ROOT" \
--work-env "$BENCH_DIR/work-env" \
--build-only \
pnpm@HEAD pnpm@main
# Pull mean ± stddev for each variant out of a hyperfine JSON into one
# table cell. Falls back to "n/a" if jq isn't on PATH, the file is
# missing, or the target isn't present in the JSON.
read_cell() {
local target=$1
local json=$2
if ! command -v jq >/dev/null; then
echo "n/a"
return
fi
done
for i in "${!VARIANTS[@]}"; do
# Run --version from BENCH_DIR to avoid pnpm's automatic version switching
# based on a packageManager field in the current directory.
echo "${VARIANTS[$i]}: $(cd "$BENCH_DIR" && node "${VARIANT_BINS[$i]}" --version) (${VARIANT_DIRS[$i]})"
done
echo "workdir: $BENCH_DIR"
echo ""
# ── Project setup ───────────────────────────────────────────────────────────
# Each variant gets its own project directory with isolated store and cache
# so there is no shared state between them.
for i in "${!VARIANTS[@]}"; do
dir="${VARIANT_PROJECTS[$i]}"
mkdir -p "$dir" "${VARIANT_CACHES[$i]}"
cp "$BRANCH_DIR/benchmarks/fixture.package.json" "$dir/package.json"
printf "storeDir: %s\ncacheDir: %s\n" "${VARIANT_STORES[$i]}" "${VARIANT_CACHES[$i]}" > "$dir/pnpm-workspace.yaml"
done
# Keep a pristine copy of package.json for the peek benchmark
cp "$BRANCH_DIR/benchmarks/fixture.package.json" "$BENCH_DIR/original-package.json"
# ── Populate stores and caches ─────────────────────────────────────────────
# A full install populates both the content-addressable store and the
# registry metadata cache for each variant.
for i in "${!VARIANTS[@]}"; do
label="${VARIANTS[$i]}"
dir="${VARIANT_PROJECTS[$i]}"
bin="${VARIANT_BINS[$i]}"
echo "Populating store and cache for $label..."
rm -rf "$dir/node_modules" "$dir/pnpm-lock.yaml"
cd "$dir" && node "$bin" install --ignore-scripts --no-frozen-lockfile
if [ ! -f "$dir/pnpm-lock.yaml" ]; then
echo "error: pnpm-lock.yaml was not created for $label in $dir" >&2
exit 1
fi
cp "$dir/pnpm-lock.yaml" "$BENCH_DIR/saved-lockfile-$label.yaml"
done
# ── Helper ──────────────────────────────────────────────────────────────────
# run_bench <name> <prepare_template> <cmd_template>
#
# Templates use placeholders that are substituted per variant:
# {project} → project directory
# {bin} → compiled pnpm binary
# {store} → store directory
# {cache} → cache directory
# {lockfile} → saved lockfile path
run_bench() {
local bench_name=$1
local prepare_tpl=$2
local cmd_tpl=$3
for i in "${!VARIANTS[@]}"; do
local variant="${VARIANTS[$i]}"
local project="${VARIANT_PROJECTS[$i]}"
local bin="${VARIANT_BINS[$i]}"
local store="${VARIANT_STORES[$i]}"
local cache="${VARIANT_CACHES[$i]}"
local lockfile="$BENCH_DIR/saved-lockfile-$variant.yaml"
local prepare="$prepare_tpl"
prepare="${prepare//\{project\}/$project}"
prepare="${prepare//\{bin\}/$bin}"
prepare="${prepare//\{store\}/$store}"
prepare="${prepare//\{cache\}/$cache}"
prepare="${prepare//\{lockfile\}/$lockfile}"
local cmd="$cmd_tpl"
cmd="${cmd//\{project\}/$project}"
cmd="${cmd//\{bin\}/$bin}"
cmd="${cmd//\{store\}/$store}"
cmd="${cmd//\{cache\}/$cache}"
cmd="${cmd//\{lockfile\}/$lockfile}"
echo ""
echo " $variant:"
hyperfine \
--warmup "$WARMUP" \
--runs "$RUNS" \
--ignore-failure \
--prepare "$prepare" \
--command-name "$variant" \
"$cmd" \
--export-json "$BENCH_DIR/${bench_name}-${variant}.json" \
|| true
done
jq -r --arg t "$target" '
[.results[] | select(.command == $t)
| "\((.mean*1000|round)/1000)s ± \((.stddev*1000|round)/1000)s"]
| first // "n/a"
' "$json" 2>/dev/null || echo "n/a"
}
# ── Benchmark 1: Headless install ──────────────────────────────────────────
# Lockfile present, node_modules deleted, store and cache warm.
# This is the common "CI install" or "fresh clone + install" path.
results_md="$BENCH_DIR/results.md"
{
echo "# Benchmark Results"
echo
echo "| # | Scenario | main | HEAD |"
echo "|---|---|---|---|"
} > "$results_md"
echo ""
echo "━━━ Benchmark 1: Headless install (frozen lockfile, warm store+cache) ━━━"
run_bench "headless" \
"rm -rf {project}/node_modules && cp {lockfile} {project}/pnpm-lock.yaml" \
"cd {project} && node {bin} install --frozen-lockfile --ignore-scripts >/dev/null 2>&1"
# ── Benchmark 2: Re-resolution with existing lockfile ─────────────────────
# Lockfile present, add a new dependency to trigger re-resolution.
# Store and cache warm. This exercises the peekManifestFromStore path.
echo ""
echo "━━━ Benchmark 2: Re-resolution (add dep to existing lockfile, warm store+cache) ━━━"
run_bench "peek" \
"rm -rf {project}/node_modules && cp {lockfile} {project}/pnpm-lock.yaml && cp $BENCH_DIR/original-package.json {project}/package.json" \
"cd {project} && node {bin} add is-odd --ignore-scripts >/dev/null 2>&1"
# ── Benchmark 3: Full resolution (warm store+cache) ──────────────────────
# No lockfile, no node_modules, store and cache warm.
# Resolution runs for all packages using cached registry metadata.
echo ""
echo "━━━ Benchmark 3: Full resolution (no lockfile, warm store+cache) ━━━"
run_bench "nolockfile" \
"rm -rf {project}/node_modules {project}/pnpm-lock.yaml && cp $BENCH_DIR/original-package.json {project}/package.json" \
"cd {project} && node {bin} install --ignore-scripts --no-frozen-lockfile >/dev/null 2>&1"
# ── Benchmark 4: Headless cold (lockfile, no store, no cache) ─────────────
# Lockfile present, but store and cache are empty.
# This tests the fetch-from-registry + link path guided by a lockfile.
echo ""
echo "━━━ Benchmark 4: Headless install (frozen lockfile, cold store+cache) ━━━"
run_bench "headless-cold" \
"rm -rf {project}/node_modules {store} {cache} && cp {lockfile} {project}/pnpm-lock.yaml" \
"cd {project} && node {bin} install --frozen-lockfile --ignore-scripts >/dev/null 2>&1"
# ── Benchmark 5: Cold install (no store, no cache, no lockfile) ───────────
# Everything is deleted before each run. This is the true cold start.
echo ""
echo "━━━ Benchmark 5: Cold install (no store, no cache, no lockfile) ━━━"
run_bench "cold" \
"rm -rf {project}/node_modules {project}/pnpm-lock.yaml {store} {cache} && cp $BENCH_DIR/original-package.json {project}/package.json" \
"cd {project} && node {bin} install --ignore-scripts --no-frozen-lockfile >/dev/null 2>&1"
# ── Benchmark 6: GVS warm reinstall ───────────────────────────────────────
# Global virtual store enabled, GVS warm, node_modules deleted.
# This tests the reattach fast-path: all packages should be skipped
# (no fetch/import) because their GVS hash directories already exist.
echo ""
echo "━━━ Benchmark 6: GVS warm reinstall (frozen lockfile, warm global virtual store) ━━━"
# Set up separate GVS-enabled project directories per variant
GVS_PROJECTS=()
for i in "${!VARIANTS[@]}"; do
gvs_dir="$BENCH_DIR/project-gvs-${VARIANTS[$i]}"
mkdir -p "$gvs_dir"
cp "$BRANCH_DIR/benchmarks/fixture.package.json" "$gvs_dir/package.json"
printf "storeDir: %s\ncacheDir: %s\nenableGlobalVirtualStore: true\n" \
"${VARIANT_STORES[$i]}" "${VARIANT_CACHES[$i]}" > "$gvs_dir/pnpm-workspace.yaml"
GVS_PROJECTS+=("$gvs_dir")
# Warm the GVS with a full install
echo "Warming GVS for ${VARIANTS[$i]}..."
cd "$gvs_dir" && node "${VARIANT_BINS[$i]}" install --ignore-scripts --no-frozen-lockfile >/dev/null 2>&1
cp "$gvs_dir/pnpm-lock.yaml" "$BENCH_DIR/saved-lockfile-gvs-${VARIANTS[$i]}.yaml"
done
for i in "${!VARIANTS[@]}"; do
variant="${VARIANTS[$i]}"
gvs_project="${GVS_PROJECTS[$i]}"
bin="${VARIANT_BINS[$i]}"
lockfile="$BENCH_DIR/saved-lockfile-gvs-$variant.yaml"
i=1
for entry in "${SCENARIOS[@]}"; do
scenario="${entry%%:*}"
label="${entry#*:}"
echo ""
echo " $variant:"
hyperfine \
echo "━━━ Benchmark $i: $label ━━━"
"$BIN" \
--scenario "$scenario" \
--registry npm \
--pnpm-repository "$REPO_ROOT" \
--fixture-dir "$FIXTURE_DIR" \
--work-env "$BENCH_DIR/work-env" \
--warmup "$WARMUP" \
--runs "$RUNS" \
--ignore-failure \
--prepare "rm -rf $gvs_project/node_modules && cp $lockfile $gvs_project/pnpm-lock.yaml" \
--command-name "$variant" \
"cd $gvs_project && node $bin install --frozen-lockfile --ignore-scripts >/dev/null 2>&1" \
--export-json "$BENCH_DIR/gvs-warm-${variant}.json" \
|| true
pnpm@main pnpm@HEAD
cp "$BENCH_DIR/work-env/BENCHMARK_REPORT.md" "$BENCH_DIR/${scenario}.md"
cp "$BENCH_DIR/work-env/BENCHMARK_REPORT.json" "$BENCH_DIR/${scenario}.json"
main_cell=$(read_cell "pnpm@main" "$BENCH_DIR/${scenario}.json")
head_cell=$(read_cell "pnpm@HEAD" "$BENCH_DIR/${scenario}.json")
echo "| $i | $label | $main_cell | $head_cell |" >> "$results_md"
i=$((i + 1))
done
# ── Summary ─────────────────────────────────────────────────────────────────
# Combine the per-scenario hyperfine JSONs into one Bencher-shaped
# report. Keep only the @HEAD result from each scenario and rename
# `.command` to the scenario name so Bencher's shell_hyperfine adapter
# names the benchmark after the scenario instead of `pnpm@HEAD`.
if command -v jq >/dev/null; then
bencher_inputs=()
for entry in "${SCENARIOS[@]}"; do
scenario="${entry%%:*}"
jq --arg s "$scenario" \
'.results |= [.[] | select(.command == "pnpm@HEAD") | .command = $s]' \
"$BENCH_DIR/${scenario}.json" > "$BENCH_DIR/${scenario}-bencher.json"
bencher_inputs+=("$BENCH_DIR/${scenario}-bencher.json")
done
jq -s '{results: map(.results) | add}' \
"${bencher_inputs[@]}" > "$BENCH_DIR/bencher-results.json"
else
echo "warning: jq not on PATH; skipping bencher-results.json generation" >&2
fi
RESULTS_MD="$BENCH_DIR/results.md"
echo ""
echo
echo "━━━ Results ━━━"
node "$BRANCH_DIR/benchmarks/generate-results.js" "$BENCH_DIR" "$RESULTS_MD"
echo ""
echo "Results saved to: $RESULTS_MD"
# Cleanup
for project in "${VARIANT_PROJECTS[@]}" "${GVS_PROJECTS[@]}"; do
rm -rf "$project/node_modules"
done
echo ""
cat "$results_md"
echo
echo "Results saved to: $results_md"
echo "Temp directory kept at: $BENCH_DIR"
echo "Remove with: rm -rf $BENCH_DIR"

12144
benchmarks/fixture/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
const fs = require('fs')
const benchDir = process.argv[2]
const outputFile = process.argv[3]
const benchmarks = [
['headless', 'Headless (warm store+cache)'],
['peek', 'Re-resolution (add dep, warm)'],
['nolockfile', 'Full resolution (warm, no lockfile)'],
['headless-cold', 'Headless (cold store+cache)'],
['cold', 'Cold install (nothing warm)'],
['gvs-warm', 'GVS warm reinstall (warm global store)'],
]
function readResult (benchDir, name, variant) {
try {
const data = JSON.parse(fs.readFileSync(`${benchDir}/${name}-${variant}.json`, 'utf8'))
const r = data.results[0]
return `${r.mean.toFixed(3)}s ± ${r.stddev.toFixed(3)}s`
} catch (err) {
if (err && err.code !== 'ENOENT') {
console.error(`Warning: failed to read ${name}-${variant}: ${err.message}`)
}
return 'n/a'
}
}
const lines = [
'# Benchmark Results',
'',
'| # | Scenario | main | branch |',
'|---|---|---|---|',
]
benchmarks.forEach(([name, label], i) => {
const mainCell = readResult(benchDir, name, 'main')
const branchCell = readResult(benchDir, name, 'branch')
lines.push(`| ${i + 1} | ${label} | ${mainCell} | ${branchCell} |`)
})
lines.push('')
const output = lines.join('\n')
fs.writeFileSync(outputFile, output)
console.log(output)

View File

@@ -1,5 +1,43 @@
# @pnpm/link-bins
## 1100.0.10
### Patch Changes
- Updated dependencies [a456dc7]
- Updated dependencies [35d2355]
- @pnpm/workspace.project-manifest-reader@1100.0.9
- @pnpm/types@1101.2.0
- @pnpm/bins.resolver@1100.0.5
- @pnpm/pkg-manifest.reader@1100.0.5
- @pnpm/pkg-manifest.utils@1100.2.1
## 1100.0.9
### Patch Changes
- Updated dependencies [d7da112]
- @pnpm/workspace.project-manifest-reader@1100.0.8
## 1100.0.8
### Patch Changes
- Updated dependencies [1627943]
- Updated dependencies [64afc92]
- @pnpm/pkg-manifest.utils@1100.2.0
- @pnpm/types@1101.1.1
- @pnpm/workspace.project-manifest-reader@1100.0.7
- @pnpm/bins.resolver@1100.0.4
- @pnpm/pkg-manifest.reader@1100.0.4
## 1100.0.7
### Patch Changes
- @pnpm/pkg-manifest.utils@1100.1.4
- @pnpm/workspace.project-manifest-reader@1100.0.6
## 1100.0.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/bins.linker",
"version": "1100.0.6",
"version": "1100.0.10",
"description": "Link bins to node_modules/.bin",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,32 @@
# @pnpm/remove-bins
## 1100.0.6
### Patch Changes
- Updated dependencies [35d2355]
- @pnpm/types@1101.2.0
- @pnpm/bins.resolver@1100.0.5
- @pnpm/core-loggers@1100.1.2
- @pnpm/pkg-manifest.reader@1100.0.5
## 1100.0.5
### Patch Changes
- Updated dependencies [64afc92]
- @pnpm/types@1101.1.1
- @pnpm/bins.resolver@1100.0.4
- @pnpm/core-loggers@1100.1.1
- @pnpm/pkg-manifest.reader@1100.0.4
## 1100.0.4
### Patch Changes
- Updated dependencies [4a79336]
- @pnpm/core-loggers@1100.1.0
## 1100.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/bins.remover",
"version": "1100.0.3",
"version": "1100.0.6",
"description": "Remove bins from .bin",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,19 @@
# @pnpm/package-bins
## 1100.0.5
### Patch Changes
- Updated dependencies [35d2355]
- @pnpm/types@1101.2.0
## 1100.0.4
### Patch Changes
- Updated dependencies [64afc92]
- @pnpm/types@1101.1.1
## 1100.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/bins.resolver",
"version": "1100.0.3",
"version": "1100.0.5",
"description": "Returns bins of a package",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,130 @@
# @pnpm/building.after-install
## 1101.0.17
### Patch Changes
- Updated dependencies [a23956e]
- Updated dependencies [aa6149d]
- Updated dependencies [e55f4b5]
- Updated dependencies [35d2355]
- @pnpm/config.reader@1101.4.1
- @pnpm/worker@1100.1.8
- @pnpm/lockfile.utils@1100.0.10
- @pnpm/types@1101.2.0
- @pnpm/store.connection-manager@1100.2.4
- @pnpm/bins.linker@1100.0.10
- @pnpm/deps.graph-hasher@1100.2.2
- @pnpm/building.pkg-requires-build@1100.0.5
- @pnpm/building.policy@1100.0.7
- @pnpm/config.normalize-registries@1100.0.5
- @pnpm/core-loggers@1100.1.2
- @pnpm/deps.path@1100.0.5
- @pnpm/exec.lifecycle@1100.0.14
- @pnpm/installing.context@1100.0.13
- @pnpm/installing.modules-yaml@1100.0.6
- @pnpm/lockfile.types@1100.0.8
- @pnpm/lockfile.walker@1100.0.8
- @pnpm/pkg-manifest.reader@1100.0.5
- @pnpm/store.cafs@1100.1.7
- @pnpm/store.controller-types@1100.1.2
## 1101.0.16
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
- @pnpm/bins.linker@1100.0.9
- @pnpm/store.connection-manager@1100.2.3
- @pnpm/exec.lifecycle@1100.0.13
## 1101.0.15
### Patch Changes
- @pnpm/store.connection-manager@1100.2.2
## 1101.0.14
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [9cb48bb]
- Updated dependencies [d1b340f]
- Updated dependencies [64afc92]
- @pnpm/config.reader@1101.3.3
- @pnpm/exec.lifecycle@1100.0.12
- @pnpm/types@1101.1.1
- @pnpm/store.connection-manager@1100.2.1
- @pnpm/installing.context@1100.0.12
- @pnpm/deps.graph-hasher@1100.2.1
- @pnpm/lockfile.types@1100.0.7
- @pnpm/lockfile.utils@1100.0.9
- @pnpm/store.controller-types@1100.1.1
- @pnpm/bins.linker@1100.0.8
- @pnpm/building.pkg-requires-build@1100.0.4
- @pnpm/building.policy@1100.0.6
- @pnpm/config.normalize-registries@1100.0.4
- @pnpm/core-loggers@1100.1.1
- @pnpm/deps.path@1100.0.4
- @pnpm/installing.modules-yaml@1100.0.5
- @pnpm/lockfile.walker@1100.0.7
- @pnpm/pkg-manifest.reader@1100.0.4
- @pnpm/store.cafs@1100.1.6
- @pnpm/worker@1100.1.7
## 1101.0.13
### Patch Changes
- 3ddde2b: **fix**: anchor the side-effects-cache key and global-virtual-store hash to the project's script-runner Node — `engines.runtime` pin when present, shell `node` otherwise — instead of pnpm's own runtime.
`ENGINE_NAME` (the `<platform>;<arch>;node<major>` prefix used as the side-effects-cache key and the engine portion of the GVS hash) was computed from `process.version` — the Node that runs pnpm itself. That was wrong in two situations:
1. **`@pnpm/exe` SEA bundle.** The bundle has its own embedded Node, not the `node` on the user's `PATH` that actually spawns lifecycle scripts. Two pnpm installations on the same machine (one SEA, one npm-package) therefore disagreed on the cache key, partitioning the side-effects cache and the global virtual store across two Node majors even though both installs would run scripts on the same shell `node`.
2. **`engines.runtime` / `devEngines.runtime` pin.** When a project pins a Node version via `devEngines.runtime` (pnpm v11+), pnpm downloads that Node into `node_modules/node/` and uses it to run lifecycle scripts. But the hash still anchored to whichever Node ran pnpm itself, not to the pinned Node — so two installs of the same project with two different runner Nodes would still disagree on the GVS slot path even though scripts run on the same pinned Node.
Three changes:
- `@pnpm/engine.runtime.system-node-version` now exports `engineName(nodeVersion?)`. Resolves the version in this order: explicit override → `getSystemNodeVersion()` (which already prefers `node --version` over `process.version` in SEA contexts) → `process.version`.
- `@pnpm/deps.graph-hasher` now exports `findRuntimeNodeVersion(snapshotKeys)` — scans an iterable of lockfile snapshot keys for a `node@runtime:<version>` entry and returns its bare version string. `calcDepState` and `calcGraphNodeHash`/`iterateHashedGraphNodes` accept a `nodeVersion?` (in the options bag for the first, as a trailing parameter / ctx field for the others), forwarded to `engineName()`. The default (no override) preserves the pre-change behaviour. The legacy `ENGINE_NAME` constant in `@pnpm/constants` is unchanged so external consumers and existing tests keep working; in non-SEA, non-pinned contexts every value lines up.
- Every install-side caller of the graph-hasher (`@pnpm/installing.deps-resolver`, `@pnpm/installing.deps-restorer`, `@pnpm/installing.deps-installer`, `@pnpm/building.during-install`, `@pnpm/building.after-install`, `@pnpm/deps.graph-builder`) now derives the project's pinned runtime via `findRuntimeNodeVersion(Object.keys(graph))` once per invocation and threads it through.
On upgrade, two one-time GVS slot churns are possible:
- **SEA-pnpm users** without a runtime pin: slots that previously hashed under the embedded-Node major (e.g. `node26`) now hash under the shell-Node major (e.g. `node24`), matching what pacquet, the npm-published `pnpm` package, and any other pnpm-compatible tool already produce.
- **Projects with a `devEngines.runtime` pin**: slots that previously hashed under the runner's Node major now hash under the pinned Node major, matching what the lifecycle scripts will actually run on.
In both cases the old slots become prune-eligible.
- Updated dependencies [4195766]
- Updated dependencies [31538bf]
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [3ddde2b]
- Updated dependencies [5dc8be8]
- Updated dependencies [4a79336]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/store.controller-types@1100.1.0
- @pnpm/store.connection-manager@1100.2.0
- @pnpm/config.reader@1101.3.2
- @pnpm/deps.graph-hasher@1100.2.0
- @pnpm/core-loggers@1100.1.0
- @pnpm/installing.context@1100.0.11
- @pnpm/lockfile.types@1100.0.6
- @pnpm/lockfile.utils@1100.0.8
- @pnpm/exec.lifecycle@1100.0.11
- @pnpm/store.cafs@1100.1.5
- @pnpm/building.policy@1100.0.5
- @pnpm/lockfile.walker@1100.0.6
- @pnpm/worker@1100.1.6
- @pnpm/bins.linker@1100.0.7
## 1101.0.12
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/building.after-install",
"version": "1101.0.12",
"version": "1101.0.17",
"description": "Rebuild packages that are already installed by running their lifecycle scripts",
"keywords": [
"pnpm",
@@ -41,7 +41,6 @@
"@pnpm/deps.graph-hasher": "workspace:*",
"@pnpm/deps.graph-sequencer": "workspace:*",
"@pnpm/deps.path": "workspace:*",
"@pnpm/engine.runtime.system-node-version": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/exec.lifecycle": "workspace:*",
"@pnpm/installing.context": "workspace:*",

View File

@@ -10,10 +10,9 @@ import {
WANTED_LOCKFILE,
} from '@pnpm/constants'
import { skippedOptionalDependencyLogger } from '@pnpm/core-loggers'
import { calcDepState, type DepsStateCache, lockfileToDepGraph } from '@pnpm/deps.graph-hasher'
import { calcDepState, type DepsStateCache, findRuntimeNodeVersion, lockfileToDepGraph } from '@pnpm/deps.graph-hasher'
import { graphSequencer } from '@pnpm/deps.graph-sequencer'
import * as dp from '@pnpm/deps.path'
import { findRuntimeNodeVersion } from '@pnpm/engine.runtime.system-node-version'
import { PnpmError } from '@pnpm/error'
import {
runLifecycleHooksConcurrently,

View File

@@ -39,9 +39,6 @@
{
"path": "../../deps/path"
},
{
"path": "../../engine/runtime/system-node-version"
},
{
"path": "../../exec/lifecycle"
},

View File

@@ -1,5 +1,91 @@
# @pnpm/building.commands
## 1100.0.23
### Patch Changes
- Updated dependencies [a23956e]
- Updated dependencies [aa6149d]
- Updated dependencies [572842a]
- Updated dependencies [35d2355]
- @pnpm/config.reader@1101.4.1
- @pnpm/installing.commands@1100.6.0
- @pnpm/types@1101.2.0
- @pnpm/building.after-install@1101.0.17
- @pnpm/store.connection-manager@1100.2.4
- @pnpm/cli.utils@1101.0.8
- @pnpm/config.writer@1100.0.10
- @pnpm/deps.path@1100.0.5
- @pnpm/installing.modules-yaml@1100.0.6
- @pnpm/workspace.projects-sorter@1100.0.4
## 1100.0.22
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
- @pnpm/installing.commands@1100.5.0
- @pnpm/cli.utils@1101.0.7
- @pnpm/building.after-install@1101.0.16
- @pnpm/store.connection-manager@1100.2.3
## 1100.0.21
### Patch Changes
- Updated dependencies [881a865]
- @pnpm/installing.commands@1100.4.2
## 1100.0.20
### Patch Changes
- @pnpm/installing.commands@1100.4.1
- @pnpm/store.connection-manager@1100.2.2
- @pnpm/building.after-install@1101.0.15
## 1100.0.19
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [a620557]
- Updated dependencies [d1b340f]
- Updated dependencies [b206a15]
- Updated dependencies [64afc92]
- @pnpm/config.reader@1101.3.3
- @pnpm/installing.commands@1100.4.0
- @pnpm/types@1101.1.1
- @pnpm/building.after-install@1101.0.14
- @pnpm/store.connection-manager@1100.2.1
- @pnpm/cli.utils@1101.0.6
- @pnpm/config.writer@1100.0.9
- @pnpm/deps.path@1100.0.4
- @pnpm/installing.modules-yaml@1100.0.5
- @pnpm/workspace.projects-sorter@1100.0.3
## 1100.0.18
### Patch Changes
- Updated dependencies [4195766]
- Updated dependencies [31538bf]
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [3ddde2b]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/installing.commands@1100.3.0
- @pnpm/store.connection-manager@1100.2.0
- @pnpm/config.reader@1101.3.2
- @pnpm/building.after-install@1101.0.13
- @pnpm/cli.utils@1101.0.5
- @pnpm/config.writer@1100.0.8
## 1100.0.17
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/building.commands",
"version": "1100.0.17",
"version": "1100.0.23",
"description": "Commands for rebuilding and managing dependency builds",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,101 @@
# @pnpm/building.during-install
## 1101.0.14
### Patch Changes
- Updated dependencies [a23956e]
- Updated dependencies [aa6149d]
- Updated dependencies [26a7d63]
- Updated dependencies [35d2355]
- @pnpm/config.reader@1101.4.1
- @pnpm/worker@1100.1.8
- @pnpm/patching.apply-patch@1100.0.1
- @pnpm/types@1101.2.0
- @pnpm/bins.linker@1100.0.10
- @pnpm/deps.graph-hasher@1100.2.2
- @pnpm/core-loggers@1100.1.2
- @pnpm/deps.path@1100.0.5
- @pnpm/exec.lifecycle@1100.0.14
- @pnpm/pkg-manifest.reader@1100.0.5
- @pnpm/store.controller-types@1100.1.2
- @pnpm/fs.hard-link-dir@1100.0.1
## 1101.0.13
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
- @pnpm/bins.linker@1100.0.9
- @pnpm/exec.lifecycle@1100.0.13
## 1101.0.12
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [9cb48bb]
- Updated dependencies [d1b340f]
- Updated dependencies [64afc92]
- @pnpm/config.reader@1101.3.3
- @pnpm/exec.lifecycle@1100.0.12
- @pnpm/types@1101.1.1
- @pnpm/deps.graph-hasher@1100.2.1
- @pnpm/store.controller-types@1100.1.1
- @pnpm/bins.linker@1100.0.8
- @pnpm/core-loggers@1100.1.1
- @pnpm/deps.path@1100.0.4
- @pnpm/pkg-manifest.reader@1100.0.4
- @pnpm/worker@1100.1.7
- @pnpm/fs.hard-link-dir@1100.0.1
- @pnpm/patching.apply-patch@1100.0.0
## 1101.0.11
### Patch Changes
- 3ddde2b: **fix**: anchor the side-effects-cache key and global-virtual-store hash to the project's script-runner Node — `engines.runtime` pin when present, shell `node` otherwise — instead of pnpm's own runtime.
`ENGINE_NAME` (the `<platform>;<arch>;node<major>` prefix used as the side-effects-cache key and the engine portion of the GVS hash) was computed from `process.version` — the Node that runs pnpm itself. That was wrong in two situations:
1. **`@pnpm/exe` SEA bundle.** The bundle has its own embedded Node, not the `node` on the user's `PATH` that actually spawns lifecycle scripts. Two pnpm installations on the same machine (one SEA, one npm-package) therefore disagreed on the cache key, partitioning the side-effects cache and the global virtual store across two Node majors even though both installs would run scripts on the same shell `node`.
2. **`engines.runtime` / `devEngines.runtime` pin.** When a project pins a Node version via `devEngines.runtime` (pnpm v11+), pnpm downloads that Node into `node_modules/node/` and uses it to run lifecycle scripts. But the hash still anchored to whichever Node ran pnpm itself, not to the pinned Node — so two installs of the same project with two different runner Nodes would still disagree on the GVS slot path even though scripts run on the same pinned Node.
Three changes:
- `@pnpm/engine.runtime.system-node-version` now exports `engineName(nodeVersion?)`. Resolves the version in this order: explicit override → `getSystemNodeVersion()` (which already prefers `node --version` over `process.version` in SEA contexts) → `process.version`.
- `@pnpm/deps.graph-hasher` now exports `findRuntimeNodeVersion(snapshotKeys)` — scans an iterable of lockfile snapshot keys for a `node@runtime:<version>` entry and returns its bare version string. `calcDepState` and `calcGraphNodeHash`/`iterateHashedGraphNodes` accept a `nodeVersion?` (in the options bag for the first, as a trailing parameter / ctx field for the others), forwarded to `engineName()`. The default (no override) preserves the pre-change behaviour. The legacy `ENGINE_NAME` constant in `@pnpm/constants` is unchanged so external consumers and existing tests keep working; in non-SEA, non-pinned contexts every value lines up.
- Every install-side caller of the graph-hasher (`@pnpm/installing.deps-resolver`, `@pnpm/installing.deps-restorer`, `@pnpm/installing.deps-installer`, `@pnpm/building.during-install`, `@pnpm/building.after-install`, `@pnpm/deps.graph-builder`) now derives the project's pinned runtime via `findRuntimeNodeVersion(Object.keys(graph))` once per invocation and threads it through.
On upgrade, two one-time GVS slot churns are possible:
- **SEA-pnpm users** without a runtime pin: slots that previously hashed under the embedded-Node major (e.g. `node26`) now hash under the shell-Node major (e.g. `node24`), matching what pacquet, the npm-published `pnpm` package, and any other pnpm-compatible tool already produce.
- **Projects with a `devEngines.runtime` pin**: slots that previously hashed under the runner's Node major now hash under the pinned Node major, matching what the lifecycle scripts will actually run on.
In both cases the old slots become prune-eligible.
- Updated dependencies [4195766]
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [3ddde2b]
- Updated dependencies [5dc8be8]
- Updated dependencies [4a79336]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/store.controller-types@1100.1.0
- @pnpm/config.reader@1101.3.2
- @pnpm/deps.graph-hasher@1100.2.0
- @pnpm/core-loggers@1100.1.0
- @pnpm/exec.lifecycle@1100.0.11
- @pnpm/worker@1100.1.6
- @pnpm/bins.linker@1100.0.7
- @pnpm/fs.hard-link-dir@1100.0.1
- @pnpm/patching.apply-patch@1100.0.0
## 1101.0.10
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/building.during-install",
"version": "1101.0.10",
"version": "1101.0.14",
"description": "Build packages in node_modules",
"keywords": [
"pnpm",
@@ -39,7 +39,6 @@
"@pnpm/deps.graph-hasher": "workspace:*",
"@pnpm/deps.graph-sequencer": "workspace:*",
"@pnpm/deps.path": "workspace:*",
"@pnpm/engine.runtime.system-node-version": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/exec.lifecycle": "workspace:*",
"@pnpm/fs.hard-link-dir": "workspace:*",

View File

@@ -6,8 +6,7 @@ import util from 'node:util'
import { linkBins, linkBinsOfPackages } from '@pnpm/bins.linker'
import { getWorkspaceConcurrency } from '@pnpm/config.reader'
import { skippedOptionalDependencyLogger } from '@pnpm/core-loggers'
import { calcDepState, type DepsStateCache } from '@pnpm/deps.graph-hasher'
import { findRuntimeNodeVersion } from '@pnpm/engine.runtime.system-node-version'
import { calcDepState, type DepsStateCache, findRuntimeNodeVersion } from '@pnpm/deps.graph-hasher'
import { PnpmError } from '@pnpm/error'
import { runPostinstallHooks } from '@pnpm/exec.lifecycle'
import { logger } from '@pnpm/logger'

View File

@@ -36,9 +36,6 @@
{
"path": "../../deps/path"
},
{
"path": "../../engine/runtime/system-node-version"
},
{
"path": "../../exec/lifecycle"
},

View File

@@ -1,5 +1,19 @@
# @pnpm/building.pkg-requires-build
## 1100.0.5
### Patch Changes
- Updated dependencies [35d2355]
- @pnpm/types@1101.2.0
## 1100.0.4
### Patch Changes
- Updated dependencies [64afc92]
- @pnpm/types@1101.1.1
## 1100.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/building.pkg-requires-build",
"version": "1100.0.3",
"version": "1100.0.5",
"description": "Checks if a package requires to be built",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,30 @@
# @pnpm/building.policy
## 1100.0.7
### Patch Changes
- Updated dependencies [35d2355]
- @pnpm/types@1101.2.0
- @pnpm/config.version-policy@1100.1.2
- @pnpm/deps.path@1100.0.5
## 1100.0.6
### Patch Changes
- Updated dependencies [64afc92]
- @pnpm/types@1101.1.1
- @pnpm/config.version-policy@1100.1.1
- @pnpm/deps.path@1100.0.4
## 1100.0.5
### Patch Changes
- Updated dependencies [b6e2c8c]
- @pnpm/config.version-policy@1100.1.0
## 1100.0.4
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/building.policy",
"version": "1100.0.4",
"version": "1100.0.7",
"description": "Create a function for filtering out dependencies that are not allowed to be built",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,60 @@
# @pnpm/cache.api
## 1100.0.17
### Patch Changes
- Updated dependencies [a23956e]
- Updated dependencies [35d2355]
- Updated dependencies [0721d64]
- @pnpm/config.reader@1101.4.1
- @pnpm/resolving.npm-resolver@1101.3.3
- @pnpm/store.cafs@1100.1.7
## 1100.0.16
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
- @pnpm/resolving.npm-resolver@1101.3.2
## 1100.0.15
### Patch Changes
- @pnpm/resolving.npm-resolver@1101.3.1
## 1100.0.14
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [d1b340f]
- Updated dependencies [3a54205]
- Updated dependencies [1627943]
- @pnpm/config.reader@1101.3.3
- @pnpm/resolving.npm-resolver@1101.3.0
- @pnpm/store.cafs@1100.1.6
## 1100.0.13
### Patch Changes
- Updated dependencies [963861c]
- Updated dependencies [4195766]
- Updated dependencies [31538bf]
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/resolving.npm-resolver@1101.2.0
- @pnpm/config.reader@1101.3.2
- @pnpm/store.cafs@1100.1.5
## 1100.0.12
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/cache.api",
"version": "1100.0.12",
"version": "1100.0.17",
"description": "API for controlling the cache",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,55 @@
# @pnpm/cache.commands
## 1100.0.18
### Patch Changes
- Updated dependencies [a23956e]
- Updated dependencies [35d2355]
- @pnpm/config.reader@1101.4.1
- @pnpm/cache.api@1100.0.17
- @pnpm/cli.utils@1101.0.8
## 1100.0.17
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
- @pnpm/cli.utils@1101.0.7
- @pnpm/cache.api@1100.0.16
## 1100.0.16
### Patch Changes
- @pnpm/cache.api@1100.0.15
## 1100.0.15
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [d1b340f]
- @pnpm/config.reader@1101.3.3
- @pnpm/cache.api@1100.0.14
- @pnpm/cli.utils@1101.0.6
## 1100.0.14
### Patch Changes
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/config.reader@1101.3.2
- @pnpm/cache.api@1100.0.13
- @pnpm/cli.utils@1101.0.5
## 1100.0.13
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/cache.commands",
"version": "1100.0.13",
"version": "1100.0.18",
"description": "Commands for controlling the cache",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,52 @@
# @pnpm/cli.commands
## 1100.0.16
### Patch Changes
- Updated dependencies [e8b3ae1]
- Updated dependencies [a23956e]
- Updated dependencies [35d2355]
- @pnpm/workspace.projects-reader@1101.0.8
- @pnpm/config.reader@1101.4.1
- @pnpm/cli.utils@1101.0.8
- @pnpm/workspace.workspace-manifest-reader@1100.0.5
## 1100.0.15
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
- @pnpm/cli.utils@1101.0.7
- @pnpm/workspace.projects-reader@1101.0.7
## 1100.0.14
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [d1b340f]
- @pnpm/config.reader@1101.3.3
- @pnpm/cli.utils@1101.0.6
- @pnpm/workspace.projects-reader@1101.0.6
- @pnpm/workspace.workspace-manifest-reader@1100.0.4
## 1100.0.13
### Patch Changes
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/config.reader@1101.3.2
- @pnpm/cli.utils@1101.0.5
- @pnpm/workspace.projects-reader@1101.0.5
## 1100.0.12
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/cli.commands",
"version": "1100.0.12",
"version": "1100.0.16",
"description": "Commands for pnpm CLI",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,69 @@
# @pnpm/default-reporter
## 1100.2.3
### Patch Changes
- Updated dependencies [a23956e]
- Updated dependencies [35d2355]
- @pnpm/config.reader@1101.4.1
- @pnpm/types@1101.2.0
- @pnpm/cli.meta@1100.0.5
- @pnpm/core-loggers@1100.1.2
- @pnpm/deps.inspection.peers-issues-renderer@1100.0.3
## 1100.2.2
### Patch Changes
- Updated dependencies [3b62f9d]
- Updated dependencies [212315d]
- @pnpm/config.reader@1101.4.0
## 1100.2.1
### Patch Changes
- Updated dependencies [3687b0e]
- Updated dependencies [ced20cb]
- Updated dependencies [d1b340f]
- Updated dependencies [64afc92]
- @pnpm/config.reader@1101.3.3
- @pnpm/types@1101.1.1
- @pnpm/cli.meta@1100.0.4
- @pnpm/core-loggers@1100.1.1
- @pnpm/deps.inspection.peers-issues-renderer@1100.0.2
## 1100.2.0
### Minor Changes
- 4a79336: The lockfile verifier added in #11705 now emits `pnpm:lockfile-verification` log events (`status: 'started' | 'done'`) around the registry round-trip pass, and the default reporter renders them as a transient progress line so users can see that pnpm is doing work — on a cold registry cache the round-trip can take a noticeable beat, and the previous behavior was complete silence followed by either a long pause or an error. The cached short-circuit stays silent (no logs when no work happens), and the `done` line carries the number of distinct entries that were checked plus the elapsed time.
Pacquet parity: not ported — pacquet doesn't carry the lockfile verifier yet (see the parity note on #11705).
### Patch Changes
- 4195766: Tightened the `minimumReleaseAge` story so the bypass becomes explicit on disk instead of silent, and removed the discover-by-loop dance for strict-mode users:
1. Fresh resolutions in loose mode (`minimumReleaseAgeStrict: false`) that fall back to a version newer than the cutoff auto-collect the picked `name@version` into the workspace manifest's `minimumReleaseAgeExclude`. A single info message lists the additions; entries already on the list are left alone.
2. The post-resolution lockfile verifier introduced in #11583 now runs in loose mode too — every accepted-immature pin must be on `minimumReleaseAgeExclude`, just like strict mode requires. A lockfile produced under a weaker (or absent) policy that still has immature entries is rejected the same way strict mode would reject it.
3. **Strict mode (interactive)** no longer aborts on the first immature pick. The resolver gathers every immature direct _and_ transitive in one pass; before peer-dependency resolution runs, pnpm prompts the user with the full list and asks whether to add them all to `minimumReleaseAgeExclude` and proceed. Approve → install continues and the workspace manifest is written at the end. Decline → resolution aborts before the lockfile or package.json is touched (tarballs already in the store stay, since the store is idempotent). This closes the [#10488](https://github.com/pnpm/pnpm/issues/10488) loop where security bumps to packages with platform-specific transitives (e.g. `next` + the `@next/swc-*` shims) made users re-run `pnpm add` once per transitive.
4. **Strict mode (non-interactive / CI)** now aborts with the full immature set in the error message instead of the first pick. The resolver always collects every immature direct + transitive; the install command then throws `ERR_PNPM_NO_MATURE_MATCHING_VERSION` listing each entry's `name@version` and publish time. Deterministic CI behavior is preserved (same exit code, same error code), but the error pinpoints every offending entry instead of forcing the discover-by-loop dance. The expected workflow is interactive approval locally → the lockfile + workspace manifest get committed → CI runs cleanly against the populated exclude list.
5. **The lockfile verifier now also covers `trustPolicy: 'no-downgrade'`.** The same post-resolution gate that re-checks `minimumReleaseAge` on lockfile entries now re-runs `failIfTrustDowngraded` for every npm-registry entry whose name isn't on `trustPolicyExclude`. The two checks share a single full-metadata fetch per package, so the extra coverage doesn't cost an extra round trip when both policies are active. Resolver-time trust checks still run as before — this just closes the gap when an entry bypasses resolution (peek path, `--frozen-lockfile`, restored CI cache).
Pacquet parity: not ported — pacquet's `minimumReleaseAge` policy is itself only stubbed today (see `pacquet/crates/package-manager/src/version_policy.rs`). The auto-exclude, loose-mode verifier, prompt, and the new trust-policy verifier check will travel with the broader policy port whenever that happens.
- Updated dependencies [020ac45]
- Updated dependencies [d3f8408]
- Updated dependencies [4a79336]
- Updated dependencies [a62f959]
- Updated dependencies [ba2c884]
- Updated dependencies [8df408c]
- @pnpm/config.reader@1101.3.2
- @pnpm/core-loggers@1100.1.0
## 1100.1.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/cli.default-reporter",
"version": "1100.1.2",
"version": "1100.2.3",
"description": "The default reporter of pnpm",
"keywords": [
"pnpm",

View File

@@ -127,6 +127,7 @@ export function toOutput$ (
const deprecationPushStream = new Rx.Subject<logs.DeprecationLog>()
const summaryPushStream = new Rx.Subject<logs.SummaryLog>()
const lifecyclePushStream = new Rx.Subject<logs.LifecycleLog>()
const lockfileVerificationPushStream = new Rx.Subject<logs.LockfileVerificationLog>()
const statsPushStream = new Rx.Subject<logs.StatsLog>()
const packageImportMethodPushStream = new Rx.Subject<logs.PackageImportMethodLog>()
const installCheckPushStream = new Rx.Subject<logs.InstallCheckLog>()
@@ -170,6 +171,9 @@ export function toOutput$ (
case 'pnpm:lifecycle':
lifecyclePushStream.next(log)
break
case 'pnpm:lockfile-verification':
lockfileVerificationPushStream.next(log)
break
case 'pnpm:stats':
statsPushStream.next(log)
break
@@ -243,6 +247,7 @@ export function toOutput$ (
ignoredScripts: Rx.from(ignoredScriptsPushStream),
lifecycle: Rx.from(lifecyclePushStream),
link: Rx.from(linkPushStream),
lockfileVerification: Rx.from(lockfileVerificationPushStream),
other,
packageImportMethod: Rx.from(packageImportMethodPushStream),
packageManifest: Rx.from(packageManifestPushStream),

View File

@@ -72,7 +72,12 @@ function getErrorInfo (logObj: Log, config?: Config): ErrorInfo | null {
return { title: err.message, body: 'If you cannot fix this registry issue, then set "resolution-mode" to "highest".' }
case 'ERR_PNPM_NO_MATCHING_VERSION':
case 'ERR_PNPM_NO_MATURE_MATCHING_VERSION':
return formatNoMatchingVersion(err, logObj as unknown as { packageMeta: PackageMeta, immatureVersion?: string })
// ERR_PNPM_NO_MATURE_MATCHING_VERSION used to come from the resolver
// with `packageMeta` attached; it now comes from the install / dlx /
// self-update callers as a plain PnpmError once the resolver has
// surfaced the violations. `packageMeta` may be undefined, in which
// case the formatter falls back to the bare title+message.
return formatNoMatchingVersion(err, logObj as unknown as { packageMeta?: PackageMeta })
case 'ERR_PNPM_RECURSIVE_FAIL':
return formatRecursiveCommandSummary(logObj as any) // eslint-disable-line @typescript-eslint/no-explicit-any
case 'ERR_PNPM_BAD_TARBALL_SIZE':
@@ -134,11 +139,18 @@ interface PackageMeta {
time?: Record<string, string>
}
function formatNoMatchingVersion (err: Error, msg: { packageMeta: PackageMeta, immatureVersion?: string }) {
const meta: PackageMeta = msg.packageMeta
function formatNoMatchingVersion (err: Error, msg: { packageMeta?: PackageMeta }) {
// Errors raised by the install/dlx/self-update layer after the resolver
// surfaces violations may not carry the original packageMeta. In that
// case the error message alone already names every offending entry,
// so we just echo it through without the registry-metadata appendix.
const meta = msg.packageMeta
if (!meta) {
return { title: err.message }
}
const latestVersion = meta['dist-tags'].latest
let output = `The latest release of ${meta.name} is "${latestVersion}".`
const latestTime = msg.packageMeta.time?.[latestVersion]
const latestTime = meta.time?.[latestVersion]
if (latestTime) {
output += ` Published at ${stringifyDate(latestTime)}`
}
@@ -150,7 +162,7 @@ function formatNoMatchingVersion (err: Error, msg: { packageMeta: PackageMeta, i
if (tag !== 'latest') {
const version = meta['dist-tags'][tag]
output += ` * ${tag}: ${version}`
const time = msg.packageMeta.time?.[version]
const time = meta.time?.[version]
if (time) {
output += ` published at ${stringifyDate(time)}`
}
@@ -161,10 +173,6 @@ function formatNoMatchingVersion (err: Error, msg: { packageMeta: PackageMeta, i
output += `${EOL}If you need the full list of all ${Object.keys(meta.versions).length} published versions run "pnpm view ${meta.name} versions".`
if (msg.immatureVersion) {
output += `${EOL}${EOL}If you want to install the matched version ignoring the time it was published, you can add the package name to the minimumReleaseAgeExclude setting. Read more about it: https://pnpm.io/settings#minimumreleaseageexclude`
}
return {
title: err.message,
body: output,

View File

@@ -13,6 +13,7 @@ import { reportIgnoredBuilds } from './reportIgnoredBuilds.js'
import { reportInstallChecks } from './reportInstallChecks.js'
import { reportInstallingConfigDeps } from './reportInstallingConfigDeps.js'
import { reportLifecycleScripts } from './reportLifecycleScripts.js'
import { reportLockfileVerification } from './reportLockfileVerification.js'
import { LOG_LEVEL_NUMBER, reportMisc } from './reportMisc.js'
import { reportPeerDependencyIssues } from './reportPeerDependencyIssues.js'
import { reportProgress } from './reportProgress.js'
@@ -41,6 +42,7 @@ export function reporterForClient (
deprecation: Rx.Observable<logs.DeprecationLog>
summary: Rx.Observable<logs.SummaryLog>
lifecycle: Rx.Observable<logs.LifecycleLog>
lockfileVerification: Rx.Observable<logs.LockfileVerificationLog>
stats: Rx.Observable<logs.StatsLog>
installCheck: Rx.Observable<logs.InstallCheckLog>
installingConfigDeps: Rx.Observable<logs.InstallingConfigDepsLog>
@@ -130,6 +132,10 @@ export function reporterForClient (
}),
reportInstallChecks(log$.installCheck, { cwd }),
reportInstallingConfigDeps(log$.installingConfigDeps),
reportLockfileVerification(log$.lockfileVerification, {
cwd,
workspaceDir: opts.pnpmConfig?.workspaceDir,
}),
reportScope(log$.scope, { isRecursive: opts.isRecursive, cmd: opts.cmd }),
reportSkippedOptionalDependencies(log$.skippedOptionalDependency, { cwd }),
reportHooks(log$.hook, { cwd, isRecursive: opts.isRecursive }),

View File

@@ -0,0 +1,77 @@
import path from 'node:path'
import type { LockfileVerificationLog } from '@pnpm/core-loggers'
import chalk from 'chalk'
import normalize from 'normalize-path'
import prettyMs from 'pretty-ms'
import * as Rx from 'rxjs'
import { map } from 'rxjs/operators'
export interface ReportLockfileVerificationOptions {
cwd: string
/**
* The workspace root, when one exists. Used as the "expected"
* location for the lockfile — when the lockfile lives there, the
* path is implied and we don't repeat it in the rendered message.
* Falls back to `cwd` for single-project installs.
*/
workspaceDir?: string
}
export function reportLockfileVerification (
lockfileVerification$: Rx.Observable<LockfileVerificationLog>,
opts: ReportLockfileVerificationOptions
): Rx.Observable<Rx.Observable<{ msg: string }>> {
const expectedDir = opts.workspaceDir ?? opts.cwd
// A single inner observable so the `done` message overwrites the
// transient `started` message in ansi-diff mode. In appendOnly mode
// both lines are printed.
return Rx.of(lockfileVerification$.pipe(
map((log) => {
const path_ = formatLockfilePath(log.lockfilePath, opts.cwd, expectedDir)
const entries = `${log.entries} ${log.entries === 1 ? 'entry' : 'entries'}`
switch (log.status) {
case 'started':
return {
msg: `${chalk.cyan('?')} Verifying lockfile${path_} against supply-chain policies (${entries})...`,
}
case 'done':
return {
msg: `${chalk.green('✓')} Lockfile${path_} passes supply-chain policies (${entries} in ${prettyMs(log.elapsedMs)})`,
}
case 'failed':
// Brief one-liner so the transient `started` frame doesn't
// stay on screen above the detailed PnpmError block that the
// error reporter prints next.
return {
msg: `${chalk.red('✗')} Lockfile${path_} failed supply-chain policy check (${entries} in ${prettyMs(log.elapsedMs)})`,
}
}
})
))
}
// Returns a leading-space-prefixed `at <path>` suffix only when the
// lockfile sits outside the obvious project/workspace root — otherwise
// the path is implied and printing it would just add noise to every
// install. Empty string when the path is omitted or matches the
// expected location.
//
// Uses `path.relative` rather than a strict `===` between
// `path.dirname(lockfilePath)` and `expectedDir`: relative path
// computation normalizes slash direction and trailing separators, so
// a workspaceDir like `C:/repo/` correctly matches a lockfilePath at
// `C:\repo\pnpm-lock.yaml` on Windows. The lockfile is considered
// "inside the expected dir" when the relative path is a bare file
// name (no separator) that doesn't escape upward.
function formatLockfilePath (
lockfilePath: string | undefined,
cwd: string,
expectedDir: string
): string {
if (lockfilePath == null) return ''
const fromExpected = path.relative(expectedDir, lockfilePath)
const isDirectChild = !fromExpected.includes(path.sep) && !fromExpected.startsWith('..')
if (isDirectChild) return ''
return ` at ${normalize(path.relative(cwd, lockfilePath))}`
}

View File

@@ -0,0 +1,150 @@
import path from 'node:path'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { expect, test } from '@jest/globals'
import { toOutput$ } from '@pnpm/cli.default-reporter'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { lockfileVerificationLogger } from '@pnpm/core-loggers'
import { createStreamParser } from '@pnpm/logger'
import { firstValueFrom, take, toArray } from 'rxjs'
test('prints lockfile verification in-progress and completion messages', async () => {
const cwd = '/repo'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
// Subscribe before emitting so we capture both the started and the
// done frame in ansi-diff mode.
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
const lockfilePath = path.join(cwd, 'pnpm-lock.yaml')
lockfileVerificationLogger.debug({ status: 'started', entries: 234, lockfilePath })
lockfileVerificationLogger.debug({
status: 'done',
entries: 234,
elapsedMs: 1234,
lockfilePath,
})
const [started, done] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (234 entries)...')
expect(stripAnsi(done)).toBe('✓ Lockfile passes supply-chain policies (234 entries in 1.2s)')
})
test('uses singular noun for one entry', async () => {
const output$ = toOutput$({
context: { argv: ['install'] },
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
lockfileVerificationLogger.debug({ status: 'started', entries: 1 })
lockfileVerificationLogger.debug({
status: 'done',
entries: 1,
elapsedMs: 42,
})
const [started, done] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (1 entry)...')
expect(stripAnsi(done)).toBe('✓ Lockfile passes supply-chain policies (1 entry in 42ms)')
})
test('prints relative path when lockfile lives outside the workspace root', async () => {
const cwd = '/repo/packages/app'
const workspaceDir = '/repo'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd, workspaceDir } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
// Lockfile lives in a sibling dir, not at the workspace root.
const lockfilePath = '/repo/locks/pnpm-lock.yaml'
lockfileVerificationLogger.debug({ status: 'started', entries: 5, lockfilePath })
lockfileVerificationLogger.debug({
status: 'done',
entries: 5,
elapsedMs: 200,
lockfilePath,
})
const [started, done] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile at ../../locks/pnpm-lock.yaml against supply-chain policies (5 entries)...')
expect(stripAnsi(done)).toBe('✓ Lockfile at ../../locks/pnpm-lock.yaml passes supply-chain policies (5 entries in 200ms)')
})
test('does not print path when running from workspace subdir and lockfile is at workspace root', async () => {
const cwd = '/repo/packages/app'
const workspaceDir = '/repo'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd, workspaceDir } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(1), toArray()))
const lockfilePath = path.join(workspaceDir, 'pnpm-lock.yaml')
lockfileVerificationLogger.debug({ status: 'started', entries: 10, lockfilePath })
const [started] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (10 entries)...')
})
test('suppresses path when workspaceDir has a trailing separator', async () => {
const cwd = '/repo'
// Workspace dir with a trailing slash — strict === against
// path.dirname(lockfilePath) would mismatch; path.relative normalizes.
const workspaceDir = '/repo/'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd, workspaceDir } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(1), toArray()))
lockfileVerificationLogger.debug({
status: 'started',
entries: 3,
lockfilePath: '/repo/pnpm-lock.yaml',
})
const [started] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (3 entries)...')
})
test('emits a brief failure line on failed status', async () => {
const output$ = toOutput$({
context: { argv: ['install'] },
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
lockfileVerificationLogger.debug({ status: 'started', entries: 12 })
lockfileVerificationLogger.debug({
status: 'failed',
entries: 12,
elapsedMs: 800,
})
const [started, failed] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (12 entries)...')
expect(stripAnsi(failed)).toBe('✗ Lockfile failed supply-chain policy check (12 entries in 800ms)')
})

View File

@@ -1,5 +1,19 @@
# @pnpm/cli-meta
## 1100.0.5
### Patch Changes
- Updated dependencies [35d2355]
- @pnpm/types@1101.2.0
## 1100.0.4
### Patch Changes
- Updated dependencies [64afc92]
- @pnpm/types@1101.1.1
## 1100.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@pnpm/cli.meta",
"version": "1100.0.3",
"version": "1100.0.5",
"description": "Reads the metainfo of the currently running pnpm instance",
"keywords": [
"pnpm",

View File

@@ -1,5 +1,44 @@
# @pnpm/cli-utils
## 1101.0.8
### Patch Changes
- Updated dependencies [a456dc7]
- Updated dependencies [35d2355]
- @pnpm/workspace.project-manifest-reader@1100.0.9
- @pnpm/types@1101.2.0
- @pnpm/config.package-is-installable@1100.0.7
- @pnpm/cli.meta@1100.0.5
- @pnpm/pkg-manifest.utils@1100.2.1
## 1101.0.7
### Patch Changes
- Updated dependencies [d7da112]
- @pnpm/workspace.project-manifest-reader@1100.0.8
## 1101.0.6
### Patch Changes
- Updated dependencies [1627943]
- Updated dependencies [64afc92]
- @pnpm/pkg-manifest.utils@1100.2.0
- @pnpm/types@1101.1.1
- @pnpm/workspace.project-manifest-reader@1100.0.7
- @pnpm/cli.meta@1100.0.4
- @pnpm/config.package-is-installable@1100.0.6
## 1101.0.5
### Patch Changes
- @pnpm/config.package-is-installable@1100.0.5
- @pnpm/pkg-manifest.utils@1100.1.4
- @pnpm/workspace.project-manifest-reader@1100.0.6
## 1101.0.4
### Patch Changes

Some files were not shown because too many files have changed in this diff Show More