The Rust release workflow authenticated every publish via npm trusted
publishing, but npm allows a single trusted publisher per package and
`pnpm` / `@pnpm/exe` have theirs bound to release.yml (the v11 TS
releases). The OIDC token exchange therefore returned 404, pnpm skipped
OIDC, and the unauthenticated PUT failed with E404.
Split the publish into two steps:
1. The `@pnpm/exe.<target>` natives are published only from this
workflow, so they keep trusted publishing and go live directly.
2. The `pnpm` and `@pnpm/exe` wrappers are staged with
`pnpm stage publish` using the static NPM_TOKEN. Nothing goes live
from CI for those two names; a maintainer promotes them with
`pnpm stage approve <stage-id> --otp`, which is where the
proof-of-presence is consumed. Stage ids are teed into the job
summary for the approver.
The steps must stay separate because `pnpm publish` skips OIDC as soon
as a static _authToken is configured (pnpm/pnpm#11495). `--provenance`
keeps working in both steps: it needs only the `id-token: write` OIDC
token, not trusted publishing. `set -o pipefail` guards the tee pipe
because the runner's default bash is -e without pipefail.
* feat(release): auto-refresh embedded signing keys in the release PR
The create-release-pr workflow previously failed when the embedded npm
signing keys or Node.js release keys drifted from their canonical
sources, requiring a manual update PR before a release could proceed.
Now the workflow runs the update scripts in --update mode and commits
the refreshed keys together with the version bumps, so the new trust
roots are reviewed as part of the release PR diff. A synthesized
changeset records the refresh in the changelogs of the affected
packages.
Also embeds the Node.js release team's new signing key
655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD (Stewart X Addison), which the
last release run reported as missing, and fixes the Rust renderer in
update-node-release-keys.mjs so its output is lint-clean (angle-bracket
URL for dylint's bare-url lint, raw-string hashes only when the key
contains a quote for clippy's needless_raw_string_hashes) and matches
the committed file byte-for-byte on a no-drift run.
* fix(release): canonicalize npm key order and flag trust-root refreshes
Review follow-ups: sort the merged npm signing keys by keyid so a
reordering of npm's response cannot churn the generated file or
synthesize a spurious changeset, and leave an explicit warning comment
on the release PR whenever embedded trust roots were refreshed, so a
key change cannot slip through buried in a large version-bump diff.
When resolving with --offline or --prefer-offline, pickPackage loaded a
package's metadata from the on-disk cache mirror but never stored it in
the in-memory metaCache, so every subsequent resolution of the same
package (once per dependent that references it) re-read and re-parsed
the full packument from disk. Store the parsed metadata in the
in-memory cache on those disk-return paths, so each package's metadata
is parsed at most once per command. On a large monorepo this collapses
23,746 metadata loads to 5,124 (66.8 GB of JSON parsed to 8.8 GB) and
takes pnpm dedupe --offline from ~140s to ~75s with a byte-identical
lockfile. The cache is the existing bounded LRU; online and network
paths are untouched.
Promotion alone would have made the top-of-function cache hit terminal:
a disk-promoted packument that predates a later-requested range would
turn a previously-recoverable network fallback into
ERR_PNPM_NO_MATCHING_VERSION. Track registry-unverified disk promotions
(a WeakSet keyed by object identity in TypeScript; a registry_verified
flag carried inside the cache value in pacquet, so meta and state are
read atomically) and let a cache hit whose pick fails on such an entry
fall through to the regular network-validating flow unless offline. The
revalidating fetch stores a verified entry, so each package revalidates
at most once. The pre-existing exact-version fast paths on both stacks
had the same latent failure mode and are covered by the same marking.
Pacquet's PackageMetaCache::set_unverified is a required trait method
so custom cache implementations cannot silently inherit the terminal
behavior, and the TypeScript PackageMetaCache documents the
object-identity contract its WeakSet relies on.
Also registry-qualify pacquet's verifier shared-meta-cache reads
(read_shared_meta queried bare name keys while the resolver stores
registry-qualified ones, so the fast path could never hit), mirroring
the TypeScript readSharedMeta lookup order.
Add an isolated-linker.fresh-resolve.hot-cache.offline benchmark
scenario (install --offline --lockfile-only against a warm mirror, with
an untimed online pre-warm pass and per-iteration node_modules +
lockfile wipes so the up-to-date short-circuit cannot skip the measured
resolution) to guard offline resolution on all Bencher testbeds. Fixing
it to measure real work also revived the pnpm benchmark pipeline,
silently dead since the pnpm11/ move: bench.sh resolved REPO_ROOT to
pnpm11/ where no Cargo.toml exists, the orchestrator probed the
pre-move pnpm/dist bundle path, and continue-on-error masked every
failure as a green run. The scenario measures this change directly:
pnpm@HEAD resolves the fixture offline in 2.41s vs 3.17s for pnpm@main
(1.31x), with metadata-mirror reads dropping from 2625 to 1297.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Model every addressable registry origin in pnpr as a registry mount at
/~<mount>/: a pnpr-hosted organization registry, a single-origin upstream, and
a router mapping package-name patterns to one concrete source. Provenance is
declared, never inferred — no configuration can express a cross-origin
fall-through. Full replacement of the legacy Verdaccio-shaped model.
New `mount` module: a decidable PackagePattern language with a covers()
superset relation; first-match authoritative resolution; and Mounts::validate,
which rejects shadowed/unreachable routes (including a non-last catch-all),
duplicate patterns, and unknown/self/non-concrete sources at config load.
mounts:/defaultTarget: is the only routing surface; uplinks:, packages: proxy:
fallback chains, hosted-first serving, and multi-uplink tarball fallback are
removed. Path-less and write requests route through the mount graph; a router
no-route is a 404 with no fall-through and a down source errors rather than
404s. Served tarball URLs stay canonical for the client's base. Per-package
ACLs apply on every mount-served read.
Each hosted-org mount has its own storage namespace (local dir and S3/R2) so two
orgs hosting the same name@version cannot collide; the org is threaded through
staging, commit, and the publish journal so crash recovery lands in the right
org. Public upstream mounts use a stable, secret-free cache namespace.
The bundled config.yaml and the integrated-benchmark mock config are converted
to the mount model; registry-mock keeps working with no task or seed changes.
Implements the pnpr side of RFC pnpm/rfcs#13 only; lockfile registry-identity
changes for the TypeScript CLI and pacquet are out of scope.
Port the `pnpm bugs` command to pacquet, following the structure of the TypeScript handler at `pnpm11/deps/inspection/commands/src/bugs/index.ts` and matching its error codes (`ERR_PNPM_NO_BUGS_URL`, `ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND`). The command supports local manifest lookup and registry lookup by package name, with repository URL normalization for GitHub/GitLab/Bitbucket shorthand, hosted git URLs, and self-hosted git servers. Unit tests cover all URL derivation branches (36 tests). Integration tests cover the CLI entry points with local manifests and a mocked registry (9 tests).
Related to pnpm/pnpm#11633.
On a cache miss the proxy buffered the whole tarball into the cache, verified
its SRI, and only then streamed the finished file — a head-of-line stall on
every cold fetch: the client waited for the full upstream fetch plus a verify
pass before the first byte, with no overlap. Buffer-then-verify is the wrong
model for a streaming proxy.
Stream the upstream response to the client while teeing it into the cache and
hashing it (stream_verified_to_cache); promote the cache entry only once the
full body matches dist.integrity. A mismatched/truncated/oversize body is never
cached and the client re-verifies what it receives, so a bad upstream tarball
can't poison a later client — it just can't be turned into a BAD_GATEWAY, since
SRI is only known after the body is already streamed.
Converge the namespaced /~<uplink>/ route onto the same model (the integrity is
known from the packument either way), removing the now-dead uplink integrity
sidecar machinery. Stop forwarding the upstream Content-Length on streamed
responses (attacker-controlled, unverifiable before streaming; kept only for
the internal early oversize check). Add a cold-pnpr-cache benchmark scenario
that actually exercises the cold download/serve path (every other scenario
warms the mock); it runs frozen so resolution variance doesn't mask the
serve-path delta.
The measured speedup on that scenario is modest and within noise (this fixture
is small tarballs over a latency-bound link); the change is a correctness fix,
and the benchmark confirms no regression.
Out of scope: packuments with no usable dist.integrity (pnpm/pnpm#12727).
Make the pacquet Rust port installable from npm as pnpm v12, published with
equal content under two names — `pnpm` and `@pnpm/exe` — on the `next-12`
dist-tag (never `latest`, which keeps serving the production TypeScript pnpm
v11). First release is 12.0.0-alpha.0.
CLI presents as pnpm: clap name/bin_name are `pnpm`; PACQUET_VERSION holds the
pnpm 12.x version; the default User-Agent is the plain `pnpm/<version>` form;
the reporter footer reads `Done in ... using pnpm v<version>`; the `pnpm link`
/ `pnpm patch` / `pnpm patch-commit` hints now name pnpm (parity fixes); and
launched as `pnpx`/`pnx`, the binary injects the `dlx` subcommand itself by
detecting its current_exe name (porting pnpm's buildArgv).
npm packaging mirrors how `@pnpm/exe` ships pnpm: both the `pnpm` and `@pnpm/exe`
wrappers ship root-level placeholder bins (pnpm/pn/pnpx/pnx) plus an install.js
preinstall that hardlinks the host's native binary over the placeholder (no Node
launcher). Native binaries publish as `@pnpm/exe.<platform>-<arch>[-musl]` (file
`pnpm` inside). `pacquet`/`@pnpm/pacquet` are no longer published.
installPnpm initializes v12 exactly like `@pnpm/exe`: linkExePlatformBinary takes
the wrapper name and resolves the native binary via require, and PNPM_ALLOW_BUILDS
includes both names so the GVS hash is per-platform. resolvePackageManagerIntegrities
already resolves both `pnpm` and `@pnpm/exe`, so publishing `@pnpm/exe@12` makes
self-update / version-switch work with no v12-specific logic. From v12 the
install always uses the `pnpm` package (even from a `@pnpm/exe` build), via
pnpmPackageNameToInstall().
The configDependencies install-engine path still installs the published
`pacquet`/`@pnpm/pacquet` releases (which ship `@pacquet/<target>` binaries) and
is left untouched.
Add `pacquet ping`, ported from pnpm's ping command.
`ping` GETs `<registry>-/ping?write=true` and prints pnpm's PING/PONG
report, plus a pretty-printed `PONG <details>` line when the registry
returns a non-empty JSON body. It resolves the registry from `--registry`
or the configured default, adds a trailing slash before joining so a
registry path prefix is preserved (matching pnpm's
`new URL('./-/ping', ...)`), sends the optional `Authorization` header when
one is configured (unlike whoami it does not require auth), and issues a
single attempt with no retries (pnpm's `retry: { retries: 0 }`). Timing
wraps the request and body read, matching pnpm's `Date.now()` measurement.
Both a transport failure and a non-success status map to the single
`ERR_PNPM_PING_ERROR` code with pnpm's "Failed to reach registry" message;
the transport error message is credential-redacted before it reaches
stderr / CI logs, the same precaution as whoami. Unlike whoami's username
(printed raw, hence sanitized), ping's JSON details are re-serialized
through serde_json, which escapes control characters, so no raw registry
bytes reach the terminal.
Extract the registry-client builder duplicated from whoami into a shared
`cli_args::registry_client::build_registry_client` and switch whoami to it.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* ci: add Oz cloud triage workflow for new issues
Wire Warp's Oz "cloud factory" issue-triage stage onto pnpm/pnpm as a
GitHub-triggered cloud agent (part of the pnpm x Warp OSS partnership).
- .github/workflows/triage-issues.yml runs on issues:[opened], skips
Bot-authored issues, and invokes warpdotdev/oz-agent-action@v1 with the
triage skill. Minimal permissions: contents:read, issues:write.
- .agents/skills/triage/SKILL.md adapts the canonical triage rubric to pnpm:
it maps the four readiness states onto existing pnpm "state:" labels
(accepted / needs design / needs steps to repro / blocked / rejected)
instead of creating new ones, is aware of the TypeScript/pacquet/pnpr split
and the install/add/update/remove parity rule, and is conservative about
removing maintainer-applied labels.
Triage-only for now; the implementation/auto-PR stage is intentionally not
included. Activation also requires the WARP_API_KEY secret and the Oz by Warp
GitHub App with the org enabled in Warp's Admin Panel.
* ci: address CodeRabbit review on Oz triage PR
- Pin actions to commit SHAs (actions/checkout to the repo's v7.0.0 pin,
warpdotdev/oz-agent-action to the v1 tag commit) with version comments,
matching the repo convention and satisfying zizmor.
- Harden the triage skill against prompt injection: treat all fetched issue
content (title, body, comments, attachments, linked docs) as untrusted data,
never as instructions, and forbid actions beyond the defined read/label/comment
steps.
- Make the non-bug "Needs info" path consistent across the decision rule, the
label-application step, and the result template: apply no state label and post
clarifying questions instead of forcing an ill-fitting label.
The Rust CI test job's `cargo nextest` build holds a full set of
`--all-targets` debug binaries for the whole workspace (~60 crates plus
heavy C deps such as aws-lc-sys, ring, libsql-ffi, and tree-sitter) in
target/. That had grown close to the hosted ubuntu runner's free-disk
ceiling even after the fixed `Free up runner disk space` step, and a run
crossed it: the linker died with "No space left on device" while building
an unrelated crate's test binary (macOS and Windows were unaffected).
Build the test artifacts with line-tables-only debug info via job-level
CARGO_PROFILE_DEV_DEBUG / CARGO_PROFILE_TEST_DEBUG env. This drops most of
the debuginfo bytes that fill target/ while keeping panic backtraces
file:line-accurate. Setting it in the workflow rather than Cargo.toml
leaves local `just test` builds with full debug info.
pnpr-release-to-npm.yml can be triggered via workflow_dispatch. Its publish job uses npm OIDC
trusted publishing to publish the `@pnpm/pnpr` package, and its docker job pushes the mutable
ghcr.io/pnpm/pnpr:latest tag. Neither had an environment gate, so any collaborator with write
access could publish to npm and overwrite the mutable Docker tag without reviewer approval.
Add an environment: release gate to the publish and docker jobs (the build job produces no
externally visible artifacts and is intentionally left ungated), matching the protection used
by release.yml.
Co-authored-by: Claude <noreply@anthropic.com>
The Docker workflow can be triggered via workflow_dispatch, and its build job pushes mutable
tags (:latest and :<major>) to ghcr.io/pnpm/pnpm. Unlike release.yml and update-latest.yml,
the build job had no environment gate, so any collaborator with write access could publish an
image to the public mutable tags without reviewer approval.
Add an environment: release gate to the build job so workflow_dispatch runs require approval
from the release environment reviewers, matching the protection already used by the other
publishing workflows. The release (published) event path is unaffected.
Co-authored-by: Claude <noreply@anthropic.com>
A branch that lives in this repo fires both a push and a pull_request
TS CI run for the same commit, and both report the TS CI / Success
required check. Branch protection then sees two results for that
context, so a cancellation or flake on the push side (e.g. two pushes
racing on the test.yml concurrency group) blocks the PR even when the
pull_request run passed.
Restrict push CI to main so PR branches carry TS CI / Success only via
their pull_request run (and the merge queue via merge_group). Since
pull_request now covers every PR, run the build/test jobs on every
pull_request (same-repo and fork) instead of relying on the push run,
and drop the same-repo/fork/chore-lockfile gating that existed only to
avoid duplicate push+pull_request builds. main stays covered pre-merge
by merge_group and post-merge by the push run.
* ci(pnpr): publish a Docker image on release
Add a `docker` job to the pnpr release workflow that builds and pushes a
multi-arch (linux/amd64, linux/arm64) image to ghcr.io/pnpm/pnpr after the
npm packages are published.
The image is built from the static musl binaries the release job already
produces, so no Rust toolchain runs in the Docker build and the image
matches exactly what ships to npm. It is based on debian:stable-slim with
ca-certificates (pnpr reaches upstream registries over HTTPS via rustls'
platform cert verifier), runs as a non-root user, declares storage and
cache volumes, exposes 7677, and defaults to binding 0.0.0.0 so the server
is reachable from outside the container. The build self-checks that
`pnpr --version` matches the PNPR_VERSION build-arg.
Stable releases also move the mutable `latest` tag; prereleases (versions
containing a hyphen) only get the exact-version tag.
* ci(pnpr): drop unused id-token permission from docker job
The docker job uses only docker/build-push-action's built-in provenance
and SBOM attestations, which are pushed to GHCR via GITHUB_TOKEN. It does
not call actions/attest-build-provenance, so the OIDC id-token: write
permission was unused. Drop it to follow least privilege.
* ci(pnpr): verify staged binary checksum in the docker image build
The build job now pins each binary's SHA256 at build time and uploads it
with the artifact. The docker job verifies the staged binaries against
those checksums and passes them as build-args, and the Dockerfile
re-verifies the binary it copies before trusting it. A 'pnpr --version'
check only confirms reported metadata, so it cannot stand in for an
integrity check on the binary itself. Mirrors the existing pnpm image.
* ci(pnpr): extract release artifacts into an isolated directory
Extract the downloaded musl tarballs into a throwaway directory and move
only the expected, checksum-verified regular files into the Docker build
context. A malformed archive (path traversal or symlink entries) can no
longer escape into the workspace and overwrite the Dockerfile or staged
binaries before the image is pushed to GHCR.
* docs(pnpr): add a language to the docker README code fence
The image-name fenced block lacked a language identifier, tripping
markdownlint MD040. Tag it as text.
GitHub's merge queue runs each entry on a temporary
`gh-readonly-queue/**` branch, whose creation also fires a `push`
event. For `ci.yml` (`on: [push, pull_request, merge_group]`) that
produced two TS CI runs for the identical commit: one `push`, one
`merge_group`. Both reach the reusable `test.yml`, whose concurrency
group keys on `github.ref` with `cancel-in-progress: true`. Since the
two runs share `github.ref`, their per-(platform, node, chunk) test
jobs land in the same group and cancel each other.
The Windows shards are gated only on compile-and-lint + build-pnpr, so
both runs' Windows jobs start near-simultaneously and reliably collide.
The `merge_group` run — the only one the queue's required
`TS CI / Success` check reads — lost the race, so its cancelled test
jobs failed `Success` and ejected the PR for "failed status checks".
Restrict the `push` trigger to ignore `gh-readonly-queue/**` so only
the `merge_group` run executes for queued commits. `pacquet-ci.yml`
already scopes push to `main` and was unaffected. Apply the same
`branches-ignore` to `audit.yml` to drop its redundant push run on
those branches.
* ci: build pnpr per-OS so Linux tests don't wait on the Windows build
The pnpr binaries were built by a single `build-pnpr` matrix job spanning
`[ubuntu-latest, windows-latest]`. A `needs` edge can only target a whole
job, never an individual matrix leg, so every consumer of `build-pnpr`
(the ubuntu smoke/test jobs and the windows test shards) blocked until both
legs finished. The Linux tests therefore waited for the slower Windows pnpr
build even though they only consume the Linux binary artifact.
Extract the build into a per-OS reusable workflow (`build-pnpr.yml`) and call
it twice from `ci.yml` as `build-pnpr-linux` and `build-pnpr-windows`. The
ubuntu test jobs now gate only on `build-pnpr-linux` and the windows shards
only on `build-pnpr-windows`, so neither platform waits on the other's build.
* ci: trigger TS CI when build-pnpr.yml changes
Add the new reusable .github/workflows/build-pnpr.yml to the dorny/paths-filter
ts: list so a PR touching only that workflow still runs the pnpr build and TS
test jobs it gates, instead of being skipped.
The benchmark report comment identifies which revision was benchmarked
only via the workflow run, leaving the PR comment detached from the
exact SHA when read later. Add a clickable short-SHA line directly
under the report header so the comment is self-describing.
SHA source is the PR head SHA on pull_request events (stable commit
that survives merge), falling back to github.sha for push and
workflow_dispatch. Display-only — the comment workflow continues to
resolve the PR number from the trusted workflow_run payload, so the
SHA in the fork-controlled artifact cannot redirect where the comment
lands.
* ci: run required checks on merge_group so the merge queue works
The merge queue dispatches a merge_group event against a temporary
gh-readonly-queue/main ref, but neither TS CI (ci.yml) nor Rust CI
(pacquet-ci.yml) listened for it. Their required status checks therefore
never ran in the queue, so every queued PR waited forever on the missing
contexts (e.g. Rust CI / Success never starting).
Add merge_group to both workflows' triggers and force the change
detection true for that event. Forcing matters because TS CI / Compile &
Lint is itself a required context: a skipped job never reports its
context, which would keep the queue waiting, so it has to actually run.
It also makes the queue test the fully merged result, which is the point
of a merge queue. The Rust deny job's nested path filter, which has no
push/PR base to diff against in the queue, runs unconditionally on
merge_group instead.
* ci: gate compile-and-lint and build-pnpr on merge_group explicitly
The merge queue tests the merged commit, so the gating jobs must run on
merge_group. build-pnpr (added by the Windows-sharding work) carries the
same event guard as compile-and-lint and feeds test-smoke/test/test-windows,
so it needs the merge_group clause too — otherwise the queue would skip the
whole test suite.
Run Windows TypeScript test jobs only on Node.js 22 and split them into three parallel chunks.
Windows TS tests are significantly slower than Linux, and running the full suite on three Node versions spends the extra capacity on duplicate runtime coverage instead of reducing wall clock time. The reusable test workflow now accepts chunk inputs, includes chunk identity in concurrency and artifact names, and keeps Bencher uploads with chunk-specific benchmark names.
Add a CI helper that discovers the selected workspace packages for the same full or affected test scope, shards Jest test files into balanced chunks, runs selected package files with the same Jest Node options and package pretest setup, and writes pnpm-style execution summaries so the pnpm CLI e2e duration extraction still works.
The Release workflow allowed workflow_dispatch from branch refs but only built `@pnpm/exe` artifacts on tag refs. A branch dispatch on main after the 11.9.0 release bump selected the platform artifact packages in the internal publish step, then failed in their prepublishOnly binary verification because the binaries had not been built.
Add a preflight job that accepts only refs/tags/v*.*.*. The real release job depends on that preflight, so non-tag dispatches fail before entering the protected release environment or configuring npm auth.
With non-tag refs rejected up front, the release job can run the full tag release path without per-step tag guards.
The TypeScript pnpm CLI was relocated from `pnpm/` to `pnpm11/pnpm/` in
pnpm/pnpm#12537, but the "Extract pnpm CLI e2e test duration" step still
passed `--package-dir pnpm`. That path no longer exists, so the
exec-summary lookup found no entry and the step exited 1, failing the
full TS CI test job on main.
Point `--package-dir` at the package's new location, `pnpm11/pnpm`.
The TypeScript pnpm CLI freezes at v11; pnpm 12 will be the Rust pacquet
port. To make that split legible, all TypeScript source, test, and build
directories move under a new top-level pnpm11/ directory. The name states
the version boundary rather than implying a behavioral fork, since the two
stacks are meant to behave identically.
Scope is source-only: the shared workspace root stays at the repo root.
pnpm-workspace.yaml, package.json, pnpm-lock.yaml, .pnpmfile.cjs,
.meta-updater, __patches__, .changeset, .husky, and the lint/spell configs
remain in place, so one pnpm workspace and one Cargo workspace still span
all three products. pnpr/client and pacquet/tasks/registry-mock stay as
cross-product workspace members.
Rewiring the move required:
- pnpm-workspace.yaml globs prefixed with pnpm11/
- root package.json script paths, eslint.config.mjs, tsconfig.lint.json,
.gitignore, and CODEOWNERS updated
- .meta-updater/src/index.ts literals repointed (pnpm11/pnpm/package.json,
pnpm11/__utils__, pnpm11/__typings__, and the main package directory)
- regenerated every moved package's repository/homepage URL via meta-updater
- pnpm11/pnpm/bundle-deps.ts and __utils__/scripts/src/typecheck-only.ts
climb one more level to reach the repo root
.meta-updater stays at the repo root because @pnpm/meta-updater resolves
its config at <cwd>/.meta-updater/main.mjs.
TS CI (.github/workflows/ci.yml) now only runs when pnpm11/-relevant paths
change, via a dorny/paths-filter changes job plus a TS CI / Success
aggregate gate; branch protection should require only that gate.
Approvals on PRs from forks never got the `reviewed: coderabbit` label or a
Discord announcement. A pull_request_review run triggered by a forked PR is
granted a read-only token and no secrets, so the label step failed with HTTP
403 and the Discord step had no webhook.
Split the automation in two: the pull_request_review workflow now only records
the approval as an artifact (no token, no secrets), and a new workflow_run
companion runs from the default branch in base-repo context — where it has the
write-scoped token and secrets — to add the label and post to Discord.
The privileged half never checks out or executes PR content: it reads inert
data files, validates the PR number is an integer, maps the reviewer to a fixed
label, requests only actions:read + issues:write, surfaces a failed (vs absent)
artifact download instead of passing as a silent no-op, and scopes the Discord
webhook secret to the announce step.
Also documents that agents must open PRs using .github/pull_request_template.md,
since gh pr create does not apply it automatically.
* chore: stop configuring CodeRabbit pre-merge checks
Defining any pre-merge check turned on the whole pre-merge-checks
subsystem, which under Request Changes Workflow replaces the
auto-approve flow: CodeRabbit emits a pre-merge-checks status block
instead of approving once review threads are resolved, leaving PRs at
REVIEW_REQUIRED even with all threads cleared and all checks green.
Remove the title check (added in pnpm/pnpm#12460) to restore
auto-approval and document why the block is intentionally absent.
Conventional Commits on the squash title stay covered by the commit-msg
hook and the squash-title convention.
* fix: declare Qodo auto-approval under [config]
enable_auto_approval and auto_approve_for_no_suggestions were declared
under [pr_reviewer], where Qodo never reads them, so auto-approval never
ran. Per the Qodo docs both keys belong under [config]. Move them and
drop the now-empty [pr_reviewer] section.
* chore: stop applying labels from Qodo
Product labels are applied by CodeRabbit (auto_apply_labels +
labeling_instructions in .coderabbit.yaml). Having Qodo also publish
labels via enable_custom_labels/publish_labels/custom_labels meant two
tools managing the same labels. Drop the Qodo label config and leave
labeling to CodeRabbit.
* ci: tag Qodo-approved PRs and fix the label step
Add an on-qodo-approval job that applies the 'reviewed: qodo' label when
qodo-free-for-open-source-projects[bot] approves, mirroring the existing
CodeRabbit job.
The label step used 'gh pr edit --add-label', which is broken (it errors
on the deprecated Projects-v1 GraphQL field), so adding the label failed
on approval. Switch all three jobs to the issues REST API
(POST /repos/{owner}/{repo}/issues/{number}/labels) instead.
The pr-review-automation workflow merged in pnpm/pnpm#12462 fails at the
label step with 'Resource not accessible by integration
(addLabelsToLabelable)'. gh pr edit --add-label uses the GraphQL
addLabelsToLabelable mutation, which requires issues: write even when
labeling a PR; pull-requests: write alone is insufficient. Grant it per
job, matching pacquet-integrated-benchmark-comment.yml.
* ci: label and notify on CodeRabbit approval
Add a workflow that fires on pull_request_review and, when CodeRabbit
submits an approving review, applies the informational "reviewed:
coderabbit" label and (if a DISCORD_WEBHOOK secret is configured) posts
a notice to Discord.
PR fields reach the steps through the environment rather than script
interpolation, so an attacker-controlled PR title cannot inject shell
commands. The Discord step is skipped when the webhook secret is unset.
* ci: flag maintainer-approved PRs for automerge
Rename the approval workflow to cover both review automations and add a
second job: when zkochan submits an approving review, apply the existing
"state: automerge" label.
Like the CodeRabbit job, the PR number is passed through the environment
rather than interpolated into the run script.
* ci: harden PR review automation per review feedback
- Scope permissions to each job (top-level permissions: {}) instead of
granting pull-requests: write workflow-wide (zizmor).
- Set allowed_mentions to {parse: []} on the Discord payload so a PR
title containing `@everyone`/`@here` cannot ping the server.
- Add connect/overall timeouts and retries to the Discord curl call.
Branch protection required the individual Rust CI and TS CI jobs as
status checks. That is brittle: most Rust jobs skip on PRs that touch no
Rust files, and a matrix job skipped at the job level never expands its
'${{ matrix.* }}' name — GitHub reports one check under the literal
unexpanded name, so the required per-OS contexts never appear and any
non-Rust PR blocks forever waiting for status that is never reported.
Add one static-named aggregate gate per workflow ('Rust CI / Success'
and 'TS CI / Success') that runs with 'if: always()' and fails only when
a dependency actually failed or was cancelled; skipped dependencies
count as a pass. Branch protection can then require just these two
contexts, decoupling them from the matrix shapes.
TS CI runs on both push and pull_request, and on same-repo PRs the
pull_request run skips every job for deduplication. A naive gate would
report a green 'TS CI / Success' on that skipped run and let a PR merge
before the real push run finished. The gate's name is therefore
'TS CI / Success' only on runs that do real work, and a different name
on the skipped duplicate run — using 'compile-and-lint''s 'if:' verbatim,
which the name MUST stay in sync with. Rust CI needs no such guard: its
push trigger is restricted to main, so a PR produces a single run.
Requires a branch-protection update: replace the per-job Rust CI / TS CI
contexts with 'Rust CI / Success' and 'TS CI / Success'.
* fix(ecosystem-e2e): run the build script via absolute /bin/sh
The build stage sets the child PATH to prepend the project's
node_modules/.bin, and Rust resolves a bare program name against that child
PATH. Spawning `sh` therefore let a dependency-installed `.bin/sh` run in
place of the system shell — running unintended code and masking build
pass/fail. Use the absolute `/bin/sh` so the orchestrating shell is always
the system one; the script it runs still finds framework bins through the
prepended PATH.
* ci(ecosystem-e2e): build pnpm and pacquet once, share via artifacts
The stack matrix rebuilt the pacquet release binary and the pnpm bundle in
every one of its seven jobs. Add a single build job that compiles pacquet,
the harness, and the pnpm bundle once and uploads them as an artifact; the
per-stack jobs download it and run, so the multi-minute Rust and bundle
builds happen once per run instead of seven times.
* feat(ecosystem-e2e): add harness installing real JS stacks across pnpm/pacquet and layouts
Adds `pacquet/tasks/ecosystem-e2e`, a Yarn-PnP-style ecosystem test that
installs, builds, and serves real framework scaffolds (Next.js, Vite,
Angular, Astro, SvelteKit, Nuxt, React Router 7) across the cross product
of {pnpm, pacquet} x {isolated, global virtual store}.
The binary axis catches pnpm/pacquet parity gaps; the layout axis catches
breakage introduced by the global virtual store. Each cell scaffolds the
project once, installs with the binary under test, runs the build, then
boots the production server and probes it over HTTP — so a green cell means
the produced node_modules works at runtime, not just at bundle time.
Runs on a daily cron (one job per stack), not per-PR: the installs are slow
and track upstream framework releases, so a red cell is investigated rather
than treated as a merge blocker.
* style(ecosystem-e2e): use ASCII ellipsis in comments for dylint
* fix(ecosystem-e2e): address PR review findings
- workflow: wrap `pnpm/bin/pnpm.cjs` for the `--pnpm` shim; the built bundle
is `dist/pnpm.mjs`, so the previous `dist/pnpm.cjs` path never existed and
the shim would have failed to launch the repo-built pnpm.
- keep: `--keep` now reuses an already-scaffolded template instead of
re-running the generator into a non-empty directory (which fails).
- serve: retry HTTP 4xx/5xx until the deadline rather than failing on the
first one — dev servers can briefly answer error statuses while warming up.
- serve: the probe reads only the status line, not the whole response body.
- stacks: pin the create-astro, sv, nuxi, and create-react-router generators
to a major version instead of tracking the latest tag, matching the stated
reproducibility intent for the scheduled run.
* fix(ecosystem-e2e): scrub subprocess env, bound CI job, fix README fence
- security: scaffold/install/build/serve run third-party lifecycle code, so
they now launch via `sandboxed_command`, which clears the inherited
environment down to a small allowlist (PATH, HOME, proxy/cert/locale vars,
plus PORT/HOST for servers). An unattended dependency build script can no
longer read ambient CI secrets such as the workflow token.
- workflow: add a 60-minute job timeout so a hung build or serve subprocess
can't pin a runner indefinitely.
- docs: give the README's grid code block a language identifier (MD040).
* fix(ecosystem-e2e): spawn serve argv directly and reset cell log per run
- serve: run the tokenized serve command directly off the project's
node_modules/.bin instead of joining tokens into a `sh -c` string, so
argument boundaries survive and a token that needs quoting can't break the
launch. The spawned PID is the server itself, so teardown still works.
- log: truncate cell.log at the start of each run so reruns (notably under
--keep) don't interleave with stale output.
## 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.