## Problem
On Windows, **any failed `pnpm` command hangs 20–46 seconds before exiting.** The error handler (`pnpm/src/errorHandler.ts`) enumerates descendant processes via `pidtree` to terminate them on every error exit. On Windows `pidtree` shells out to `wmic` and, where wmic has been removed, a PowerShell `Get-CimInstance Win32_Process` fallback — a process listing that takes tens of seconds on busy CI runners.
This also broke Windows CI: the `verifyDepsBeforeRun/*` e2e suites are full of intentional-failure assertions (e.g. `pnpm start` with `--config.verify-deps-before-run=error` when deps aren't installed). Each failure paid the ~23 s error-handler tax, so the suite blew past the 70-minute cap. `pnpm install` and success paths never hit the error handler, which is why only failures were slow.
Diagnosed by sampling `process.getActiveResourcesInfo()` during the hang: it showed a lingering `ProcessWrap` (a spawned child), and hooking `child_process.spawn` named it (`wmic` → `powershell … Get-CimInstance Win32_Process`, exiting after ~23–46 s).
## Fix
Race the descendant-process lookup against a 2 s timeout. If it doesn't return in time, skip the kill and exit — `exit()` calls `process.exit`, which abandons the still-running (harmless, read-only) process query instead of blocking on it. The fast path (Unix, fast Windows) is unchanged.
Confirmed on Windows CI: the failing `start` invocations dropped from **~23 s to ~2.7 s**, and `multiProjectWorkspace.ts` went from **716 s to 124 s**.
## Also included
The CI pnpr-binary cache is split into `restore` + an explicit `save` step that runs right after the build, so a failing test step no longer discards the ~20-minute Rust build (the combined `actions/cache` only saved in a post-job step that gets skipped on failure).
## Summary
Adds CI duration tracking for the `pnpm-ci-performance` Bencher project.
Tracked Rust testbeds and benchmarks:
- `pacquet.ubuntu`, `pacquet.windows`, `pacquet.macos` -> `tests.all`
- `pnpr.ubuntu`, `pnpr.windows`, `pnpr.macos` -> `tests.all`
Tracked pnpm testbeds and benchmarks for full test runs:
- `pnpm.ubuntu.node22`, `pnpm.ubuntu.node24`, `pnpm.ubuntu.node26` -> `tests.all`, `tests.cli`
- `pnpm.windows.node22`, `pnpm.windows.node24`, `pnpm.windows.node26` -> `tests.all`, `tests.cli`
The test workflows produce Bencher-compatible JSON artifacts without receiving `BENCHER_API_TOKEN`. A separate `workflow_run` workflow downloads those artifacts only for same-repository runs, validates their metadata, and uploads from trusted workflow code using the existing `BENCHER_API_TOKEN` secret. The pnpm CLI e2e duration is extracted from `pnpm run --report-summary` output during the same full-test execution, so the CLI e2e suite is not run a second time.
* fix(pnpr): pass batch_publish test request bodies by reference
The put_json/put_json_with_token test helpers took the JSON body by
value but only borrowed it for serde_json::to_vec, tripping clippy's
needless_pass_by_value under --all-targets. Take &Value instead, which
also drops an unnecessary body.clone() at one call site.
* ci: run clippy as a single-OS job and add it to the pre-push hook
Clippy was a step inside the three-OS Lint-and-Test matrix, so it ran
once per OS even though it lints the same platform-independent source
each time. Move it to its own ubuntu-only job, mirroring the existing
single-OS doc, format, and dylint jobs (platform-gated cfg blocks are
still type-checked per-OS by the test build).
It was also missing from pacquet/scripts/pre-push-rust.sh, so a clippy
lint that only fires under --all-targets — like the one that just
reached main — slipped past local pushes and surfaced only in CI. Add
the same --all-targets workspace clippy gate to the hook.
## Motivation
The [vlt.sh benchmarks](https://benchmarks.vlt.sh/) (2026-06-11 run, pacquet 0.11.3) show pacquet several times slower than the fastest package managers in the warm-metadata fresh-resolve cells (`cache`: 3.9–8.1x), the cold-cache frozen-install cells (`lockfile`: up to 10x on vue), and `clean`. Profiling the babylon and vue fixtures locally (macOS time profiles of the warm fresh resolve and the install tail) surfaced three independent causes, fixed here.
## Changes
### 1. Deprecation probing without manifest hydration (pacquet)
With `minimumReleaseAge` active (the default), every range pick runs `filter_pkg_metadata_by_publish_date`, and any dist-tag pointing outside the maturity cutoff (`next`, `beta`, `canary`, a too-fresh `latest`) repopulates by scanning all candidates and reading each candidate's `deprecated` flag. Each read hydrated the full version manifest — a complete `serde_json` parse including the flattened catch-all map. On babylon's warm fresh resolve this was the single largest CPU consumer (~10 thread-seconds, all on the resolve task's critical path).
`PackageVersions::is_deprecated` now answers from the raw fragment (substring pre-check, then a single-field deserialize with the same normalization as `PackageVersion::deprecated`), the tag-repopulation loop parses candidate versions once per filter call (mirroring the `parsedSemverCache` in pnpm's `filterPkgMetadataByPublishDate`), and the deprecated-pick fallback uses the probe instead of hydrating every version.
**babylon warm fresh resolve: `resolve_workspace` 7.5s → 2.6s.**
### 2. Relative-symlink up-to-date check (pacquet)
`force_symlink_dir` joined an existing link's relative contents onto the link parent and compared the result *verbatim* against the wanted target. Virtual-store links contain `..` segments (`../../<pkg>/node_modules/<name>`), so the joined path never compared equal and every up-to-date symlink was unlinked and recreated. Node's `path.relative` — which upstream `symlink-dir`'s `isExistingSymlinkUpToDate` builds on — resolves its arguments, so pnpm treats those links as current. Both sides now pass through `lexical_normalize`. The babylon install tail was dominated by exactly this unlink+symlink churn.
**babylon warm install: 6.8s → 4.7s; warm frozen install: 4.2s → 2.3s.**
### 3. Default network concurrency floor 16 → 64 (pnpm + pacquet)
The default was `min(64, max(workers * 3, 16))`. Downloads are I/O-bound, not CPU-bound: on a 4-vCPU CI runner the formula yields 16 concurrent requests, so a low-latency registry drains 600–1300-tarball installs 16 at a time while staying unsaturated — a large share of the cold-cell (`lockfile`/`clean`) gap on the benchmark runners. The default is now `min(96, max(workers * 3, 64))`; the `networkConcurrency` setting still overrides it. Applied to `@pnpm/installing.package-requester`, the lockfile-resolution verifier fan-out that mirrors its floor, and the same two spots in pacquet. Changeset included (minor). **This is a user-visible defaults change on both stacks — flagging it explicitly for review.**
## Local results (M-series macOS, vlt fixtures, isolated store/cache)
| cell | before | after |
|---|---|---|
| vue `cache` | 1159 ms | **479 ms** |
| vue `cache+lockfile` | 621 ms | **392 ms** |
| vue no-op install | 48 ms | **41 ms** |
| babylon `cache` | ~8.8 s | **4.75 s** |
| babylon `cache+lockfile` | ~4.2 s | **2.27 s** |
vue's warm cells are now ahead of every competitor measured locally; babylon's `cache` cell closed from ~2.5x behind the leader to ~1.35x (the remainder is the per-file store-integrity verify and per-file linking that the pnpm store contract requires).
## Validation
- `cargo nextest`: registry, resolving-npm-resolver, resolving-deps-resolver, lockfile-verification, network, fs, tarball, package-manager, cli — 1300+ tests, all green; new unit tests cover the deprecation probe (string/bool/empty/corrupt shapes, nested-key false positives) and cross-parent relative-symlink reuse (fails without the fix).
- Lockfile stability: `--lockfile-only` output is byte-identical before/after on vue; on babylon the resolved **package-version sets are identical across 6 runs (3 per binary)**. The babylon lockfile does flap between runs in the peer-suffix shape of `webpack-dev-server@5.2.2` (`(bufferutil@4.1.0)(utf-8-validate@5.0.10)` appearing/disappearing) — this is **pre-existing nondeterminism** reproducible with the unmodified binary against itself, in the optional-peer area; worth a separate issue.
- Pre-push checks (fmt, taplo, `cargo doc -D warnings`, dylint) pass; eslint (root config) and `tsgo --build` pass for the two touched TS packages.
Summary:
- add Linux musl binary package selection to the pacquet and pnpr npm shims
- generate linux-x64-musl and linux-arm64-musl native npm packages with libc metadata
- build musl Rust release targets for both pacquet and pnpr
- update package docs and cspell entries for the touched workflow files
Follow-up to #12292 (which verifies the **package-manager** binary). This closes the same class of gap for the **Node.js runtime**.
When a repository requests a Node.js runtime — `devEngines.runtime: node@X` (with `onFail: download`, the default) or `useNodeVersion` — pnpm downloads and then executes a Node binary (it's used to run lifecycle / `run` / `exec` scripts). The download **mirror is repository-configurable** via `node-mirror:<channel>` (`nodeDownloadMirrors`) in project `.npmrc`, and the integrity comes from `SHASUMS256.txt` fetched **from that same mirror**.
That's a circular check: a malicious mirror serves a tampered `node` tarball **and** a matching `SHASUMS256.txt`, the sha256 check passes, and pnpm runs the binary. Drive-by on a normal command in a cloned repo.
## Fix
pnpm now fetches `SHASUMS256.txt.sig` and verifies its **detached OpenPGP signature** against the **Node.js release team's public keys, embedded in the pnpm CLI**, before trusting the hashes. A mirror that serves a tampered binary cannot also produce a valid signature, so verification fails. Any faithful mirror (one that proxies the real signed SHASUMS) keeps working.
- `@pnpm/crypto.shasums-file`: new `fetchVerifiedNodeShasums` / `fetchVerifiedNodeShasumsFile` verify the signature via `openpgp` against the embedded keys.
- The keys live in a generated file (`src/nodeReleaseKeys.ts`, 28 keys) mirrored from the canonical `nodejs/release-keys` list. `crypto/shasums-file/scripts/update-node-release-keys.mjs` keeps them current (`pnpm check:node-release-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate so a new release signer can't silently break verification.
- `@pnpm/engine.runtime.node-resolver` verifies the **configurable-mirror** SHASUMS. The hardcoded `unofficial-builds.nodejs.org` musl mirror is **not** repo-configurable and is signed by a different key, so it stays trusted over TLS.
## Scope
- **Pre-release channels (rc, nightly, …) are not verified** — Node only signs the `release` channel (no `SHASUMS256.txt.sig` exists for them, even on nodejs.org), so they remain unverifiable. Verification is gated on the `release` channel.
- **Bun / Deno are unaffected** — their download/SHASUMS URLs are hardcoded to canonical GitHub (`github.com/oven-sh/bun`, `api.github.com/repos/denoland/deno`), not mirror-configurable, so a repo can't redirect them.
- **Pacquet parity:** `pacquet/crates/engine-runtime-node-resolver` has the same mirror-configurable SHASUMS logic and needs the equivalent Rust port — tracked as a follow-up (per the repo's parity rule, opening the TS side first).
pnpm can be made to download and execute a native binary through two **repository-controlled** inputs, neither of which was authenticated before this change:
1. **pacquet install engine** — declaring `pacquet` (or `@pnpm/pacquet`) in `configDependencies` (in `pnpm-workspace.yaml`) opts in to pnpm's Rust install engine, and pnpm spawns the platform binary `@pacquet/<platform>-<arch>` during `pnpm install`.
2. **package-manager version switch** — the `packageManager` / `devEngines.packageManager` field makes pnpm download and run a specific pnpm version. This is **on by default** (`onFail` defaults to `download`) and also covers `pnpm self-update` and `pnpm with`.
In both cases the repository also controls the lockfile integrity and the registry the bytes are fetched from (via `.npmrc`), so matching the lockfile integrity proves nothing — it matches the hash the attacker wrote. A cloned, untrusted repository could therefore execute an arbitrary native binary just by running a normal pnpm command.
## Fix (corepack-style registry-signature verification)
pnpm now verifies the **npm registry signature** of the bytes it is about to spawn, **over the installed integrity**, against npm's public signing keys that **ship embedded in the pnpm CLI** (exactly as corepack does). If the bytes on disk were substituted or tampered with, npm's real signature does not validate over them.
- New reusable `verifyInstalledPackageSignatures()` in `@pnpm/deps.security.signatures` verifies `name@version:integrity` against `dist.signatures` using the embedded keys.
- Because the keys are **embedded** (not fetched), a registry the user did not vouch for cannot supply its own keypair to forge a signature. The signed packument is fetched from the **configured** registry, so an **npm mirror works transparently** — it proxies the same signed packument, with no configuration. There is intentionally **no runtime override or off-switch** for the keys.
- **pacquet** (`installing/commands`): verifies the `pacquet` shim and the host platform binary. It **fails the command** if the signature does not verify or cannot be checked (e.g. registry unreachable); the only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.
- **pnpm engine** (`engine/pm/commands`): verifies `pnpm`, `@pnpm/exe`, and the host platform binary, **only on a store cache miss** (an actual download), so it adds no network round trip to every command. It **fails closed** — any verification failure, including an unreachable registry, refuses the version switch rather than running an unverified binary.
## Keeping the embedded keys fresh
The embedded keys live in a generated file. `deps/security/signatures/scripts/update-npm-signing-keys.mjs` keeps them in sync with npm's keys endpoint (`pnpm check:npm-signing-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate, so a key rotation cannot silently break verification — a stale key set blocks the release until refreshed.
## Pacquet parity
pacquet gained `configDependencies` support on `main` (#12285), but it has **no install-engine-spawn sink** — pacquet *is* the engine, and it does not select/spawn an alternate engine from `configDependencies` (its only config-dependency code-execution path is `updateConfig` plugin pnpmfiles, which it shares with pnpm and which this advisory does not cover). So CAND-PNPM-097 has no pacquet-side analog; no pacquet code change is needed.
- align pacquet peer parent context handling with pnpm for same-package child providers and peer diamonds
- keep optional cached peer resolutions bubbling to later parents without an explicit provider, matching the jest-config and @types/node case
- preserve pnpm's duplicated peer-suffix segments for aliased providers that resolve to the same package
- include aliased child providers when their real package name is peer-relevant, not only when the install alias is peer-relevant
- limit importer-seeded peer parent refs to alias/real names that can affect peer resolution, reducing clone overhead
- add focused resolver tests for the issue 12272 lockfile mismatch and related diamond/alias-provider behavior
- port the six related pnpm CLI alias-peer install cases to Pacquet CLI tests
- pin the alias-peer CLI test peer suffix length so exact lockfile suffix assertions are independent of global config
- update the Pacquet coverage upload to codecov-action v6.0.2 so it uses Codecov's current signing-key configuration
Fixespnpm/pnpm#12272.
Refs: pnpm/pnpm#12250
- share warm/cold virtual-store slot linking through one parallel helper
- emit structured pacquet install phase metrics for virtual-store partition sizes and link-slot elapsed time
- generate integrated-benchmark diagnostics artifacts and use them for the fresh pnpr cold-batch and pnpr-vs-direct guardrails
- split client registry latency/bandwidth from pnpr server registry latency, while rewriting server-origin tarball URLs back to the measured client registry path
- keep the benchmark PR comment focused on scenario tables and collapsed raw JSON; diagnostics stay available as artifacts instead of inline report noise
Closes#12234.
This PR has two parts. The headline turned out to be the **benchmark**, not the feature.
## Part 1 — `/v1/resolve` streaming (the #12234 feature)
`POST /v1/resolve` now streams **NDJSON** instead of buffering the whole lockfile: one `package` frame per resolved tarball as the server's tree walk yields it, then a terminal `done` (full lockfile + stats), `error`, or `violations` frame. The client fetches each tarball as its frame arrives, overlapping the server's resolution — matching the native `PrefetchingResolver` shape.
- Server: new `ResolutionObserver`/`ObservingResolver` in `package-manager`, threaded through `Install`; `handle_resolve` runs the resolve in a detached task whose observer pushes frames into the response channel; `application/x-ndjson` is excluded from the gzip layer so frames flush incrementally.
- Client: `resolve_streaming(opts, on_package)`; `install_via_pnpr` drives a new `TarballPrefetcher` that warms the shared mem cache as frames arrive.
- Breaking change within protocol v1 (no version bump — experimental pnpr allows it).
**Honest caveat:** streaming only helps when the *server's* resolve is slow (cold/distant server). Against a warm server it's inert — see the results below (`pnpr@HEAD` ≈ `pnpr@main` everywhere). Whether to keep this commit or defer it is an open question; the benchmark fixes below stand on their own.
## Part 2 — make the integrated benchmark actually measure pnpr
While validating Part 1, the benchmark turned out not to be exercising pnpr **at all**, plus it was serving the registry far faster than reality. Fixes:
- **`pnpr@<rev>` targets never routed through pnpr.** `.pnpr-env` exported a bare `PNPR_SERVER`, but pacquet reads config env vars only under the `PNPM_CONFIG_*` prefix, so `config.pnpr_server` was always `None` and every pnpr row was a silent duplicate of its direct row. Fixed the env var name; added a post-run guard that fails the benchmark if a pnpr target's `pnpr-storage` is empty (proof it never served a resolve).
- **Emulate a real registry link for every client.** The latency proxy modeled RTT but not throughput, and fronted only direct targets. Generalized it to a `LinkProfile` (one-way delay + per-direction bandwidth cap), added `--registry-bandwidth-mbps`, and routed *all* registry traffic through it (direct installs, the pnpr server's resolve, the pnpr client's fetches) so the registry-mock is uniformly as remote as real npm. CI runs it at 50 ms + 200 Mbit/s (≈ the measured public-npm peak).
- **Make "cold cache" cold for resolution.** Forced `cacheDir` bench-local and wipe it in cold-cache scenarios, so a direct install actually pays the packument-fetch waterfall (previously the global metadata mirror survived every wipe).
- **New scenario** `fresh-install.cold-cache.hot-store` that isolates resolution (cold metadata, hot store → no download to mask it).
## Results (Linux CI, after the fixes)
| Scenario | pacquet@HEAD | pnpr@HEAD | pnpr speedup |
|---|---:|---:|---:|
| fresh-install · cold cache · **hot store** | 5.06 s | 0.69 s | **7.4×** |
| fresh-install · cold cache · cold store | 5.36 s | 2.03 s | **2.65×** |
| fresh-install · hot cache · hot store | 1.46 s | 0.69 s | **2.1×** |
| fresh-restore (frozen) · cold cache · cold store | 10.08 s | 5.13 s | **2.0×** |
| fresh-restore (frozen) · hot cache · hot store | 0.71 s | 0.80 s | 0.89× (slower) |
pnpr offloads the client's resolution (and, on the frozen path, lockfile verification) to its warm server: 2–7× faster wherever the client would otherwise pay that cost. The lone regression is the fully-warm frozen install, where there's nothing to offload and pnpr's one round trip is pure overhead. `pnpr@HEAD` vs `pnpr@main` is flat throughout — i.e. the streaming commit (Part 1) adds ~nothing against a warm server, while the base pnpr win (offload to a warm server) is large.
Releases now land via a PR from an ad-hoc branch rather than a commit
pushed straight to the target, so `bump.ts` keyed the ledger off the
ephemeral PR branch and scattered each release into its own file instead
of accumulating in `main.txt`.
Recover the target from a `release-pr/<target>` branch name, add a
manually-dispatched workflow that creates such a PR, and drop the
accumulated per-PR ledger files (verified inert: no overlap with pending
changesets, no live branch reintroduces a released changeset on merge).
The integrated-benchmark "Precompile benchmark revisions" step took ~14
minutes every run. Two causes:
1. The "Cache Rust builds" step cached the multi-GB
`bench-work-env/*/pacquet/target` dirs under a 1-minute restore
timeout. A restore that large never finished in 60s, so (with
`continue-on-error`) the cache silently missed and every run built all
four targets cold.
2. `pacquet@HEAD` and `pnpr@HEAD` resolve to the same commit but built in
separate clones, compiling the `pacquet` binary twice (same for main).
Cache the compiled binaries per *resolved commit* instead:
- Orchestrator: a `--reuse-prebuilt-binaries` flag skips the clone +
`cargo build` for a target whose output binary is already present (i.e.
restored from cache). Targets are built pnpr-first; since a `pnpr@<rev>`
build also produces the `pacquet` client binary, a same-revision
`pacquet@<rev>` reuses it by copy rather than recompiling the commit.
- Workflow: resolve the HEAD/main SHAs, then cache the two `pnpr@<rev>`
binaries keyed on the commit (they cover all four targets via the
dedup-copy). `main` is a near-certain hit on PRs (stable SHA) and a
same-HEAD re-run hits HEAD too, so only a fresh HEAD compiles. Drop the
giant `bench-work-env/*/pacquet/target` cache (the small binary caches
restore in seconds, with no eviction risk) and keep a cargo-deps +
orchestrator-target cache with a realistic 3-minute timeout.
A fresh-HEAD run now compiles one workspace once (~half the old work);
re-runs and main reuse cached binaries and skip compilation entirely.
## What
The pnpr install accelerator is a **remote** server, but the integrated benchmark ran it on **loopback** (RTT ≈ 0), which hides the round-trip cost that dominates a real install — and that pnpr exists to reduce. This injects network latency so the benchmark measures pnpr as the remote service it is in production.
## How
A dependency-free, synchronous latency-injecting TCP proxy (`latency_proxy`) plus two knobs on `integrated-benchmark`:
- **`--pnpr-latency-ms`** — fronts each `pnpr@<rev>` server, so the client↔server link pays the given round trip (half each direction).
- **`--registry-latency-ms`** — fronts the registry for the direct (`pacquet`/`pnpm`/`--with-pnpm`) targets, so a direct install crosses the same network.
`pnpr@<rev>` targets keep a **direct (fast) registry link** — that models a warm, colocated server, so pnpr's advantage shows up as **fewer round trips, not a faster backend**:
```
direct target: client → [latency proxy] → registry
pnpr target: client → [latency proxy] → pnpr server → (direct) → registry
```
The workflow sets both equal (`50ms`) so the in-run pnpr-vs-direct ratio is fair and the `pnpr` Bencher testbed (pnpr@HEAD vs pnpr@main) becomes **sensitive to protocol round-trip-count changes** — which is what makes the upcoming protocol work (collapsing the 3-round-trip handshake/install/files flow) measurable on main. See #12165 for that plan.
## Notes
- **Latency only, no bandwidth cap.** The public registry is CDN-backed and CI runners are fast, so install time is latency/round-trip bound, not throughput bound — a bandwidth cap would be overly pessimistic. A high-ceiling, opt-in bandwidth knob can follow if a slow-link scenario is ever wanted.
- Both flags **default to `0`** (current behavior unchanged); the registry proxy is also skipped in `--registry=npm` mode (already remote).
- The proxy is unit-tested (a round trip through it reflects the injected latency). `cargo check`/`clippy`/`fmt`/`dylint` clean.
- One caveat the proxy does **not** model: TLS-handshake round trips and HTTP/2 multiplexing of a real CDN — it reproduces propagation delay, the dominant and relevant factor here, not a byte-exact replica of registry.npmjs.org.
* ci(pnpr): add pnpr@<rev> target + Bencher testbed for the install accelerator
Measures the pnpr-accelerated install path end to end. A new `pnpr@<rev>`
target in the integrated-benchmark orchestrator builds both the `pacquet`
client and the `pnpr` server from the revision's monorepo clone, boots a
per-target pnpr server with an isolated `--storage`, and points the client
at it via `PNPR_SERVER`.
Reusing the existing multi-target hyperfine model gives both comparisons:
- `pnpr@HEAD pacquet@HEAD` -> pnpr-vs-direct ratio in one run (same client,
with and without the accelerator).
- `pnpr@HEAD pnpr@main` -> regression delta tracked in a new Bencher `pnpr`
testbed.
Two CI workflows mirror the fork-safe two-stage pacquet pattern, triggered
on pnpr/**, pacquet/crates/pnpr-client/**, and pacquet/crates/config/**
(the pnprServer plumbing), running the hot-cache/hot-store restore and
fresh-install scenarios that model a warm long-running server.
* ci(pnpr): fold the install-accelerator bench into the pacquet workflow
The pnpr server is built from the pacquet resolver/store/tarball crates,
so any pacquet change can move the pnpr-accelerated numbers as much as the
direct ones. That means the two benchmarks share a trigger surface and
should co-run — so rather than a separate pnpr workflow posting a second
comment on every pacquet PR, measure both in one run.
The pacquet integrated-benchmark workflow now also runs `pnpr@<rev>`
targets in the two hot-cache/hot-store scenarios (a warm long-running
server is pnpr's realistic shape), emits one combined report/comment, and
uploads to two Bencher testbeds: `pacquet` (direct, all scenarios) and
`pnpr` (accelerated, hot scenarios). The trigger gains `pnpr/**`.
Deletes the standalone pnpr-integrated-benchmark{,-comment}.yml added
earlier in this branch.
* ci(pnpr): also benchmark pnpr with a cold client store
Run the pnpr targets in the cold-cache/cold-store scenarios too, not just
the hot ones. Those scenarios already wipe the client store between
iterations while the per-target pnpr server store stays warm, so this
measures pnpr's cold-client-vs-warm-server shape — the realistic CI case
(empty local store hitting a warm shared server) — alongside the existing
hot-client numbers.
Both tools now run all four scenarios, so the report tables and both
Bencher testbeds (pacquet, pnpr) cover cold and hot. Collapses the two
target-list env vars into one and bumps the cold-step timeouts for the
extra commands. Table rendering is unchanged.
* ci(pnpr): address PR review feedback
- work_env: wrap the spawned pnpr child in its PnprServer guard before the
readiness wait and .pnpr-env write, so an early panic kills the process
on unwind instead of leaking an orphaned server (Copilot).
- cli_args: document pnpr@<rev> in the `targets` --help text (CodeRabbit).
- workflows: guard each bencher upload on its file existing, so a missing
optional results file logs a notice instead of failing the step (Copilot).
The experimental TypeScript `pnpm-agent` install-accelerator server is
superseded by the `pnpr` server, which implements the same protocol.
Remove `agent/server` and route the agent e2e test through pnpr.
The pnpm TypeScript client (`@pnpm/agent.client`) is kept and made
compatible with pnpr. The wire protocol carries the on-disk lockfile
format, while pnpm keeps an in-memory `LockfileObject` in process:
- Incoming: the agent's response lockfile is converted to the in-memory
shape via `convertToLockfileObject`.
- Outgoing: the existing lockfile is read in its on-disk shape with the
new `readWantedLockfileFile` and forwarded as-is — no in-memory
round-trip.
pnpr now resolves multi-project workspaces by reconstructing the
workspace on disk (root manifest + `pnpm-workspace.yaml` + member
manifests) and letting pacquet's install path discover every importer.
Member dirs are written as quoted YAML scalars; importer dirs are
validated against path traversal (rejecting absolute, `..`, backslash,
and slashes-only inputs) and de-duplicated; synthetic manifest names
map injectively from dirs.
The CI test job builds the `pnpr` server from source (cached on the
Rust sources) so the agent e2e tests run against the current server.
The published `@pnpm/pnpr` is dropped as a test dependency: running the
suite already requires building `pnpr-prepare` from source (no npm
fallback), so the toolchain to build `pnpr` is always present, and the
published binary can predate the server protocol the tests exercise.
* chore: also bump Node.js, pnpm, and pacquet in update-lockfile workflow
* chore: address PR review feedback on update-lockfile workflow
- Base the update branch on an explicitly fetched origin/main
- Don't persist the write token during install; push with explicit URL
- Detect open PRs via gh --json instead of grepping table output
- Add a concurrency guard to serialize dispatch + scheduled runs
* chore: update pnpm-lock.yaml
* chore: sync node.js runtime version in scripts with devEngines via meta-updater
* chore: sync node.js version in CI workflows with devEngines via meta-updater
Implements Tier 4 pnpmfile hooks for pacquet (#11633, point 4.1): pacquet now discovers and runs a project `.pnpmfile` during dependency management, matching pnpm.
## What it does
- **Discovery** — looks for `.pnpmfile.mjs` then `.pnpmfile.cjs` (dotted names only, `.mjs` preferred), matching pnpm's `requireHooks`. Only actual files are accepted (`is_file()`).
- **`readPackage`** — wired into resolution. Mirrors pnpm's `requirePnpmfile` contract: the four dependency fields are defaulted to `{}` before the hook runs, and the returned manifest is validated (must be a non-null object whose dependency fields, when present, are objects rather than arrays). A throwing/syntactically-invalid pnpmfile, a missing `require`, or a hook that returns nothing aborts the install (`PNPMFILE_FAIL`) instead of being silently ignored.
- **`afterAllResolved`** — wired into the lockfile write. The resolved lockfile is passed to the hook and its return value is what gets written to `pnpm-lock.yaml`. The round-trip goes through `serde_json::Value` (the workspace already enables `preserve_order`) so hook-added keys the typed `Lockfile` cannot represent survive to disk; the round-trip only runs when a hook is present, so unmodified installs write byte-identical lockfiles. A throwing hook aborts the install.
- **`preResolution`** — wired. Receives the resolution context (wanted/current lockfile, `existsCurrentLockfile`, `existsNonEmptyWantedLockfile`, lockfile dir, store dir, registries) over stdin.
- **`filterLog`** — implemented in the bridge but not yet routed through the reporter (pacquet's reporter is a stateless synchronous emitter); deferred, see follow-ups.
## How hooks run
Hooks are served by a long-lived Node.js worker, spawned lazily once per pnpmfile. Requests and responses are newline-delimited JSON over the worker's stdin/stdout, multiplexed by a monotonic request id so the concurrent `readPackage` calls the resolver makes (it resolves dependencies in parallel) share one process. This removes the per-package `node` startup cost on the resolution hot path and avoids interpolating payloads into a `node -e` argument (no `E2BIG` risk for large lockfiles). Each `context.log(...)` a hook emits is forwarded back to the call's log callback. `preResolution` keeps a one-shot `node` invocation since it runs once per install and needs an `info`/`warn` logger.
## Tests
- Unit (hooks crate): readPackage validation (returns nothing / non-object / array dependency fields), manifest-field normalization, syntax-error and missing-module failures, worker request-id multiplexing under concurrency, and `context.log` forwarding.
- Integration (package-manager): a `readPackage` hook pins a transitive dependency version; a hook that returns nothing aborts the install; a pnpmfile syntax error aborts the install; an `afterAllResolved` hook's mutation is written to `pnpm-lock.yaml`; a throwing `afterAllResolved` aborts the install.
## Scope
The remaining pnpmfile-hook surface pnpm has but pacquet does not yet implement — wiring `filterLog` and the `pnpm:hook` log channel into the reporter, the `--pnpmfile` / `--global-pnpmfile` / `--ignore-pnpmfile` flags, pnpmfile checksum invalidation, `updateConfig`, and finders/resolvers/fetchers — is tracked in #12118.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* refactor(pnpr): rename pnpm-registry to pnpr
Rename the registry server across the board to match the npm wrapper
package name, which was already `@pnpm/pnpr`.
- crate `pnpm-registry` -> `pnpr`, `pnpm-registry-fixtures` -> `pnpr-fixtures`
- binaries `pnpm-registry` -> `pnpr`, `pnpm-registry-prepare` -> `pnpr-prepare`
- module paths and log targets `pnpm_registry::*` -> `pnpr::*`
- binary-locating env vars `PNPM_REGISTRY_BIN` -> `PNPR_BIN`,
`PNPM_REGISTRY_PREPARE_BIN` -> `PNPR_PREPARE_BIN`
- top-level directory `registry/` -> `pnpr/` (crates, npm wrapper, fixtures)
The registry-mock storage concept is intentionally left as-is:
`PNPM_REGISTRY_MOCK_PORT`/`PNPM_REGISTRY_MOCK_STORAGE`/`PNPM_REGISTRY_STORAGE`,
the `~/.cache/pnpm-registry/storage` path + benchmark cache keys, and the
external `pnpm-registry-mock` npm package referenced in test fixtures.
* style(pnpr): rustfmt import grouping after rename
* ci(pnpr): point typos at pnpr instead of removed registry dir
* chore(pnpr): update pre-push path filter from registry to pnpr
Replace the external `@pnpm/registry-mock` (Verdaccio) test dependency with an in-repo, in-process registry that serves package fixtures to **both** the pacquet Rust tests and the pnpm CLI (Jest) tests. No separately managed registry process is needed.
### How it works
- **Fixtures** live at `registry/.fixtures/packages/<name>/<version>/…`, moved verbatim from [`pnpm/registry-mock`](https://github.com/pnpm/registry-mock) (keyed by each `package.json`'s `name`+`version`).
- **`pnpm-registry-fixtures`** builds verdaccio-shaped storage from those fixtures; the in-tree **`pnpm-registry`** crate serves it.
- Files whose names differ only by case (`@pnpm.e2e/with-same-file-in-different-cases`) and `bundleDependencies` trees are composed **in memory** by the builder, since neither can be committed to the working tree.
- **pacquet**: `pacquet-testing-utils`' `TestRegistry` starts the server lazily (once per process) in proxy mode, serving `@pnpm.e2e` fixtures locally and falling through to the npm uplink for real packages (`is-positive`, `is-negative`, …) — matching how registry-mock behaved.
- **pnpm CLI**: the `with-registry` Jest `globalSetup` builds storage from the fixtures via the new `pnpm-registry-prepare` binary (built from source in the Test CI job) and serves it with `pnpm-registry`. `REGISTRY_MOCK_PORT` / `REGISTRY_MOCK_CREDENTIALS` / `getIntegrity` now come from `@pnpm/testing.registry-mock`.
### Result
`@pnpm/registry-mock` is removed from every manifest, the catalog, and `packageExtensions`; `cargo test` / `cargo nextest run` / `just test` and the pnpm CLI Jest suites all run registry-backed tests without launching Verdaccio.
The pacquet release workflow patched the clap `version` attribute in
cli_args.rs, expecting a string literal. Since #12047 that attribute
references the `pacquet_config::PACQUET_VERSION` constant, so the perl
substitution matched nothing and the verifying grep failed, aborting the
whole release.
Patch the `PACQUET_VERSION` constant in defaults.rs instead. That single
constant feeds both `pacquet --version` and the default User-Agent, so
both report the published version.
Also tag the default User-Agent as `pnpm/pacquet-<version>` so registries
can tell pacquet's traffic apart from the TypeScript pnpm CLI, while
keeping the `pnpm` token for UA-keyed allow / rate-limit rules.
trailofbits/dylint 6.0.1 (published 2026-05-26 17:51 UTC) ships
prebuilt cargo-binstall artifacts that bake in a path from the
dylint repo's own CI workspace:
error: failed to get `dylint_driver` as a dependency of package
`dylint_driver-nightly-2026-04-16-x86_64-unknown-linux-gnu`
Caused by:
failed to read `/home/runner/work/dylint/dylint/driver/Cargo.toml`
Caused by:
No such file or directory (os error 2)
Downstream runners don't have that workspace, so the driver bootstrap
fails before any lint runs and the Dylint job goes red on every PR.
6.0.0 (the version main was passing with 90 minutes earlier) is
unaffected. Pin both binaries until upstream cuts 6.0.2.
---
Written by an agent (Claude Code, claude-opus-4-7).
The e2e/integration test harness spawns `pnpm-registry` as a faster
verdaccio replacement. CI used to install Rust and build the crate
from source on every test job — adding several minutes per platform.
`@pnpm/pnpr` now publishes the prebuilt binary to npm, and `pnpm install`
already pulls in the matching `@pnpm/pnpr.<platform>-<arch>` package
via optionalDependencies. The Jest globalSetup resolves that binary
through `@pnpm/pnpr/bin/pnpr`'s own module path (the wrapper carries
the platform packages as siblings in its `node_modules`, not on the
parent chain of this file).
- Add `@pnpm/pnpr` to `pnpm-workspace.yaml` catalog and depend on it
from `@pnpm/jest-config`.
- Replace `resolvePnpmRegistryBin`'s `$CARGO_TARGET_DIR` lookup with
`require.resolve` through the npm-installed wrapper. The
`PNPM_REGISTRY_BIN` env var is still honored as an escape hatch for
contributors who want to point at a locally-built Rust binary.
- Remove the "Install Rust toolchain" + "Build pnpm-registry" steps
from `.github/workflows/test.yml`.
---
Written by an agent (Claude Code, claude-opus-4-7).
* ci(pnpm-registry): add manual release workflow that publishes to npm
Mirror pacquet's release pipeline for the pnpm-registry crate:
- New `Release pnpm-registry` GitHub workflow (manual `workflow_dispatch`
with a version input) builds the `pnpm-registry` binary for six
`<os>-<cpu>` matrix legs via `cross`, attests provenance, and uploads
artifacts.
- The publish job patches `registry/npm/pnpm-registry/package.json`'s
`version` field with the input, downloads the artifacts, runs
`generate-packages.mjs` to produce per-platform packages, then
publishes everything via `pnpm publish --provenance` using OIDC.
- The wrapper package is `pnpm-registry`; the per-platform binary
packages are scoped `@pnpm/registry.<os>-<cpu>` and resolved by a
Node shim under `bin/pnpm-registry`.
The CLI's `--version` output comes from `CARGO_PKG_VERSION` via clap's
derive `version` attribute, so the build leg patches the crate's
`Cargo.toml` `version` field rather than a hardcoded clap string.
---
Written by an agent (Claude Code, claude-opus-4-7).
* ci(pnpr): rename package to @pnpm/pnpr with @pnpm/pnpr.<os>-<cpu> binaries
Shorter, scoped name that reads naturally on npm: `@pnpm/pnpr` for the
wrapper, `@pnpm/pnpr.<os>-<cpu>` for the six per-platform binary
packages (mirroring `@pacquet/<os>-<cpu>` under a different scope).
- Move `registry/npm/pnpm-registry/` to `registry/npm/pnpr/` and rename
the JS shim and `bin` entry to `pnpr`.
- Update `generate-packages.mjs` so `BIN_NAME = pnpr` (used for both
the binary file inside each platform package and the autogenerated
package dir) and the scope/prefix produce `@pnpm/pnpr.<os>-<cpu>`.
- Rename the workflow file to `pnpr-release-to-npm.yml` and rename
the built `pnpm-registry` binary to `pnpr-<code-target>` at archive
time, so `generate-packages.mjs` finds it at `REPO_ROOT/pnpr-<plat>-<arch>`.
- The Rust crate name stays `pnpm-registry` (still what `cross build -p`
targets); only the npm-publishing layer is renamed.
---
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pnpr): propagate signal exits through the Node shim
`spawnSync` returns `result.status === null` when the child terminates
via a signal. Assigning that to `process.exitCode` makes the parent
exit 0 and masks the signal — bad for a long-running server where
the operator is most likely to kill it with SIGINT/SIGTERM. Re-raise
the signal so the parent terminates the same way, falling back to a
non-zero exit code if for some reason we can't.
---
Written by an agent (Claude Code, claude-opus-4-7).
Lands the pieces of the npm registry protocol that pnpm-registry was missing, and switches the TypeScript test harness off verdaccio onto pnpm-registry. `@pnpm/registry-mock` (the npm package) is untouched.
### Server-side additions (`registry/crates/pnpm-registry`)
- `PUT /-/user/org.couchdb.user:<name>` — adduser / login, returns a Bearer token. In-memory user + token stores.
- `PUT /:pkg` — publish (scoped + unscoped). Base64-decodes `_attachments`, merges into the existing packument, writes manifest + tarball atomically. 100 MiB body limit.
- `GET /-/package/:pkg/dist-tags` + `PUT/DELETE /-/package/:pkg/dist-tags/:tag` — rewrites the on-disk packument so tag changes survive a restart.
- `Authorization: Bearer` and `Authorization: Basic` both identify the caller.
- Per-package access policy (wax glob patterns). Defaults mirror `@pnpm/registry-mock`'s `config.yaml`: `@private/*` and `@pnpm.e2e/needs-auth` require auth; everything else is anonymous read, authenticated write. Enforced on every packument / version-manifest / tarball GET and every write endpoint.
### TypeScript-test migration
- `__utils__/jest-config/with-registry/globalSetup.js` keeps `prepare()` from `@pnpm/registry-mock` (still needed for the tempy storage path written into the runtime-config yaml — `getIntegrity` reads it from there) but spawns `pnpm-registry` instead of verdaccio. `addUser`, `addDistTag`, `getIntegrity`, `REGISTRY_MOCK_*` from registry-mock work as-is — they're plain npm-wire-protocol HTTP calls.
- Binary lookup follows pacquet's pattern: `PNPM_REGISTRY_BIN` env override, then `target/release/pnpm-registry`, then `target/debug/pnpm-registry`.
- CI test job (`.github/workflows/test.yml`) installs the Rust toolchain via the existing `./.github/actions/rustup` composite action and builds `pnpm-registry --release` before tests run. Per-platform — Linux and Windows in the matrix each build their own.
The pnpm-CLI baseline was useful while pacquet was slower than pnpm; now
that pacquet is the perf target itself, comparing against pnpm on every
run is noise. Drop --with-pnpm from every scenario step.
When the workflow runs on main, HEAD and main point at the same commit,
so a HEAD-vs-main comparison is wasted work. Resolve the target list at
job level: pacquet@HEAD on main, pacquet@HEAD pacquet@main everywhere
else (PRs, workflow_dispatch from non-main). The Bencher upload already
filters to pacquet@HEAD, so the single-target result still lands on the
main baseline as before.
Creates a working pnpm-compatible npm registry server (verdaccio analogue, in Rust) — and replaces `@pnpm/registry-mock`'s Node + Verdaccio launcher in pacquet's test setup with the new binary, against `@pnpm/registry-mock`'s shipped storage.
### What `pnpm-registry` does
- **HTTP server** (axum + tower-http) with the three endpoints pnpm/npm clients need:
- `GET /<pkg>` — packument (`/{name}` and `/{scope}/{name}`)
- `GET /<pkg>/<version-or-tag>` — single-version manifest, resolves `dist-tags` and rewrites `dist.tarball` to point at this server
- `GET /<pkg>/-/<tarball>` — tarball, streamed
- **Two modes:**
- **Proxy** — fetches missing packuments/tarballs from a configurable upstream (defaults to `https://registry.npmjs.org`), caches to disk
- **Static** (`--static`) — serves the storage directory verbatim, 404s on cache miss
- **Verdaccio-shaped on-disk storage** (`<root>/<pkg>/package.json` + flat tarballs) — drop-in compatible with the storage `@pnpm/registry-mock` publishes
- **Tarball streaming** — cache hits stream off disk; cache misses tee upstream chunks into a temp file via an mpsc channel and forward them to the client at the same time, atomically renaming on success and abandoning on upstream error or client disconnect
- **Tuned HTTP client** — wraps `pacquet_network::ThrottledClient::new_for_installs()`, inheriting pnpm's tuned defaults (`User-Agent: pnpm`, HTTP/1.1, hickory DNS, connection-pool tuning, concurrency semaphore)
- **Gateway-style status mapping** — `is_timeout()` → 504, `is_connect()` → 503, everything else (incl. upstream 5xx) → 502. No proxy-side retry (the pnpm client already has `fetch-retries`; stacking retries would only multiply latency on real failures).
### What changed in pacquet
- `pacquet/tasks/registry-mock` now spawns `pnpm-registry` against `node_modules/@pnpm/registry-mock/registry/storage-cache` (proxy mode with `npmjs.org` upstream and a 1-year packument TTL — matching `@pnpm/registry-mock`'s `'**': proxy: npmjs` verdaccio config). No more Node, no more Verdaccio, no more `launch.mjs`, no more process-tree walk to kill child verdaccios.
- `@pnpm/registry-mock` stays as a devDep — only for the storage data it ships, not the launcher.
### Tests
- **36 pnpm-registry tests** (12 unit + 7 against `@pnpm/registry-mock` storage in static mode + 17 mockito-based proxy/cache/streaming): packument rewrite, version-manifest resolution, tarball streaming (large body, cache finalize, mid-stream upstream error, client disconnect mid-stream, concurrent fetches → one cache file), gateway status mapping (504/503/502), stale-cache fallback on upstream failure, TTL refresh, invalid-package-name 400, scoped vs unscoped routing.
- **Full pacquet test suite** (2043 tests) runs green against `pnpm-registry`-backed mock.
### CI
- `pacquet-ci.yml` and `pacquet-codecov.yml` path filters now include `registry/**` (so registry-only PRs trigger the workspace CI); typos checker covers `registry` too. The workflow name stays "Pacquet CI" but a header comment explains the intentional cross-stack scope.
- `just registry-mock launch` pre-builds with `cargo nextest run --no-run` (workspace-wide) so its fingerprint matches what `just test` will later need — without this, Windows MSVC fails with `os error 5` trying to re-link the running `pnpm-registry.exe`.
### Crates.io name reservations (from the original scaffold commit)
- [`pnpm-registry`](https://crates.io/crates/pnpm-registry) — published from this repo
- [`pnpm-registry-cli`](https://crates.io/crates/pnpm-registry-cli) / [`pnpm-registry-server`](https://crates.io/crates/pnpm-registry-server) — placeholder stubs, name reservation only
- Add `--start-point-clone-thresholds` to the non-main upload arms
so PR/feature branches inherit thresholds configured on main; pair
it with `--err` so a sample over the upper boundary fails the job.
- Add `checks: write` to the three workflows that call `bencher run`.
On main pushes (no `--ci-number`, not a PR event) Bencher falls back
to creating a GitHub Check on the commit; without the permission it
exits 1 with "Failed to create GitHub Check".
* ci(bencher): record benchmark results to Bencher
Tracks both stacks in one Bencher project (`pnpm`), under separate
testbeds (`pnpm`, `pacquet`).
- `benchmarks/bench.sh` emits a hyperfine-shaped `bencher-results.json`
combining the six pnpm scenarios.
- `benchmark.yml` adds `push: branches: [main]` so each merge updates
the `pnpm` baseline, then uploads via the Bencher CLI.
- `pacquet-integrated-benchmark.yml` adds the same main-push baseline
for `pacquet`, combines the four scenario JSONs into a Bencher
report, and stages it into the existing artifact.
- `pacquet-integrated-benchmark-comment.yml` uploads PR results from
the trusted `workflow_run` context so fork PRs are covered too.
Requires `BENCHER_API_TOKEN` in repo secrets; workflows no-op with a
`::notice::` if it's missing.
* ci(bencher): allow workflow_dispatch to upload pacquet results
The inline Bencher upload was gated to `event_name == 'push'`, which
meant manual dispatch from a feature branch ran the bench but skipped
the upload. Both push and workflow_dispatch execute in the base-repo
privilege context, so it's safe to upload from both — the fork-safety
gate only needs to keep `pull_request` runs out.
On dispatch from a non-main branch we record into the ref name with
`--start-point main --start-point-reset`, matching the pnpm bench's
branch policy.
* ci(bencher): treat workflow_dispatch on main as the main baseline
Two small fixes from PR review:
- `benchmark.yml`: manual dispatch from `main` was falling into the
non-main branch arm, recording `--branch main --start-point main
--start-point-reset` — equivalent to forking main from itself.
Treat it like a `push` event so a manual run on main updates the
baseline directly. Matches the pacquet workflow's branch policy.
- `bench.sh`: emit a stderr warning when `jq` is missing instead of
silently skipping `bencher-results.json`. Keeps behaviour optional
for local users but makes the skip discoverable.
* bench: rename scenarios with explicit state axes
Replaces the hyperfine-leaking names (`clean-install`, `frozen-lockfile`,
`peek`, `gvs-warm`, …) with a consistent grid that spells out every
state the benchmark depends on:
- "Fresh" — node_modules wiped at start (future variants will start
with a populated node_modules).
- "Install" vs "Restore" vs "Add new dep" — the work being measured.
- "hot/cold cache + hot/cold store" — both pnpm directories,
spelled out separately because they're distinct on disk.
- "isolated linker" — nodeLinker mode (future variants will cover
`hoisted` and `pnp`).
The slugs map directly from the clap-derived kebab-case names, so
`--scenario=fresh-restore-cold-cache-cold-store-isolated` is the new
CLI surface. Updates land across the Rust orchestrator
(`BenchmarkScenario`), `benchmarks/bench.sh`, the pacquet workflow,
`benchmarks/README.md`, and `pacquet/CONTRIBUTING.md` so the names
agree end-to-end.
Adds a justified `#[allow(clippy::enum_variant_names)]` on the enum
because every variant currently shares the `Fresh` prefix; the lint
will stop firing once `Filled*`/`Resynced*` counterparts land.
Bencher's stored history for the old benchmark names will become
orphaned and can be archived in the UI.
* bench: linker-first slug shape with dot-separated axes
Reshapes the scenario identifiers so the linker mode is the leading
group: `<linker>.<action>.<cache state>.<store state>`. Dots separate
the four axes the bench varies, and `isolated-linker.*` /
`gvs-linker.*` sort together in any dashboard that groups by prefix.
Future buckets (`hoisted-linker.*`, `pnp-linker.*`) will slot in
without disturbing the existing names.
GVS is its own top-level bucket rather than a sub-variant of
isolated — its perf profile differs enough to chart separately.
Renames:
- `clean-install` → `isolated-linker.fresh-install.cold-cache.cold-store`
- `full-resolution` → `isolated-linker.fresh-install.hot-cache.hot-store`
- `frozen-lockfile` → `isolated-linker.fresh-restore.cold-cache.cold-store`
- `frozen-lockfile-hot-cache` → `isolated-linker.fresh-restore.hot-cache.hot-store`
- `peek` → `isolated-linker.fresh-add-dep.hot-cache.hot-store`
- `gvs-warm` → `gvs-linker.fresh-restore.hot-cache.hot-store`
Each Rust variant now carries `#[value(name = "…")]` so clap accepts
the dotted CLI form (`--scenario=isolated-linker.fresh-install.cold-cache.cold-store`).
Display labels follow the slug structure: `Isolated linker: fresh
install, cold cache + cold store` and `GVS linker: fresh restore,
hot cache + hot store`.
The `#[allow(clippy::enum_variant_names)]` is renewed; 5 of 6 variants
share the `Isolated` prefix today. Once `Hoisted*` / `Pnp*` buckets
land the lint will stop firing on its own.
* style: apply rustfmt after scenario rename
The longer match-arm pattern produced by the linker-first rename
exceeded the rustfmt width budget. Auto-format breaks the
`&["install", "--frozen-lockfile"]` body onto its own line so the
arm stays within the limit.
* ci(benchmark): run all six integrated-benchmark scenarios
Wires `clean-install`, `full-resolution`, `peek`, and `gvs-warm` into
`pacquet-integrated-benchmark.yml` so per-PR runs cover the same scenario
set the manual `benchmark.yml` workflow already exercises via
`benchmarks/bench.sh`. Requested for #11837, where the perf delta affects
the resolution-bound scenarios (`firstInstall`, `withWarmCache`,
`withWarmModules`, `updatedDependencies`) that the prior two-scenario set
did not measure.
Each scenario gets its own step with a 10 min hyperfine timeout (same
rationale as the existing steps) and writes per-scenario report copies
that the summary step concatenates into `SUMMARY.md`.
* ci(benchmark): drop peek and gvs-warm scenarios
Keep only the two new no-lockfile scenarios (`clean-install`,
`full-resolution`) on top of the existing `frozen-lockfile` and
`frozen-lockfile-hot-cache`. #11837's perf change is in the
fresh-lockfile install path, which only runs when resolution runs — i.e.,
exactly the no-lockfile scenarios. `peek` mutates an existing lockfile
and `gvs-warm` is a frozen-lockfile variant; neither exercises the
affected path, and including them only costs per-PR CI wall time.
* fix(bench): pin packages: ['.'] in synthesized pnpm-workspace.yaml
The integrated-benchmark clones each pacquet revision's source tree into
`<bench_dir>/pacquet/`, which on the pnpm/pnpm monorepo includes upstream
test fixtures like
`workspace/project-manifest-reader/__fixtures__/invalid-package-json/package.json`
— intentionally malformed JSON used to exercise pnpm's manifest reader.
Without a `packages:` field, both pnpm's `findPackages.ts:28` and
pacquet's `crates/workspace/src/projects.rs:128` default to `[".", "**"]`,
so the fresh-resolve install path's `find_workspace_projects` walk
descends into the cloned source tree and trips on the bad fixture:
Error: pacquet_package_manifest::serialization_error
× installing dependencies
╰─▶ expected `,` or `}` at line 3 column 3
The walk only runs on the fresh-lockfile branch (`install.rs:628-630`),
which is why frozen-lockfile and frozen-lockfile-hot-cache stay green
while clean-install and full-resolution fail every time.
Pin `packages: ['.']` in the synthesized manifest so enumeration stays
at the workspace root. The benchmark's installs are single-project,
so this doesn't narrow anything the install actually needed to see.
Fixtures supplied via `--fixture-dir` that already declare `packages:`
keep their own value.
* ci(benchmark): bump no-lockfile scenarios to 20 min
Clean-install and full-resolution go through pacquet's fresh-resolve
install path, which is currently ~3-5x slower than pnpm on the
`alotta-files` fixture (pnpm/pnpm#11832). Hyperfine's default 1 warmup
+ 10 timed runs across three benchmark targets (pacquet@HEAD,
pacquet@main, system pnpm) projects to ~13 min wallclock for these
two scenarios, putting the previous 10 min cap right on the edge.
Doubling to 20 min keeps the per-step timeout meaningful as a stuck-
install detector without losing CI time when the bench is healthy.
The frozen-lockfile steps stay at 10 min — they don't traverse the
slower fresh-resolve path.
* fix(bench): drop --no-frozen-lockfile from full-resolution scenario
Pacquet doesn't expose `--no-frozen-lockfile` (only `--frozen-lockfile`,
`--prefer-frozen-lockfile`, and `--no-prefer-frozen-lockfile`). Passing
it makes clap reject the install:
error: unexpected argument '--no-frozen-lockfile' found
tip: a similar argument exists: '--frozen-lockfile'
The flag was redundant for this scenario anyway: full-resolution starts
every iteration with no lockfile on disk (init() skips the lockfile when
`lockfile_enabled()` is false; cleanup removes it; `lockfile=false` in
the synthesized npmrc/workspace prevents writing one). With no lockfile
present the frozen path is unreachable regardless of the flag, so both
tools take fresh resolution by definition. Fold full-resolution into
clean-install's bare `install` arm.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Unify the two install-benchmark stacks (`benchmarks/bench.sh` and `pacquet/tasks/integrated-benchmark/`) into one shared Rust orchestrator. Scenario, fixture, workspace-manifest, install-script, and report generation live in one place so changes propagate to both stacks.
Scenario sets per workflow are unchanged: `benchmark.yml` keeps measuring the same 6 scenarios; `pacquet-integrated-benchmark.yml` keeps measuring the same 2. Both verdaccio and live-npm registry modes are preserved; neither is removed in favor of the other. All six scenarios accept both `pacquet@<rev>` and `pnpm@<rev>` targets.
The `pacquet --version` string is a hardcoded clap attribute in
`cli_args.rs`. It didn't get bumped for the 0.2.2 release, so the
published binary still reports 0.2.1. Bump the literal to 0.2.2 and
add a release-workflow step that rewrites the attribute from
`inputs.version` before `cross build`, so future releases stay
correct automatically. A trailing `grep -F` fails the job loudly if
the regex stops matching after a future refactor of the attribute.
When `configDependencies` declares pacquet (under either the unscoped `pacquet` or the scoped `@pnpm/pacquet` alias), pnpm delegates the fetch / import / link / build phases of an install to the pacquet Rust binary. Pnpm keeps owning dependency resolution — pacquet's resolver isn't ready yet — and hands pacquet a freshly-written lockfile to materialize.
Covered install shapes:
- frozen install (`tryFrozenInstall` → pacquet, no resolve needed)
- default isolated `nodeLinker` (`installInContext`: lockfileOnly resolve via JS, then pacquet)
- hoisted `nodeLinker` (same resolve-then-materialize shape)
- workspace partial install (subset of workspace projects mutated)
- agent-server install (`@pnpm/agent.client` resolves, pacquet materializes)
```yaml
# pnpm-workspace.yaml
configDependencies:
"@pnpm/pacquet": "^0.2.0" # or unscoped `pacquet`
```
## How it works
- `installing/commands/src/runPacquet.ts` resolves the platform binary via `createRequire(realpath(.pnpm-config/<name>/package.json))` — same algorithm the upstream wrapper uses, but skipping the JS shim's extra Node startup.
- Pacquet's NDJSON stderr is forwarded through `@pnpm/logger`'s global `streamParser` so `@pnpm/cli.default-reporter` renders its events the same way it renders pnpm's own. Non-JSON stderr lines pass through verbatim.
- A few pnpm-side log emits (`importing_done` placeholder, `pnpm:summary`) are suppressed when pacquet will take over so the reporter doesn't close streams or lock in empty diffs before pacquet's real events arrive. Pacquet's duplicate `pnpm:progress status:resolved` events are filtered on the resolve-then-materialize paths so the reporter doesn't double-count.
- `installing/deps-installer/src/install/index.ts` gates the delegation on a `runPacquet?: () => Promise<void>` callback in `StrictInstallOptions`. The CLI layer in `installing/commands/src/installDeps.ts` constructs the callback, threaded through both the single-project and workspace-recursive paths.
- The `pacquet` and `@pnpm/pacquet` npm packages ship the same JS shim from `pacquet/npm/pacquet/scripts/generate-packages.mjs`; per-platform binaries stay under the existing `@pacquet/<plat>-<arch>` scope and aren't duplicated.
Without an explicit shell, the step ran under PowerShell on
windows-latest, where `$TEST_SCRIPT` is not a variable (PowerShell
exposes env vars as `$env:TEST_SCRIPT`). `pn run ""` then exited 0
and just listed available scripts — the Windows test legs have been
silently no-op'ing since the env-var move in #11608.
The sibling `Verify Node version` and `Determine test scope` steps
already pin `shell: bash`; this brings `Run tests` in line.
---
Written by an agent (Claude Code, claude-opus-4-7).
Two related workflow changes:
### `pacquet-release-to-npm.yml`: switch to `workflow_dispatch`
The trigger was "push to main touching `pacquet/npm/pacquet/package.json`" — the version came from a committed bump and the workflow auto-fired on every such commit. Switch to `workflow_dispatch` only, with a `version` input (validated as semver). The workflow patches `pacquet/npm/pacquet/package.json` before `generate-packages.mjs` runs, so the version is single-sourced from the manual trigger rather than needing a separate commit to bump the manifest first.
The committed manifest now omits the `version` field entirely — it only exists at release time inside the runner.
Dropped along the way:
- The `check` job (EndBug/version-check against unpkg) — no longer needed when the operator types the version.
- The `Create GitHub Release` step — no draft release, no `v*.*.*` git tag. The pacquet `v0.x.x` tag scheme collided with pnpm's `v11.x.x`; npm is the authoritative artifact store and provenance attestations stay attached via `--provenance` on `pnpm publish`.
- `contents: write` on the publish job (no longer needs to create a tag).
### `release.yml`: add `workflow_dispatch` as a lib-only republish path
Add a `workflow_dispatch:` trigger alongside the existing tag-push trigger. Tag-push behaves exactly as before. Manual dispatch becomes a fast **lib-only republish** path — useful after a version bump to one or more lib packages that doesn't warrant a full CLI release.
On `workflow_dispatch` from any ref, the following are skipped (guarded with `if: startsWith(github.ref, 'refs/tags/')`):
- `Publish @pnpm/exe` step — also contains the multi-minute `build-artifacts` call.
- `Publish pnpm CLI` step.
- `Copy Artifacts`, `Attest build provenance` (the `dist/*` attestation), `Generate release description`, `Release` (`softprops/action-gh-release`) — these are the GitHub-Release-side ceremony. Without an explicit `tag_name`, `softprops/action-gh-release@v2.5.0` defaults to `github.ref_name`, which on a manual dispatch from main would create a junk release tagged literally `main`.
What still runs on `workflow_dispatch`:
- `actions/checkout`, garnet scan, `pnpm/setup`
- `Publish internal workspace packages (static token)` — i.e. `pn publish --filter=!pnpm --filter=!@pnpm/exe --access=public --provenance`
Compilation is handled by each lib package's own `prepublishOnly: tsgo --build` hook (which `pnpm publish` runs automatically), same as the existing tag-push flow.
The npm registry rejects any version already on it, so re-running on an already-released tree is a no-op — that's the safety net for accidental clicks.
## How to use
**pacquet release**: Actions → Release Pacquet → Run workflow → fill in `version` (e.g. `0.2.3` or `0.2.3-rc.1`) → Run. No tag, no GitHub release.
**pnpm full release**: still triggered by a `v*.*.*` tag push. Publishes @pnpm/exe + libs + CLI, attests, copies artifacts, creates a draft GitHub release.
**pnpm lib-only republish**: Actions → Release → Run workflow → choose `main` → Run. Publishes just the internal workspace packages from whatever versions are currently in each `package.json`. Skips CLI, @pnpm/exe, build-artifacts, GitHub release.
* chore(pacquet): fold registry-mock into root workspace, fix npm metadata
Two unrelated cleanups bundled because they touch the same publishing/
workspace plumbing:
1. **`pacquet/npm/pacquet/package.json` metadata** — the imported file
still pointed at the standalone repo: `repository.url` was
`pnpm/pacquet`, `repository.directory` was `npm/pacquet`, `homepage`
and `bugs` likewise. Repoint at `pnpm/pnpm`. Update
`generate-packages.mjs` so the per-platform packages it emits at
release time also point at `pnpm/pnpm`.
2. **Fold `pacquet/tasks/registry-mock` into the root pnpm workspace**.
Pacquet's standalone-repo nested-workspace setup pinned
`nodeLinker: hoisted` "for verdaccio CJS resolution," but pnpm's
own jest globalSetup (`__utils__/jest-config/with-registry/`) calls
the same `@pnpm/registry-mock.start()` API under the default
isolated linker without issue, and verified locally that
`node launch.mjs prepare` works after consolidation. The hoisted
constraint was scoped to standalone-pacquet's install pattern; in
the monorepo it's unnecessary.
Changes for (2):
- Add `pacquet/tasks/registry-mock` to `pnpm-workspace.yaml`.
- Rename the package `@pnpm-private/pacquet-registry-mock-launcher`
(private, matches the `@pnpm-private/*` convention used by other
internal workspace members) and switch `@pnpm/registry-mock` to
`catalog:` (the root catalog already pins it at 6.0.0).
- Delete `pacquet/tasks/registry-mock/pnpm-lock.yaml` and
`pnpm-workspace.yaml` — root install handles both now.
- Delete `pacquet/package.json` and `pacquet/pnpm-lock.yaml` — the
file only had a `cargo build` script + `devEngines: pnpm`, both
already covered by root, and nothing referenced it.
- `justfile install` is now just `pnpm install` (was
`cd pacquet/tasks/registry-mock && pnpm install --frozen-lockfile`).
- `pacquet-integrated-benchmark.yml` path filter and cache key
swap the deleted nested lockfile for the root `pnpm-lock.yaml` /
`pnpm-workspace.yaml`.
Verified: `pnpm install` resolves the workspace member, the lockfile
gains a `pacquet/tasks/registry-mock` importer entry, and
`pacquet/tasks/registry-mock/node_modules/@pnpm/registry-mock` is
linked correctly under the isolated layout.
* fix(pacquet): match meta-updater conventions in registry-mock launcher
The previous version of `pacquet/tasks/registry-mock/package.json`
omitted `version`, which crashed `pn lint:meta` (meta-updater hits
`manifest.version!.split('.')[0]` for every workspace package).
Backfill all the fields meta-updater would emit for an internal
`@pnpm-private/*` private package, matching `__typecheck__/package.json`
and friends:
- `version: 1100.0.0` (the pnpm 11.x convention for non-experimental
internal packages)
- self-devDep entry (`workspace:*`) that meta-updater would otherwise
inject
- `keywords: [pnpm, pnpm11]`
- `repository` pointing at this directory inside pnpm/pnpm
This is the same shape every other `@pnpm-private/*` private workspace
member uses; it lets `pn lint:meta --test` pass without modifying the
file.
* fix: update lockfile
* chore(pacquet): add build:pacquet script at root
Restore the `cargo build --release --bin pacquet` shortcut that lived in
the deleted `pacquet/package.json`. Naming it `build:pacquet` (rather
than `build`) matches the existing namespacing convention in this
file (`lint:ts`, `lint:meta`) and leaves room for a general `build`
script later. Invoke with `pnpm build:pacquet` or `pn build:pacquet`
from the repo root.