Skip to content

feat(publish): add --batch flag to publish all packages in a single request#12299

Merged
zkochan merged 10 commits into
mainfrom
multi-publish
Jun 13, 2026
Merged

feat(publish): add --batch flag to publish all packages in a single request#12299
zkochan merged 10 commits into
mainfrom
multi-publish

Conversation

@zkochan

@zkochan zkochan commented Jun 9, 2026

Copy link
Copy Markdown
Member

What

Adds an opt-in --batch flag to pnpm publish --recursive that sends all selected packages to the registry in a single request instead of one request per package, and implements the corresponding endpoint in pnpr.

pnpm publish -r --batch

Client side (@pnpm/releasing.commands)

  • New batchPublish.ts: packs every selected package first (running prepublishOnly/prepublish), builds one npm publish document per package — exactly the JSON body libnpmpublish would send for a single-package PUT /:pkg (same _attachments base64 tarball, dist.integrity/shasum/tarball) — groups the documents by target registry, and sends PUT <registry>/-/pnpm/v1/publish with the body {"packages": [...]} via npm-registry-fetch. publish/postpublish scripts run only after the request succeeds.
  • Auth, OTP challenges (web auth + classic prompt), --tag, --access, --dry-run, --report-summary, and --json work the same as the per-package path. The summary-building code is shared via a new createPublishSummary() helper.
  • A registry that doesn't implement the endpoint (404/405) fails with ERR_PNPM_BATCH_PUBLISH_UNSUPPORTED and a hint to retry without --batch.
  • Guard rails:
    • --batch without --recursiveERR_PNPM_BATCH_PUBLISH_REQUIRES_RECURSIVE.
    • --provenance and staged publishing are rejected with dedicated errors — provenance statements and OIDC trusted-publishing tokens are bound to a single package, so they can't authorize a multi-package request. Batch publish uses statically configured credentials (createPublishOptions gained an oidc: false mode).

Registry side (pnpr)

  • New route PUT /-/pnpm/v1/publish. The path is vendor-namespaced under /-/pnpm/v1/ (mirroring npm's own /-/npm/v1/... extensions) so a future npm registry endpoint in the unprefixed /-/v1/ namespace can never collide with it. The path names the operation rather than the cardinality — a single package is just a batch of one. Each entry in packages is processed by the same pipeline as a single-package publish, which was refactored into shared validate_publish_docstage_publishcommit_publish steps.
  • The batch is all-or-nothing up to the commit point: every document is validated (package name, per-package publish policy, attachment integrity/shasum/length) and every tarball of every package is fully written to a tmp slot before anything becomes visible; a failure anywhere rolls back with no on-disk artifacts.
  • All affected package locks are held across the whole stage-and-commit. A new PackageLocks::lock_many acquires the lock stripes in sorted index order (duplicates collapsed), so two overlapping multi-publishes — or a multi-publish racing a single-package publish — can't deadlock.
  • Duplicate package names within one batch are rejected with 400 (multiple versions of one package belong in a single document's versions).
  • Crash-atomic commits. Applying a publish takes several non-atomic steps (one rename/upload per tarball, one packument write per package), so a crash mid-apply could leave a batch partially published. Before anything is promoted, the full intent — merged packument bytes plus the staged tmp-file locations — is persisted under .pnpr-journal/<txn>/ and sealed with a single atomic rename of the commit marker. Startup recovery (run by serve/serve_listener before accepting requests, also exported as pnpr::recover_publish_journal) rolls sealed transactions forward and unsealed ones back, so a publish — batch or single-package — is either fully visible or fully absent. Roll-forward is idempotent and re-merges into the current on-disk packument, so versions published between a failed apply and the restart survive the replay. Works on both the fs and S3 hosted backends (staged tarballs and the journal are always local).

Tests

  • Rust (pnpr/crates/pnpr/tests/multi_publish.rs, 8 tests): multi-package publish in one request, scoped packages with libnpmpublish-style attachment names, anonymous rejection, full rollback when one package fails integrity, duplicate/malformed bodies, merging with previously published versions; journal recovery rolls a sealed crashed transaction forward, an unsealed one back, merges with versions published after the crash, and leaves no residue after a successful publish.
  • TypeScript (test/publish/batchPublish.test.ts, 5 tests): wire-format assertions against a recording stub registry (single request, attachment bytes match dist.integrity, dist-tags), --dry-run sends nothing, unsupported-registry error, flag validation, and an end-to-end test that publishes through a real pnpr server and reads the packages back with pnpm view.
  • All 17 existing publish suites (183 tests) and the full cargo test -p pnpr suite pass; eslint (from root), clippy, rustfmt, and cargo doc -D warnings are clean.

Notes

  • No pacquet port needed: publish is outside pacquet's current command surface (dependency-management commands only).
  • Docs follow-up: pnpm.io publish.md should document --batch when this lands.

Written by an agent (Claude Code, claude-fable-5).

Summary by CodeRabbit

  • New Features

    • Opt-in --batch for pnpm publish --recursive to send multiple packages in a single registry request; usage without --recursive is rejected.
  • Reliability

    • Crash-atomic publish journal with startup recovery that rolls sealed transactions forward and unsealed ones back.
  • Error Handling

    • Explicit error when registry lacks batch support and all-or-nothing semantics for batch publishes.
  • Improvements

    • Centralized publish validation, safer staging/commit flow, and improved publish summaries.
  • Tests

    • New integration and unit tests for batch publish, negative cases, and journal recovery.
  • Chores

    • Added registry-fetch runtime and typing dependencies.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a crash-atomic publish journal and startup recovery to pnpr, refactors publish into validate/stage/commit with journal sealing, exposes a PUT /-/pnpm/v1/publish batch endpoint, and implements CLI-side --batch recursive publishing with packing, registry grouping, OIDC bypass, and tests.

Changes

Batch publish and journal recovery

Layer / File(s) Summary
Publish journal module with transaction/recovery
pnpr/crates/pnpr/src/journal.rs, pnpr/crates/pnpr/src/lib.rs, pnpr/crates/pnpr/src/storage.rs
Crash-atomic journal persists publish intent under .pnpr-journal/<txn>/, seals via atomic rename, and recovers on startup by rolling sealed txns forward (finalize tarballs, merge packuments) or rolling unsealed txns back (cleanup artifacts).
Publish flow refactoring with journal integration
pnpr/crates/pnpr/src/server.rs
Separates publish into validation (validate_publish_doc), staging (stage_publish), and commit (commit_publishes) phases with per-package locking. Replaces direct error responses with RegistryError propagation. Commit phase seals journal transactions, finalizes tarballs, and writes packuments.
Batch publish endpoint routing and startup recovery
pnpr/crates/pnpr/src/server.rs
Wires PUT /-/pnpm/v1/publish batch endpoint and invokes journal recovery on both serve and serve_listener entrypoints before handling requests.
Batch publish and journal recovery integration tests
pnpr/crates/pnpr/tests/batch_publish.rs, pnpr/crates/pnpr/tests/journal_recovery.rs
Tests batch endpoint success, scoped attachments, auth, atomicity/rollback on integrity failure, duplicate/malformed request rejections, version merging, and journal recovery scenarios (sealed roll-forward, unsealed roll-back, merge after crash, cleanup).
Extract tarball hashing and publish summary generation
releasing/commands/src/tarball/publishSummary.ts
Introduces createPublishSummary function to compute SHA-1 shasum and SRI SHA-512 integrity from tarball buffer, derive filename, map contents, and extract bundled dependencies. Enables sharing across single and batch publish paths.
Batch publish implementation with packing and registry grouping
releasing/commands/src/publish/batchPublish.ts
Implements batchPublishPackages to pack projects, run prepublish scripts, group by registry, send one batch PUT request per registry with OIDC disabled, run postpublish scripts, and handle unsupported-registry errors. Constructs publish documents with base64 tarball attachments and integrity/shasum.
OIDC toggle in publish options and registry info export
releasing/commands/src/publish/publishPackedPkg.ts
Adds optional oidc parameter to createPublishOptions for conditional OIDC bypass in batch mode. Exports findRegistryInfo for batch registry grouping. Replaces inline summary construction with createPublishSummary.
Recursive publish routing for batch vs sequential
releasing/commands/src/publish/recursivePublish.ts
Routes between batchPublishPackages (batch enabled) and existing sequential per-package publish loop (batch disabled), collecting results into publishedPackages array.
CLI option schema and validation for --batch flag
releasing/commands/src/publish/publish.ts
Adds batch boolean CLI option with help text and upfront validation that --batch requires --recursive.
Jest tests for batch publish behavior
releasing/commands/test/publish/batchPublish.test.ts
Tests single batch request, dry-run, unsupported-registry error (ERR_PNPM_BATCH_PUBLISH_UNSUPPORTED), e2e success, and recursive-required validation (ERR_PNPM_BATCH_PUBLISH_REQUIRES_RECURSIVE).
Dependency and changeset updates
pnpm-workspace.yaml, releasing/commands/package.json, .changeset/batch-publish-single-request.md
Adds npm-registry-fetch and @types/npm-registry-fetch to workspace and package.json. Documents batch publish feature with minor version bumps for @pnpm/releasing.commands and pnpm.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • pnpm/pnpm#11495: Modifies OIDC token/provenance handling in createPublishOptions; overlaps with the PR's oidc toggle changes.
  • pnpm/pnpm#11478: Related changes to publish-summary generation and publish command internals that the new createPublishSummary and batch flow extend.

Poem

🐰 Journals sealed with atomic care,
Batches fly in a single share,
If one fails, none go through,
Recovery wakes to finish the queue,
A rabbit cheers: publish anew!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a --batch flag to publish multiple packages in a single request via pnpm publish --recursive, which is the primary feature of this PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch multi-publish

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter

codecov-commenter commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.51292% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.33%. Comparing base (a98767a) to head (0bbfbb7).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pnpr/crates/pnpr/src/server.rs 89.33% 16 Missing ⚠️
pnpr/crates/pnpr/src/journal.rs 93.57% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12299      +/-   ##
==========================================
+ Coverage   88.26%   88.33%   +0.06%     
==========================================
  Files         297      298       +1     
  Lines       38708    39034     +326     
==========================================
+ Hits        34167    34479     +312     
- Misses       4541     4555      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.051 ± 0.220 3.900 4.454 1.90 ± 0.13
pacquet@main 4.060 ± 0.176 3.914 4.421 1.91 ± 0.12
pnpr@HEAD 2.148 ± 0.159 1.953 2.403 1.01 ± 0.09
pnpr@main 2.127 ± 0.091 2.021 2.313 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.050789569099999,
      "stddev": 0.21959940514768927,
      "median": 3.9180036267,
      "user": 3.8110492199999997,
      "system": 3.4190766000000004,
      "min": 3.8997219712,
      "max": 4.454406780199999,
      "times": [
        4.3304503112,
        3.9152223122,
        3.9588971722,
        3.8997219712,
        4.454406780199999,
        3.9079057932,
        4.3054970442,
        3.9075121732,
        3.9207849412,
        3.9074971922
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.0600311361,
      "stddev": 0.17587137570546313,
      "median": 3.9797639917000005,
      "user": 3.8777424199999997,
      "system": 3.5021487999999996,
      "min": 3.9143104842,
      "max": 4.421337975199999,
      "times": [
        4.310699802199999,
        3.9532683972,
        3.9897532342,
        4.421337975199999,
        3.9325208322000003,
        4.1226473621999995,
        3.9143104842,
        3.9218829782,
        3.9697747492000004,
        4.0641155462
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1478630312,
      "stddev": 0.15947220772248993,
      "median": 2.1007692547000003,
      "user": 2.6264780199999995,
      "system": 2.9996878999999996,
      "min": 1.9527991352000003,
      "max": 2.4033504072,
      "times": [
        2.3331163082,
        2.0478343212,
        2.2014858662,
        2.3290110032,
        2.0953525142,
        1.9527991352000003,
        2.0112940702,
        1.9982006912,
        2.1061859952,
        2.4033504072
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1270973212,
      "stddev": 0.09136087194946096,
      "median": 2.1061672652,
      "user": 2.6549297199999993,
      "system": 3.038419,
      "min": 2.0209481692,
      "max": 2.3128642202000003,
      "times": [
        2.2395165232000003,
        2.0959926562,
        2.1755573732,
        2.3128642202000003,
        2.0671542802,
        2.1274961622,
        2.0555574462,
        2.0595445072,
        2.0209481692,
        2.1163418742
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 625.3 ± 9.5 609.1 640.6 1.00
pacquet@main 625.4 ± 14.6 610.4 658.6 1.00 ± 0.03
pnpr@HEAD 699.6 ± 55.4 653.2 823.3 1.12 ± 0.09
pnpr@main 693.6 ± 31.9 656.4 755.4 1.11 ± 0.05
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.62527925454,
      "stddev": 0.00948467226611862,
      "median": 0.62561963674,
      "user": 0.37960041999999994,
      "system": 1.2991259000000002,
      "min": 0.6091139052400001,
      "max": 0.64061682724,
      "times": [
        0.6178464222400001,
        0.62301609524,
        0.62673340824,
        0.63621921024,
        0.6253000202400001,
        0.6091139052400001,
        0.64061682724,
        0.62593925324,
        0.63197914024,
        0.61602826324
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6254052446399999,
      "stddev": 0.014617314571715544,
      "median": 0.62160732674,
      "user": 0.37723112,
      "system": 1.3026543,
      "min": 0.6104385492400001,
      "max": 0.65855258524,
      "times": [
        0.65855258524,
        0.61858409024,
        0.61326436924,
        0.6104385492400001,
        0.61622778024,
        0.62463056324,
        0.61548140624,
        0.62554547524,
        0.63816492424,
        0.63316270324
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.69964517474,
      "stddev": 0.05539406769160139,
      "median": 0.6801832402400001,
      "user": 0.39304851999999996,
      "system": 1.3310726,
      "min": 0.65317965124,
      "max": 0.8233380302400001,
      "times": [
        0.68263810524,
        0.7768346782400001,
        0.6795232102400001,
        0.65317965124,
        0.6789542792400001,
        0.67246164724,
        0.65557944924,
        0.6930994262400001,
        0.68084327024,
        0.8233380302400001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6935937998400001,
      "stddev": 0.03194594842238307,
      "median": 0.6804641142400001,
      "user": 0.3862233199999999,
      "system": 1.337996,
      "min": 0.65638074924,
      "max": 0.75537944024,
      "times": [
        0.7278121832400001,
        0.7267702982400001,
        0.65638074924,
        0.6776495102400001,
        0.6832787182400001,
        0.67541555424,
        0.68949922224,
        0.6773484172400001,
        0.75537944024,
        0.66640390524
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.128 ± 0.055 4.052 4.238 1.91 ± 0.13
pacquet@main 4.172 ± 0.052 4.095 4.250 1.93 ± 0.13
pnpr@HEAD 2.167 ± 0.149 1.969 2.356 1.00
pnpr@main 2.188 ± 0.108 2.043 2.337 1.01 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.1281474203599995,
      "stddev": 0.055377332229183965,
      "median": 4.1152939797600006,
      "user": 3.6769621200000002,
      "system": 3.35039388,
      "min": 4.05151757826,
      "max": 4.23812808026,
      "times": [
        4.10465060526,
        4.12954466526,
        4.0933010982599995,
        4.09033682026,
        4.2050723122600004,
        4.12171085026,
        4.05151757826,
        4.23812808026,
        4.10887710926,
        4.13833508426
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.1720764697599995,
      "stddev": 0.05213758569746663,
      "median": 4.16514606276,
      "user": 3.67183932,
      "system": 3.35760718,
      "min": 4.09471487526,
      "max": 4.25010490826,
      "times": [
        4.23772277926,
        4.18795323226,
        4.13718285026,
        4.09471487526,
        4.11926634926,
        4.14217514926,
        4.17938410826,
        4.25010490826,
        4.15090801726,
        4.22135242826
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.16697932296,
      "stddev": 0.149154651264495,
      "median": 2.15310684626,
      "user": 2.45560052,
      "system": 2.9227867799999996,
      "min": 1.96914885826,
      "max": 2.35615659526,
      "times": [
        2.31949456526,
        2.06578909426,
        2.33927335226,
        2.21644861426,
        2.08976507826,
        2.03103889026,
        1.96914885826,
        2.35615659526,
        2.27154962826,
        2.01112855326
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1880740734599997,
      "stddev": 0.10790793426622766,
      "median": 2.17394128376,
      "user": 2.48260782,
      "system": 2.89536658,
      "min": 2.04321867426,
      "max": 2.33740214926,
      "times": [
        2.33740214926,
        2.04777245626,
        2.26863770426,
        2.17466882526,
        2.17321374226,
        2.17221295626,
        2.07944757426,
        2.04321867426,
        2.26705907426,
        2.31710757826
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.309 ± 0.024 1.270 1.352 2.12 ± 0.04
pacquet@main 1.325 ± 0.039 1.268 1.413 2.15 ± 0.07
pnpr@HEAD 0.647 ± 0.072 0.618 0.851 1.05 ± 0.12
pnpr@main 0.617 ± 0.005 0.611 0.625 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.3086133596200002,
      "stddev": 0.024412113644359846,
      "median": 1.31116878292,
      "user": 1.2983579399999998,
      "system": 1.68112942,
      "min": 1.27045843942,
      "max": 1.3520425824200002,
      "times": [
        1.31544256942,
        1.27045843942,
        1.3520425824200002,
        1.3191378874200002,
        1.30689499642,
        1.28035411142,
        1.32892908142,
        1.29092058642,
        1.32373973042,
        1.29821361142
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.3247956606200002,
      "stddev": 0.03895246623338831,
      "median": 1.32384549942,
      "user": 1.3109149400000002,
      "system": 1.70035462,
      "min": 1.2677874684200001,
      "max": 1.41300421342,
      "times": [
        1.32684825042,
        1.33622971342,
        1.32921203242,
        1.41300421342,
        1.31948116742,
        1.32084274842,
        1.31405209242,
        1.3409743384200001,
        1.27952458142,
        1.2677874684200001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.64711593692,
      "stddev": 0.07171181992031786,
      "median": 0.6256175394200001,
      "user": 0.31014604,
      "system": 1.2477082199999998,
      "min": 0.61770546942,
      "max": 0.8509940024200001,
      "times": [
        0.62194812342,
        0.6249280864200001,
        0.6211701324200001,
        0.8509940024200001,
        0.62801605542,
        0.6256292754200001,
        0.62697230942,
        0.61770546942,
        0.62819011142,
        0.62560580342
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.61717758042,
      "stddev": 0.004702633772964347,
      "median": 0.61730871342,
      "user": 0.31327534,
      "system": 1.21815102,
      "min": 0.6110204544200001,
      "max": 0.6250003554200001,
      "times": [
        0.6250003554200001,
        0.61230656642,
        0.6184218894200001,
        0.6135890224200001,
        0.6200971364200001,
        0.6110204544200001,
        0.62337785442,
        0.6171031354200001,
        0.6133450984200001,
        0.61751429142
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.961 ± 0.044 2.901 3.053 4.64 ± 0.10
pacquet@main 2.921 ± 0.055 2.849 3.060 4.58 ± 0.11
pnpr@HEAD 0.638 ± 0.010 0.624 0.659 1.00
pnpr@main 0.649 ± 0.077 0.619 0.867 1.02 ± 0.12
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.9606289422,
      "stddev": 0.04437284331631087,
      "median": 2.9560996296999997,
      "user": 1.7239865599999997,
      "system": 1.9143739400000002,
      "min": 2.9012578537,
      "max": 3.0529440927,
      "times": [
        2.9706207126999997,
        3.0140368127,
        2.9589726457,
        2.9012578537,
        2.9625504887,
        2.9330545086999997,
        2.9293381087,
        2.9532266137,
        3.0529440927,
        2.9302875847
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.9214926817999993,
      "stddev": 0.05486370709523057,
      "median": 2.9075492542,
      "user": 1.7147555599999997,
      "system": 1.8882892400000002,
      "min": 2.8491155397,
      "max": 3.0601455367,
      "times": [
        2.9160577937,
        2.8913934447,
        2.9061879167,
        2.9020274467,
        2.8491155397,
        2.9089105916999998,
        2.9035659637,
        2.9398687707,
        3.0601455367,
        2.9376538137
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6378366732,
      "stddev": 0.010402151191898131,
      "median": 0.6350668592000001,
      "user": 0.31815355999999995,
      "system": 1.2751083399999998,
      "min": 0.6239233217000001,
      "max": 0.6586184737,
      "times": [
        0.6514652287,
        0.6330835657,
        0.6296614557000001,
        0.6586184737,
        0.6427822747,
        0.6344883947000001,
        0.6322975697000001,
        0.6239233217000001,
        0.6356453237,
        0.6364011237
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6488868457000001,
      "stddev": 0.07687561329091293,
      "median": 0.6239584077,
      "user": 0.31407116,
      "system": 1.25001674,
      "min": 0.6194085567000001,
      "max": 0.8672302767000001,
      "times": [
        0.6355598277000001,
        0.6264507647,
        0.6210773377000001,
        0.6194085567000001,
        0.6198071697,
        0.6240466537,
        0.6238701617000001,
        0.6294919917,
        0.6219257167000001,
        0.8672302767000001
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12299
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,128.15 ms
(-13.87%)Baseline: 4,792.91 ms
5,751.49 ms
(71.78%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
2,960.63 ms
(-3.84%)Baseline: 3,078.83 ms
3,694.60 ms
(80.13%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,308.61 ms
(+10.38%)Baseline: 1,185.51 ms
1,422.61 ms
(91.99%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,050.79 ms
(-22.02%)Baseline: 5,194.56 ms
6,233.48 ms
(64.98%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
625.28 ms
(-4.34%)Baseline: 653.68 ms
784.41 ms
(79.71%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12299
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,166.98 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
637.84 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
647.12 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,147.86 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
699.65 ms
🐰 View full continuous benchmarking report in Bencher

@zkochan zkochan marked this pull request as ready for review June 10, 2026 12:36
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (13) 📘 Rule violations (0)

Grey Divider


Action required

1. Version normalization mismatch 🐞 Bug ≡ Correctness ⭐ New
Description
createPublishDocument() uses semver.clean() and publishes the cleaned version in the batch
publish document, but pack.api() writes the (uncleaned) packed manifest version verbatim into
package/package.json inside the tarball. If semver.clean() changes the version string (e.g.
stripping a leading v), the registry metadata can advertise one version while the installed
tarball and publish summary still carry another.
Code

releasing/commands/src/publish/batchPublish.ts[R210-224]

+  const name = publishedManifest.name as string
+  const version = semver.clean(publishedManifest.version as string)
+  if (!version) {
+    throw new PnpmError('BAD_SEMVER', `Invalid semver: ${publishedManifest.version as string}`)
+  }
+  const publishConfigAccess = publishedManifest.publishConfig?.access
+  const access = opts.access ?? (isPublishAccess(publishConfigAccess) ? publishConfigAccess : null)
+  if (!name.startsWith('@') && access === 'restricted') {
+    throw new PnpmError('UNSCOPED_RESTRICTED', `Can't restrict access to the unscoped package ${name}`)
+  }
+  const tarballName = `${name}-${version}.tgz`
+  const manifest: ExportedManifest = {
+    ...publishedManifest,
+    _id: `${name}@${version}`,
+    version,
Evidence
The batch publish document explicitly rewrites the version using semver.clean(), while the pack
step only strips build metadata and then writes the packed manifest (including its version) directly
into package/package.json inside the tarball. The publish summary also reports
publishedManifest.version directly, so a cleaned version can diverge from what is packed and
reported.

releasing/commands/src/publish/batchPublish.ts[205-233]
releasing/commands/src/publish/pack.ts[242-248]
releasing/commands/src/publish/pack.ts[363-385]
releasing/commands/src/tarball/publishSummary.ts[42-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`batchPublish` canonicalizes `publishedManifest.version` using `semver.clean()` and then uses the cleaned value as the published version in the request document and attachment filename. This can diverge from the version embedded into the tarball by the packing step, because packing writes the packed manifest into `package/package.json` verbatim.

### Issue Context
- Packing strips only build metadata (`+...`) and does not otherwise canonicalize the version string.
- The tarball’s embedded `package/package.json` is generated from the packed manifest, so any additional version rewriting in the publish document can create a metadata-vs-tarball mismatch.

### Fix Focus Areas
- releasing/commands/src/publish/batchPublish.ts[205-224]
- releasing/commands/src/publish/pack.ts[242-248]
- releasing/commands/src/publish/pack.ts[363-385]
- releasing/commands/src/tarball/publishSummary.ts[42-51]

### Suggested fix
In `createPublishDocument()`, do **not** replace the version string with the output of `semver.clean()`.

Instead:
1. Use the packed manifest’s version **as-is** (it already had build metadata stripped during packing).
2. If you still want a guardrail, validate without rewriting, e.g.
  - `const version = publishedManifest.version as string`
  - `if (!semver.valid(version)) throw new PnpmError('BAD_SEMVER', ...)`

This keeps the publish document version consistent with the version embedded into the tarball and the publish summary output.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Wrong .npmrc warning test 🐞 Bug ≡ Correctness
Description
The new test writes cafile=${ENV_VAR_123} but asserts for the project-level request-destination
warning about registry, which is only emitted for registry/proxy URL settings. This makes the test
incorrect (likely failing) and does not validate the intended behavior.
Code

pnpm/test/getConfig.test.ts[R52-65]

test('console a warning when the .npmrc has an env variable in a project-level registry', async () => {
prepare()
+  fs.writeFileSync('.npmrc', 'cafile=${ENV_VAR_123}', 'utf8')
+
+  await getConfig({
+    json: false,
+  }, {
+    workspaceDir: '.',
+    excludeReporter: false,
+  })
+
+  expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Ignored project-level request destination "registry"'))
+})
Evidence
The test currently uses cafile=${ENV_VAR_123} but expects a warning that loadNpmrcFiles only
emits for request-destination keys such as registry/proxy; cafile is not treated as a request
destination value key, so this specific warning will not be produced for that input.

pnpm/test/getConfig.test.ts[52-65]
config/reader/src/loadNpmrcFiles.ts[263-275]
config/reader/src/loadNpmrcFiles.ts[294-300]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new Jest test writes a project-level `.npmrc` containing `cafile=${ENV_VAR_123}` but expects a warning that only applies to request-destination keys (e.g. `registry`, `proxy`, `https-proxy`). This mismatch means the test is asserting the wrong behavior and is likely to fail.
### Issue Context
In `config/reader`, the `Ignored project-level request destination ...` warning is only emitted when an env placeholder appears in a *request destination* value (registry/proxy), not for `cafile`.
### Fix Focus Areas
- pnpm/test/getConfig.test.ts[52-65]
### Suggested fix
Update the test to either:
1) Write a request-destination key/value (e.g. `registry=${ENV_VAR_123}` or `https-proxy=${ENV_VAR_123}`) if the intent is to test the request-destination warning; **or**
2) Keep `cafile=${ENV_VAR_123}` but change the expectation to the warning that actually occurs for unresolved env placeholders (e.g. `Failed to replace env in config: ${ENV_VAR_123}`), and rename the test accordingly to avoid referring to `registry`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Broken length assertion 🐞 Bug ≡ Correctness
Description
The new batch publish test calls toHaveLength() on the attachment object instead of on
attachment.length, so it will fail even when the tarball bytes and declared length are correct.
This can break CI for the PR despite a correct implementation.
Code

releasing/commands/test/publish/batchPublish.test.ts[R143-146]

+  const attachment = scopedPkg._attachments['@pnpmtest/batch-pkg-1-1.0.0.tgz']
+  expect(attachment).toBeDefined()
+  const tarballData = Buffer.from(attachment.data, 'base64')
+  expect(attachment).toHaveLength(tarballData.length)
Evidence
attachment is the value of _attachments[...] (an object with { content_type, data, length }),
but the test asserts the object itself has a length equal to the decoded tarball length, rather than
asserting attachment.length.

releasing/commands/test/publish/batchPublish.test.ts[141-150]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A Jest assertion uses `expect(attachment).toHaveLength(...)` where `attachment` is an object that contains a `length` field; `toHaveLength` expects an array/string/Buffer-like value with a `.length` and will not validate the intended field.
### Issue Context
This makes the test fail regardless of correct publish document generation.
### Fix Focus Areas
- releasing/commands/test/publish/batchPublish.test.ts[143-146]
### Suggested fix
Replace the assertion with one that checks the numeric field, e.g.:
- `expect(attachment.length).toBe(tarballData.length)`
(or `expect(attachment.length).toEqual(tarballData.length)`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (4)
4. Recovery skips tarball promotion 🐞 Bug ≡ Correctness
Description
journal::roll_forward() treats any fs::try_exists() error on a staged tarball as “does not
exist”, so it can skip promotion, still write the packument, and then delete the journal
entry—leaving metadata visible without its tarball and without any remaining journal state to retry
from.
Code

pnpr/crates/pnpr/src/journal.rs[R211-236]

+async fn roll_forward(storage: &Storage, dir: &Path) -> Result<()> {
+    let manifest: Manifest = serde_json::from_slice(&fs::read(dir.join(MANIFEST_FILE)).await?)?;
+    for package in &manifest.packages {
+        let name = PackageName::parse(&package.name)?;
+        for tarball in &package.tarballs {
+            if fs::try_exists(&tarball.tmp_path).await.unwrap_or(false) {
+                let slot = TarballSlot::from_parts(
+                    tarball.tmp_path.clone(),
+                    name.clone(),
+                    tarball.filename.clone(),
+                );
+                storage.finalize_tarball_slot(slot).await?;
+            }
+        }
+        let journaled: serde_json::Value =
+            serde_json::from_slice(&fs::read(dir.join(&package.packument_file)).await?)?;
+        let existing: Option<serde_json::Value> = match storage.read_hosted_packument(&name).await?
+        {
+            Some(bytes) => Some(serde_json::from_slice(&bytes)?),
+            None => None,
+        };
+        let merged = merge_manifest(existing.as_ref(), &journaled, &now_iso());
+        storage.write_hosted_packument(&name, &serde_json::to_vec_pretty(&merged)?).await?;
+    }
+    fs::remove_dir_all(dir).await?;
+    Ok(())
Evidence
The recovery path explicitly swallows try_exists() errors (unwrap_or(false)), but still writes
the packument and removes the transaction directory, so a transient probe failure can lead to a
packument being made visible without its tarball and without any journal state left to replay.

pnpr/crates/pnpr/src/journal.rs[211-236]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`roll_forward()` currently does:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Committed txn can rollback 🐞 Bug ≡ Correctness
Description
PublishJournal::recover() treats any error checking for the commit marker as "not committed" via
unwrap_or(false), so a sealed transaction can be rolled back and its staged tarballs deleted during
startup recovery. This can lose a publish that was already sealed as committed in the journal.
Code

pnpr/crates/pnpr/src/journal.rs[R161-168]

+        for dir in txn_dirs {
+            if fs::try_exists(dir.join(COMMIT_MARKER)).await.unwrap_or(false) {
+                roll_forward(storage, &dir).await?;
+                tracing::info!(txn = %dir.display(), "rolled publish journal entry forward");
+            } else {
+                roll_back(&dir).await;
+                tracing::info!(txn = %dir.display(), "rolled publish journal entry back");
+            }
Evidence
recover() currently interprets any try_exists error as “marker absent”, and roll_back()
deletes tarballs referenced by the manifest, so a committed txn can be destroyed if the marker check
fails transiently.

pnpr/crates/pnpr/src/journal.rs[146-169]
pnpr/crates/pnpr/src/journal.rs[225-240]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PublishJournal::recover()` uses `fs::try_exists(...).await.unwrap_or(false)` to decide whether a txn is sealed. Any I/O error (permissions, transient disk error) becomes `false`, which sends the txn to `roll_back()` and can delete staged tarballs for what may actually be a sealed/committed transaction.
### Issue Context
Recovery runs before the server accepts requests, so it is the last line of defense against partial publishes. On error, it should be conservative: never treat “can’t tell” as “unsealed”.
### Fix Focus Areas
- pnpr/crates/pnpr/src/journal.rs[146-169]
- pnpr/crates/pnpr/src/journal.rs[225-240]
### Suggested fix
Replace `unwrap_or(false)` with an explicit match:
- `Ok(true)` => roll forward
- `Ok(false)` => roll back
- `Err(e)` => return `Err(e.into())` (or at least log + abort recovery), so startup fails rather than risking rollback of a committed txn.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Partial apply after seal 🐞 Bug ☼ Reliability
Description
commit_publishes() seals the transaction and then applies packages sequentially; any mid-loop error
returns immediately, potentially leaving earlier packages visible while returning an error for the
overall publish. This breaks the implied all-or-nothing semantics for a single request until a
restart runs journal recovery, and can trigger confusing client retries (e.g., “already published”
for the subset that succeeded).
Code

pnpr/crates/pnpr/src/server.rs[R1226-1234]

+    // Past the seal, failures must NOT clean up the staged files: the
+    // transaction is committed, and startup recovery finishes the
+    // apply from exactly this on-disk state.
+    for stage in staged {
+        for slot in stage.slots {
+            state.inner.storage.finalize_tarball_slot(slot).await?;
+        }
+        state.inner.storage.write_hosted_packument(&stage.name, &stage.merged_bytes).await?;
}
Evidence
After journal.seal() succeeds, the apply loop can make some packages visible and then abort on
error due to ?, propagating an error response while leaving partial state until recovery occurs at
a later startup.

pnpr/crates/pnpr/src/server.rs[1192-1234]
pnpr/crates/pnpr/src/journal.rs[17-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
After sealing the journal, `commit_publishes()` applies each package’s tarballs + packument in a loop using `?`. If applying package N fails after packages 0..N-1 were already written, the handler returns an error but the registry state is partially updated until a restart.
### Issue Context
The code comments claim I/O failure mid-apply “can never leave the batch partially published” because recovery will roll forward later, but the server may continue running without recovery being invoked, and clients will receive an error response even though some packages may already be visible.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1192-1236]
### Suggested fix
Pick one conservative strategy:
1) **Fail-fast + force recovery soon**: if any apply step fails after sealing, log the failure and shut down the process (or otherwise stop accepting requests) so the supervisor restarts and startup recovery completes the sealed txn.
2) **Immediate roll-forward retry**: on apply error after sealing, run a best-effort roll-forward from the just-written journal entry (or call `journal.recover(...)`) and only then decide what status to return.
3) **Change response semantics**: return a dedicated error/status that clearly indicates “committed but not yet applied; do not retry”, ideally with a txn id.
Also consider applying tarballs for **all** packages first and packuments second to reduce the window of partial *packument* visibility (though it doesn’t fully eliminate partial outcomes if packument writes fail mid-way).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Scoped tarball URL 404 🐞 Bug ≡ Correctness
Description
createPublishDocument() builds dist.tarball using a tarball filename that includes the full
scoped package name (e.g. @scope/name-1.0.0.tgz), which turns the tarball URL into a 5-segment
path that pnpr will not route. Consumers downloading via the published packument will hit 404 for
batch-published scoped packages.
Code

releasing/commands/src/publish/batchPublish.ts[R220-232]

+  const tarballName = `${name}-${version}.tgz`
+  const manifest: ExportedManifest = {
+    ...publishedManifest,
+    _id: `${name}@${version}`,
+    version,
+    _nodeVersion: process.versions.node,
+    dist: {
+      integrity: `sha512-${createHash('sha512').update(tarballData).digest('base64')}`,
+      shasum: createHash('sha1').update(tarballData).digest('hex'),
+      // libnpmpublish stores an http:// URL on purpose (clients fetch via HTTPS regardless when
+      // the registry is HTTPS); keep the wire format identical to a single-package publish.
+      tarball: new URL(`${name}/-/${tarballName}`, registry).href.replace(/^https:\/\//, 'http://'),
+    },
Evidence
The client constructs dist.tarball from a filename containing / for scoped packages, but pnpr’s
router only serves scoped tarballs at a 4-segment path with a single-segment filename, and pnpr’s
package-name logic documents/implements the canonical -.tgz tarball filename for serving.

releasing/commands/src/publish/batchPublish.ts[205-233]
pnpr/crates/pnpr/src/server.rs[371-401]
pnpr/crates/pnpr/src/server.rs[634-646]
pnpr/crates/pnpr/src/package_name.rs[43-86]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
For scoped packages, `createPublishDocument()` currently sets `dist.tarball` using `tarballName = `${name}-${version}.tgz`` (which contains `/`). This produces a tarball URL like `/<scope>/<name>/-/@scope/<name>-<ver>.tgz`, which does not match pnpr’s tarball routes and will 404 for clients.
### Issue Context
pnpr accepts scoped attachment keys like `@scope/name-1.0.0.tgz`, but it canonicalizes the on-disk/served tarball filename to `<basename>-<version>.tgz` and routes tarballs as `/{scope}/{name}/-/{filename}` where `{filename}` is a single segment.
### Fix Focus Areas
- releasing/commands/src/publish/batchPublish.ts[205-249]
- releasing/commands/test/publish/batchPublish.test.ts[99-151]
### Suggested fix
- Keep the `_attachments` key as the libnpmpublish-compatible form (`@scope/name-<ver>.tgz` for scoped).
- Compute a separate **canonical** tarball filename for `dist.tarball`, e.g.:
- `const basename = name.split('/').pop()!`
- `const canonicalFilename = `${basename}-${version}.tgz``
- `dist.tarball = new URL(`${name}/-/${canonicalFilename}`, registry).href.replace(...)`
- Update the batch publish tests’ `dist.tarball` expectation for scoped packages accordingly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

8. Journal root not synced 🐞 Bug ☼ Reliability
Description
PublishJournal::seal() fsyncs files and the transaction directory but never fsyncs the journal root
directory that contains the new txn directory entry, so a crash can lose a sealed transaction and
make recovery miss it. This undermines the intended crash-atomic guarantee that a sealed publish is
eventually rolled forward to completion.
Code

pnpr/crates/pnpr/src/journal.rs[R110-140]

+    pub async fn seal(&self, packages: &[JournaledPublish<'_>]) -> Result<SealedTxn> {
+        let dir = self.root.join(txn_id());
+        fs::create_dir_all(&dir).await?;
+        let mut manifest = Manifest { packages: Vec::with_capacity(packages.len()) };
+        for (index, package) in packages.iter().enumerate() {
+            let packument_file = format!("packument-{index}.json");
+            write_synced(&dir.join(&packument_file), package.packument).await?;
+            manifest.packages.push(ManifestPackage {
+                name: package.name.as_str().to_string(),
+                packument_file,
+                tarballs: package
+                    .slots
+                    .iter()
+                    .map(|slot| ManifestTarball {
+                        filename: slot.filename().to_string(),
+                        tmp_path: slot.tmp_path.clone(),
+                    })
+                    .collect(),
+            });
+        }
+        write_synced(&dir.join(MANIFEST_FILE), &serde_json::to_vec_pretty(&manifest)?).await?;
+        sync_dir(&dir).await;
+        // The seal itself: a single same-directory rename, atomic on
+        // POSIX. Recovery treats a directory without this marker as an
+        // aborted transaction and rolls it back.
+        let marker = dir.join(COMMIT_MARKER);
+        let marker_tmp = unique_tmp_path(&marker);
+        write_synced(&marker_tmp, b"").await?;
+        fs::rename(&marker_tmp, &marker).await?;
+        sync_dir(&dir).await;
+        Ok(SealedTxn { dir })
Evidence
The journal claims that sealing makes a publish committed and recoverable after crashes, but the
seal implementation only calls sync_dir(&dir) on the txn directory and never syncs self.root
(the parent containing the txn directory entry). Without syncing the parent directory, the presence
of the sealed txn directory is not guaranteed to survive a crash, so recovery may not see it and
cannot roll it forward.

pnpr/crates/pnpr/src/journal.rs[1-16]
pnpr/crates/pnpr/src/journal.rs[106-140]
pnpr/crates/pnpr/src/journal.rs[278-288]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PublishJournal::seal()` creates a new transaction directory under `.pnpr-journal/<txn>/` and fsyncs files + the txn directory, but it never fsyncs the **journal root directory** (`.pnpr-journal`) to persist the directory entry for `<txn>` itself. On POSIX, syncing the child directory does not guarantee durability of the parent directory entry, so a crash can drop an already-sealed transaction from the directory listing and prevent startup recovery from seeing/replaying it.
## Issue Context
The module documentation states that sealing makes the publish committed and that startup recovery will roll sealed transactions forward so a publish becomes fully visible or fully absent. That requires the sealed txn directory to be discoverable under the journal root after a crash.
## Fix Focus Areas
- pnpr/crates/pnpr/src/journal.rs[110-140]
- pnpr/crates/pnpr/src/journal.rs[278-288]
## Suggested fix
1. After `fs::create_dir_all(&dir).await?;`, fsync the journal root directory (`self.root`) on unix (best-effort like existing `sync_dir`).
2. Consider also fsyncing `self.root` after the commit marker rename (or after any operation that relies on the txn dir entry being durable), depending on the intended durability envelope.
3. If `self.root` itself might be newly created by `create_dir_all`, consider also fsyncing its parent directory (best-effort) so `.pnpr-journal` becomes durable too.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Batch publish memory spike 🐞 Bug ☼ Reliability
Description
The batch publish path reads every tarball fully into memory and keeps all buffers until after the
registry request is sent; it then base64-encodes those buffers into the JSON request body. For large
workspaces or large packages this can create very high peak memory usage and may cause the publish
process to run out of memory.
Code

releasing/commands/src/publish/batchPublish.ts[R64-80]

+  const packedByRegistry = new Map<string, PackedPkg[]>()
+  const packedPkgs: PackedPkg[] = []
+  for (const project of pkgs) {
+    // eslint-disable-next-line no-await-in-loop
+    const packedPkg = await packPkgForBatch(project, opts)
+    const publishConfigRegistry = typeof packedPkg.publishedManifest.publishConfig?.registry === 'string'
+      ? packedPkg.publishedManifest.publishConfig.registry
+      : undefined
+    const { registry } = findRegistryInfo(packedPkg.publishedManifest, opts, publishConfigRegistry)
+    let group = packedByRegistry.get(registry!)
+    if (!group) {
+      group = []
+      packedByRegistry.set(registry!, group)
+    }
+    group.push(packedPkg)
+    packedPkgs.push(packedPkg)
+  }
Evidence
The implementation reads each tarball into a Buffer (fs.readFile(...)), stores those buffers for
all packages in arrays, and then turns each buffer into a base64 string inside _attachments, which
increases peak memory usage beyond just the raw tarball bytes.

releasing/commands/src/publish/batchPublish.ts[35-40]
releasing/commands/src/publish/batchPublish.ts[64-80]
releasing/commands/src/publish/batchPublish.ts[102-125]
releasing/commands/src/publish/batchPublish.ts[241-246]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`batchPublishPackages()` retains every packed tarball as a `Buffer` in memory across the entire packing phase and request construction, and `createPublishDocument()` base64-encodes each buffer into the request payload. This can substantially increase peak memory usage (Buffers + base64 strings) during `--batch` publishes.
### Issue Context
While `--batch` is opt-in, workspaces with many packages and/or large tarballs can cause high memory pressure during publish.
### Fix Focus Areas
- releasing/commands/src/publish/batchPublish.ts[35-40]
- releasing/commands/src/publish/batchPublish.ts[64-80]
- releasing/commands/src/publish/batchPublish.ts[102-125]
- releasing/commands/src/publish/batchPublish.ts[205-249]
### Suggested fixes (pick one)
1) Reduce peak RAM by not retaining `Buffer` for all packages:
- Store `tarballPath` (or a temp file path) instead of `tarballData` in `PackedPkg`.
- Read+hash+base64-encode per package only when building the request body, and release per-package data immediately.
2) Add guardrails:
- Before encoding, compute an estimated total payload size (sum of tarball sizes * ~1.34 for base64 + JSON overhead).
- If above a safe threshold, throw a dedicated error with a hint to publish without `--batch` or split the set.
3) If feasible with the fetch layer, implement streaming request body construction (avoid building the full JSON string in memory).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Masked roll-forward failure 🐞 Bug ◔ Observability
Description
When inline apply fails after sealing, commit_publishes() discards the error from
txn.roll_forward() and returns the original apply_err, hiding the real reason self-healing
failed and making it harder to detect that recovery did not complete.
Code

pnpr/crates/pnpr/src/server.rs[R1245-1248]

+        Err(apply_err) => {
+            tracing::warn!(error = %apply_err, "publish apply failed after seal; rolling forward");
+            txn.roll_forward(&state.inner.storage).await.map_err(|_| apply_err)
+        }
Evidence
The code explicitly maps any roll_forward() failure back to apply_err, which guarantees the
caller never sees the recovery failure cause and logs only the apply failure.

pnpr/crates/pnpr/src/server.rs[1245-1248]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
After sealing, `commit_publishes()` tries to self-heal by invoking `txn.roll_forward(...)`. If that roll-forward fails, the code currently does:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
11. Scripts skipped on later failure 🐞 Bug ≡ Correctness
Description
batchPublishPackages() runs publish/postpublish scripts only after all per-registry batch requests
finish successfully, so if packages target multiple registries and a later registry publish fails,
earlier registries may already have published packages but none of their publish/postpublish scripts
will run. This differs from the non-batch recursive path, where each successfully published package
runs its scripts before moving on.
Code

releasing/commands/src/publish/batchPublish.ts[R81-98]

+  for (const [registry, group] of packedByRegistry.entries()) {
+    for (const { summary } of group) {
+      globalInfo(`📦 ${summary.id} → ${registry}`)
+    }
+    if (opts.dryRun) {
+      globalWarn(`Skip publishing ${group.length} package(s) to ${registry} (dry run)`)
+      continue
+    }
+    // eslint-disable-next-line no-await-in-loop
+    await multiPublishToRegistry(registry, group, opts)
+    globalInfo(`✅ Published ${group.length} package(s) to ${registry} in a single request`)
+  }
+  if (!opts.ignoreScripts) {
+    for (const { project } of packedPkgs) {
+      // eslint-disable-next-line no-await-in-loop
+      await runScriptsIfPresent(await lifecycleOpts(project.rootDir, opts), ['publish', 'postpublish'], project.manifest)
+    }
+  }
Evidence
Batch publish runs scripts only after the registry loop, so any thrown error aborts before scripts;
the single-package publish path runs scripts inside publish.ts per package, so earlier successes
get scripts even if a later publish fails.

releasing/commands/src/publish/batchPublish.ts[55-99]
releasing/commands/src/publish/publish.ts[267-300]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`batchPublishPackages()` defers `publish`/`postpublish` until after looping over all registries. If `multiPublishToRegistry()` throws for a later registry, the function exits and skips scripts even for packages already published to earlier registries.
### Issue Context
This is observable when workspace packages have different `publishConfig.registry` values (or when `findRegistryInfo` picks different targets). The non-batch recursive path publishes package-by-package and runs scripts for each successful publish.
### Fix Focus Areas
- releasing/commands/src/publish/batchPublish.ts[55-99]
### Suggested fix
Change script execution to align with publish success:
- Option A: after each successful `multiPublishToRegistry(registry, group, ...)`, immediately run `publish`/`postpublish` scripts for packages in that `group`.
- Option B: maintain a list of successfully published packages/groups, and in a `finally` (or in a `catch` before rethrow) run scripts for the successes recorded so far.
Either way, ensure scripts are not accidentally run for packages whose publish request failed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Auth after body parse 🐞 Bug ⛨ Security
Description
publish_package parses the entire publish JSON body before calling enforce_access, so anonymous
callers can force expensive JSON parsing/allocation work (up to the 100MiB router limit) before
being rejected. This is a DoS-amplification regression compared to checking publish permissions
immediately after the package name is known from the URL.
Code

pnpr/crates/pnpr/src/server.rs[R930-933]

+    let incoming: Value = match serde_json::from_slice(&body) {
  Ok(v) => v,
  Err(err) => return error_response(&RegistryError::Json(err)),
};
Evidence
The router-wide publish body limit is 100MiB, and publish_package currently calls
serde_json::from_slice before validate_publish_doc, where enforce_access actually runs.
Therefore unauthorized requests still pay the JSON parse cost up front.

pnpr/crates/pnpr/src/server.rs[48-53]
pnpr/crates/pnpr/src/server.rs[916-953]
pnpr/crates/pnpr/src/server.rs[1074-1083]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PUT /:pkg` (`publish_package`) deserializes the request body into JSON before performing the publish permission check (`enforce_access`). This allows unauthenticated callers to spend server CPU/memory on large bodies before receiving a 401/403.
### Issue Context
- The package name is already available from the URL path, so publish permission checks can be performed before parsing the body.
- The router allows publish bodies up to 100MiB.
### Fix Focus Areas
- Move the publish permission check to run immediately after `PackageName::parse(raw_name)` and **before** `serde_json::from_slice(&body)`.
- Ensure the same early-rejection behavior is preserved for the single-package path; optionally consider a cheap early anonymous rejection for the batch endpoint too.
- pnpr/crates/pnpr/src/server.rs[916-969]
- pnpr/crates/pnpr/src/server.rs[48-53]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Seal failure leaves residue 🐞 Bug ☼ Reliability
Description
If PublishJournal::seal() fails after creating the transaction directory, the partially-created
.pnpr-journal// directory is not cleaned up immediately; commit_publishes only removes staged
tmp tarballs on this path. These unsealed journal directories will be cleaned up by startup
recovery, but long-running processes can accumulate residue between restarts if seal failures
repeat.
Code

pnpr/crates/pnpr/src/journal.rs[R110-116]

+    pub async fn seal(&self, packages: &[JournaledPublish<'_>]) -> Result<SealedTxn> {
+        let dir = self.root.join(txn_id());
+        fs::create_dir_all(&dir).await?;
+        let mut manifest = Manifest { packages: Vec::with_capacity(packages.len()) };
+        for (index, package) in packages.iter().enumerate() {
+            let packument_file = format!("packument-{index}.json");
+            write_synced(&dir.join(&packument_file), package.packument).await?;
Evidence
seal() creates the txn directory and performs multiple writes without any local error cleanup; on
the caller side, the error path only cleans tmp tarball slots and returns, leaving any
partially-created journal entry behind until the next startup recovery.

pnpr/crates/pnpr/src/journal.rs[101-141]
pnpr/crates/pnpr/src/server.rs[1202-1224]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PublishJournal::seal()` creates the transaction directory early and then performs multiple writes. If any of those writes fail, the function returns an error without removing the partially-created txn directory, leaving journal residue until the next restart/recovery.
### Issue Context
- `commit_publishes()` handles `seal()` errors by cleaning tmp tarball slots, but it does not (and cannot) remove the partially created journal directory because the directory path is not returned on error.
- Startup recovery will delete unsealed entries, but only runs on startup.
### Fix Focus Areas
- Make `PublishJournal::seal()` best-effort cleanup its own txn directory on error (e.g., create `dir` first, then use a guard to `remove_dir_all(&dir)` unless the function reaches the success path).
- Keep cleanup best-effort (ignore cleanup errors) to avoid masking the original failure.
- pnpr/crates/pnpr/src/journal.rs[110-141]
- pnpr/crates/pnpr/src/server.rs[1206-1224]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 0bbfbb7

Results up to commit e6ceed5


🐞 Bugs (12) 📘 Rule violations (0)


Action required
1. Wrong .npmrc warning test 🐞 Bug ≡ Correctness
Description
The new test writes cafile=${ENV_VAR_123} but asserts for the project-level request-destination
warning about registry, which is only emitted for registry/proxy URL settings. This makes the test
incorrect (likely failing) and does not validate the intended behavior.
Code

pnpm/test/getConfig.test.ts[R52-65]

test('console a warning when the .npmrc has an env variable in a project-level registry', async () => {
 prepare()

+  fs.writeFileSync('.npmrc', 'cafile=${ENV_VAR_123}', 'utf8')
+
+  await getConfig({
+    json: false,
+  }, {
+    workspaceDir: '.',
+    excludeReporter: false,
+  })
+
+  expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Ignored project-level request destination "registry"'))
+})
Evidence
The test currently uses cafile=${ENV_VAR_123} but expects a warning that loadNpmrcFiles only
emits for request-destination keys such as registry/proxy; cafile is not treated as a request
destination value key, so this specific warning will not be produced for that input.

pnpm/test/getConfig.test.ts[52-65]
config/reader/src/loadNpmrcFiles.ts[263-275]
config/reader/src/loadNpmrcFiles.ts[294-300]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new Jest test writes a project-level `.npmrc` containing `cafile=${ENV_VAR_123}` but expects a warning that only applies to request-destination keys (e.g. `registry`, `proxy`, `https-proxy`). This mismatch means the test is asserting the wrong behavior and is likely to fail.
### Issue Context
In `config/reader`, the `Ignored project-level request destination ...` warning is only emitted when an env placeholder appears in a *request destination* value (registry/proxy), not for `cafile`.
### Fix Focus Areas
- pnpm/test/getConfig.test.ts[52-65]
### Suggested fix
Update the test to either:
1) Write a request-destination key/value (e.g. `registry=${ENV_VAR_123}` or `https-proxy=${ENV_VAR_123}`) if the intent is to test the request-destination warning; **or**
2) Keep `cafile=${ENV_VAR_123}` but change the expectation to the warning that actually occurs for unresolved env placeholders (e.g. `Failed to replace env in config: ${ENV_VAR_123}`), and rename the test accordingly to avoid referring to `registry`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Broken length assertion 🐞 Bug ≡ Correctness
Description
The new batch publish test calls toHaveLength() on the attachment object instead of on
attachment.length, so it will fail even when the tarball bytes and declared length are correct.
This can break CI for the PR despite a correct implementation.
Code

releasing/commands/test/publish/batchPublish.test.ts[R143-146]

+  const attachment = scopedPkg._attachments['@pnpmtest/batch-pkg-1-1.0.0.tgz']
+  expect(attachment).toBeDefined()
+  const tarballData = Buffer.from(attachment.data, 'base64')
+  expect(attachment).toHaveLength(tarballData.length)
Evidence
attachment is the value of _attachments[...] (an object with { content_type, data, length }),
but the test asserts the object itself has a length equal to the decoded tarball length, rather than
asserting attachment.length.

releasing/commands/test/publish/batchPublish.test.ts[141-150]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A Jest assertion uses `expect(attachment).toHaveLength(...)` where `attachment` is an object that contains a `length` field; `toHaveLength` expects an array/string/Buffer-like value with a `.length` and will not validate the intended field.
### Issue Context
This makes the test fail regardless of correct publish document generation.
### Fix Focus Areas
- releasing/commands/test/publish/batchPublish.test.ts[143-146]
### Suggested fix
Replace the assertion with one that checks the numeric field, e.g.:
- `expect(attachment.length).toBe(tarballData.length)`
(or `expect(attachment.length).toEqual(tarballData.length)`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Recovery skips tarball promotion 🐞 Bug ≡ Correctness
Description
journal::roll_forward() treats any fs::try_exists() error on a staged tarball as “does not
exist”, so it can skip promotion, still write the packument, and then delete the journal
entry—leaving metadata visible without its tarball and without any remaining journal state to retry
from.
Code

pnpr/crates/pnpr/src/journal.rs[R211-236]

+async fn roll_forward(storage: &Storage, dir: &Path) -> Result<()> {
+    let manifest: Manifest = serde_json::from_slice(&fs::read(dir.join(MANIFEST_FILE)).await?)?;
+    for package in &manifest.packages {
+        let name = PackageName::parse(&package.name)?;
+        for tarball in &package.tarballs {
+            if fs::try_exists(&tarball.tmp_path).await.unwrap_or(false) {
+                let slot = TarballSlot::from_parts(
+                    tarball.tmp_path.clone(),
+                    name.clone(),
+                    tarball.filename.clone(),
+                );
+                storage.finalize_tarball_slot(slot).await?;
+            }
+        }
+        let journaled: serde_json::Value =
+            serde_json::from_slice(&fs::read(dir.join(&package.packument_file)).await?)?;
+        let existing: Option<serde_json::Value> = match storage.read_hosted_packument(&name).await?
+        {
+            Some(bytes) => Some(serde_json::from_slice(&bytes)?),
+            None => None,
+        };
+        let merged = merge_manifest(existing.as_ref(), &journaled, &now_iso());
+        storage.write_hosted_packument(&name, &serde_json::to_vec_pretty(&merged)?).await?;
+    }
+    fs::remove_dir_all(dir).await?;
+    Ok(())
Evidence
The recovery path explicitly swallows try_exists() errors (unwrap_or(false)), but still writes
the packument and removes the transaction directory, so a transient probe failure can lead to a
packument being made visible without its tarball and without any journal state left to replay.

pnpr/crates/pnpr/src/journal.rs[211-236]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`roll_forward()` currently does:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Committed txn can rollback 🐞 Bug ≡ Correctness
Description
PublishJournal::recover() treats any error checking for the commit marker as "not committed" via
unwrap_or(false), so a sealed transaction can be rolled back and its staged tarballs deleted during
startup recovery. This can lose a publish that was already sealed as committed in the journal.
Code

pnpr/crates/pnpr/src/journal.rs[R161-168]

+        for dir in txn_dirs {
+            if fs::try_exists(dir.join(COMMIT_MARKER)).await.unwrap_or(false) {
+                roll_forward(storage, &dir).await?;
+                tracing::info!(txn = %dir.display(), "rolled publish journal entry forward");
+            } else {
+                roll_back(&dir).await;
+                tracing::info!(txn = %dir.display(), "rolled publish journal entry back");
+            }
Evidence
recover() currently interprets any try_exists error as “marker absent”, and roll_back()
deletes tarballs referenced by the manifest, so a committed txn can be destroyed if the marker check
fails transiently.

pnpr/crates/pnpr/src/journal.rs[146-169]
pnpr/crates/pnpr/src/journal.rs[225-240]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PublishJournal::recover()` uses `fs::try_exists(...).await.unwrap_or(false)` to decide whether a txn is sealed. Any I/O error (permissions, transient disk error) becomes `false`, which sends the txn to `roll_back()` and can delete staged tarballs for what may actually be a sealed/committed transaction.
### Issue Context
Recovery runs before the server accepts requests, so it is the last line of defense against partial publishes. On error, it should be conservative: never treat “can’t tell” as “unsealed”.
### Fix Focus Areas
- pnpr/crates/pnpr/src/journal.rs[146-169]
- pnpr/crates/pnpr/src/journal.rs[225-240]
### Suggested fix
Replace `unwrap_or(false)` with an explicit match:
- `Ok(true)` => roll forward
- `Ok(false)` => roll back
- `Err(e)` => return `Err(e.into())` (or at least log + abort recovery), so startup fails rather than risking rollback of a committed txn.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Partial apply after seal 🐞 Bug ☼ Reliability
Description
commit_publishes() seals the transaction and then applies packages sequentially; any mid-loop error
returns immediately, potentially leaving earlier packages visible while returning an error for the
overall publish. This breaks the implied all-or-nothing semantics for a single request until a
restart runs journal recovery, and can trigger confusing client retries (e.g., “already published”
for the subset that succeeded).
Code

pnpr/crates/pnpr/src/server.rs[R1226-1234]

+    // Past the seal, failures must NOT clean up the staged files: the
+    // transaction is committed, and startup recovery finishes the
+    // apply from exactly this on-disk state.
+    for stage in staged {
+        for slot in stage.slots {
+            state.inner.storage.finalize_tarball_slot(slot).await?;
+        }
+        state.inner.storage.write_hosted_packument(&stage.name, &stage.merged_bytes).await?;
}
Evidence
After journal.seal() succeeds, the apply loop can make some packages visible and then abort on
error due to ?, propagating an error response while leaving partial state until recovery occurs at
a later startup.

pnpr/crates/pnpr/src/server.rs[1192-1234]
pnpr/crates/pnpr/src/journal.rs[17-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
After sealing the journal, `commit_publishes()` applies each package’s tarballs + packument in a loop using `?`. If applying package N fails after packages 0..N-1 were already written, the handler returns an error but the registry state is partially updated until a restart.
### Issue Context
The code comments claim I/O failure mid-apply “can never leave the batch partially published” because recovery will roll forward later, but the server may continue running without recovery being invoked, and clients will receive an error response even though some packages may already be visible.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1192-1236]
### Suggested fix
Pick one conservative strategy:
1) **Fail-fast + force recovery soon**: if any apply step fails after sealing, log the failure and shut down the process (or otherwise stop accepting requests) so the supervisor restarts and startup recovery completes the sealed txn.
2) **Immediate roll-forward retry**: on apply error after sealing, run a best-effort roll-forward from the just-written journal entry (or call `journal.recover(...)`) and only then decide what status to return.
3) **Change response semantics**: return a dedicated error/status that clearly indicates “committed but not yet applied; do not retry”, ideally with a txn id.
Also consider applying tarballs for **all** packages first and packuments second to reduce the window of partial *packument* visibility (though it doesn’t fully eliminate partial outcomes if packument writes fail mid-way).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Scoped tarball URL 404 🐞 Bug ≡ Correctness
Description

...

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add pnpm publish -r --batch and pnpr batch publish endpoint with crash-atomic commits
✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Add --batch to pnpm publish --recursive to publish packages per-registry in one request.
• Implement PUT /-/pnpm/v1/publish in pnpr with all-or-nothing staging and commits.
• Add integration tests for batch publish behavior, rollback, and crash recovery.
Diagram
graph TD
A["pnpm publish -r --batch"] --> B["batchPublish.ts"] --> C{{"PUT /-/pnpm/v1/publish"}} --> D["pnpr: serve_batch_publish"] --> E["validate + stage"] --> F[("commit journal")]
F --> G[("hosted store")]
H["startup recovery"] --> F
subgraph Legend
direction LR
_cli["Client/CLI"] ~~~ _ext{{"HTTP endpoint"}} ~~~ _db[("On-disk state")]
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. HTTP/2 keep-alive + limited parallel per-package publishes
  • ➕ Works with any npm-compatible registry (no new server endpoint required)
  • ➕ Avoids very large single JSON bodies (base64 tarballs) and proxy limits
  • ➖ Still incurs N requests and N registry-side transactions
  • ➖ Cannot provide cross-package atomicity semantics
2. Two-phase batch protocol (upload tarballs separately, then commit)
  • ➕ Allows streaming/multipart uploads instead of base64-in-JSON
  • ➕ More control over partial failures (e.g., retry tarball upload without rebuilding docs)
  • ➖ More endpoints and protocol complexity
  • ➖ Harder to keep wire-compat with libnpmpublish semantics
3. Server-generated packuments from tarball metadata
  • ➕ Client could send fewer fields and reuse existing tarball packing only
  • ➕ Server could centralize policy enforcement and metadata normalization
  • ➖ Server must replicate npm client metadata generation (dist tags, access, etc.)
  • ➖ Higher risk of drift from npm/libnpmpublish behavior

Recommendation: Keep the current approach: reusing the exact single-package publish document shape makes the feature predictable and lets pnpr reuse the existing publish pipeline per entry. The added commit journal + startup recovery is a strong correctness measure for batch atomicity and also benefits single-package publishes. The main follow-up to consider is operational: enforce/request-size guidance (or future streaming protocol) if very large workspaces hit proxy/body-size limits.

Grey Divider

File Changes

Enhancement (6)
journal.rs Introduce publish commit journal for crash-atomic applies +268/-0

Introduce publish commit journal for crash-atomic applies

• Implements a sealed transaction journal under '.pnpr-journal/<txn>/' that records merged packuments and staged tarball tmp paths. Adds recovery logic to roll sealed transactions forward idempotently and unsealed ones back.

pnpr/crates/pnpr/src/journal.rs


server.rs Add batch publish route and refactor publish into validate/stage/commit +241/-45

Add batch publish route and refactor publish into validate/stage/commit

• Adds 'PUT /-/pnpm/v1/publish' handler with duplicate-name checks, lock acquisition across all packages, and all-or-nothing staging. Refactors single-package publish to reuse 'validate_publish_doc', 'stage_publish', and 'commit_publishes', and runs journal recovery before serving.

pnpr/crates/pnpr/src/server.rs


storage.rs Add TarballSlot reconstruction and publish journal accessor +24/-0

Add TarballSlot reconstruction and publish journal accessor

• Adds helpers to rebuild a 'TarballSlot' from journaled parts and exposes 'Storage::publish_journal()' locating the journal root appropriately for fs vs S3-backed storage.

pnpr/crates/pnpr/src/storage.rs


batchPublish.ts Implement client-side batch publish packing and per-registry request batching +249/-0

Implement client-side batch publish packing and per-registry request batching

• Introduces batch publishing: pack all selected packages first, build libnpmpublish-shaped documents with base64 '_attachments', group by target registry, then send one 'PUT /-/pnpm/v1/publish' per registry via 'npm-registry-fetch' with OTP handling. Adds guardrails to reject staged publishing and provenance under '--batch', and runs publish/postpublish scripts only after success.

releasing/commands/src/publish/batchPublish.ts


publish.ts Add '--batch' CLI flag and enforce '--recursive' requirement +10/-0

Add '--batch' CLI flag and enforce '--recursive' requirement

• Registers the '--batch' option in CLI types/help output and rejects usage without '--recursive' with a dedicated 'ERR_PNPM_BATCH_PUBLISH_REQUIRES_RECURSIVE' error and hint.

releasing/commands/src/publish/publish.ts


recursivePublish.ts Route recursive publish through batch path when '--batch' is set +47/-37

Route recursive publish through batch path when '--batch' is set

• Adds a batch mode that collects already-filtered projects in dependency order and calls 'batchPublishPackages()' instead of publishing each package individually.

releasing/commands/src/publish/recursivePublish.ts


Refactor (3)
lib.rs Wire journal module and export recovery entrypoint +2/-0

Wire journal module and export recovery entrypoint

• Registers the new 'journal' module and re-exports 'recover_publish_journal' for use by server startup and embedders.

pnpr/crates/pnpr/src/lib.rs


publishPackedPkg.ts Share publish summary creation and add OIDC opt-out for batch +28/-31

Share publish summary creation and add OIDC opt-out for batch

• Factors summary construction into 'createPublishSummary()' and updates 'createPublishOptions()' to accept '{ oidc: false }' to skip per-package OIDC exchange for batch publishes. Exports 'findRegistryInfo()' and 'isPublishAccess()' for reuse by batch publish.

releasing/commands/src/publish/publishPackedPkg.ts


publishSummary.ts Add 'createPublishSummary()' helper for consistent publish reporting +34/-0

Add 'createPublishSummary()' helper for consistent publish reporting

• Introduces a shared helper that builds npm-like publish summaries (size, shasum, integrity, file list) from packed tarball data, used by both per-package and batch publish flows.

releasing/commands/src/tarball/publishSummary.ts


Tests (3)
batch_publish.rs Integration tests for 'PUT /-/pnpm/v1/publish' semantics +342/-0

Integration tests for 'PUT /-/pnpm/v1/publish' semantics

• Adds hermetic tests verifying multi-package publish success, scoped attachment naming compatibility, anonymous rejection, rollback on integrity failure (no artifacts), duplicate-name rejection, and packument merge behavior.

pnpr/crates/pnpr/tests/batch_publish.rs


journal_recovery.rs Integration tests for publish journal sealing and recovery +243/-0

Integration tests for publish journal sealing and recovery

• Adds tests that fabricate sealed/unsealed journal entries and verify roll-forward/rollback behavior, merge-safe replay, no-op behavior when journal dir is absent, and cleanup after successful publishes.

pnpr/crates/pnpr/tests/journal_recovery.rs


batchPublish.test.ts Jest tests for pnpm '--batch' behavior and error handling +238/-0

Jest tests for pnpm '--batch' behavior and error handling

• Adds tests ensuring recursive batch publish sends one request to '/-/pnpm/v1/publish', omits private packages, preserves libnpmpublish wire shape, supports '--dry-run', surfaces unsupported-registry errors, and rejects '--batch' without '--recursive'.

releasing/commands/test/publish/batchPublish.test.ts


Documentation (1)
batch-publish-single-request.md Document new '--batch' recursive publish behavior and endpoint +6/-0

Document new '--batch' recursive publish behavior and endpoint

• Adds a changeset announcing 'pnpm publish --recursive --batch' and the 'PUT /-/pnpm/v1/publish' endpoint. Notes unsupported registry error and pnpr’s all-or-nothing semantics.

.changeset/batch-publish-single-request.md


Other (3)
pnpm-lock.yaml Add npm-registry-fetch and types to the workspace lockfile +12/-0

Add npm-registry-fetch and types to the workspace lockfile

• Updates the lockfile to include 'npm-registry-fetch@19.x' and '@types/npm-registry-fetch@8.x' required by the new batch publish implementation.

pnpm-lock.yaml


pnpm-workspace.yaml Register npm-registry-fetch and types in the workspace catalog +2/-0

Register npm-registry-fetch and types in the workspace catalog

• Extends workspace catalog versions with 'npm-registry-fetch' and '@types/npm-registry-fetch' so packages can depend on them via 'catalog:'.

pnpm-workspace.yaml


package.json Add npm-registry-fetch dependencies for batch publish client +2/-0

Add npm-registry-fetch dependencies for batch publish client

• Adds 'npm-registry-fetch' and its type package to '@pnpm/releasing.commands' to support direct 'PUT /-/pnpm/v1/publish' requests.

releasing/commands/package.json


Grey Divider

Qodo Logo

Comment thread releasing/commands/src/publish/batchPublish.ts
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3b6e231

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit bba3066

Comment thread pnpr/crates/pnpr/src/journal.rs
Comment thread pnpr/crates/pnpr/src/server.rs Outdated

@ranm8 ranm8 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested end to end and it works perfectly

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 13, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 85e347b

zkochan added 7 commits June 13, 2026 16:39
…equest

When publishing recursively, the new opt-in --batch flag packs every
selected package first and sends them all to the registry in one
"PUT /-/v1/multi-publish" request per target registry, instead of one
request per package. The endpoint is not part of the standard npm
registry API; registries that lack it are reported with
ERR_PNPM_MULTI_PUBLISH_UNSUPPORTED.

pnpr implements the endpoint with all-or-nothing semantics: every
document is validated (name, publish policy, attachment integrity) and
every tarball is fully written to a tmp slot before anything becomes
visible, so a batch that fails midway leaves no new versions behind.
The single-package publish handler was refactored into shared
validate/stage/commit steps, and package-lock stripes are acquired in
sorted order so overlapping batches cannot deadlock.
…/v1/

Vendor-namespace the batch publish endpoint as /-/pnpm/v1/multi-publish,
mirroring how the npm client keeps its registry extensions under
/-/npm/v1/. The unprefixed /-/v1/ namespace is effectively owned by the
npm registry API (login, search, tokens), so a future npm endpoint at
the same path could collide with different semantics. The pnpm prefix
makes the path unambiguously a pnpm-client extension while staying
server-implementation-neutral.
The endpoint is not inherently about multiple packages — a single
package is just a batch of one — so name it after the operation rather
than the cardinality. The unsupported-registry error is renamed to
ERR_PNPM_BATCH_PUBLISH_UNSUPPORTED to match the other BATCH_PUBLISH_*
error codes.
A publish (single-package or batch) is applied in several non-atomic
steps — one rename/upload per tarball, one packument write per package
— so a crash mid-apply could leave a batch partially published. Before
anything is promoted, the full intent (merged packument bytes + staged
tmp-file locations) is now persisted under .pnpr-journal/<txn>/ and
sealed with a single atomic rename of the commit marker. Startup
recovery rolls sealed transactions forward (every apply step is
idempotent, and the packument is re-merged into the current on-disk
state so versions published between a failed apply and the restart
survive) and rolls unsealed ones back, so a publish is either fully
visible or fully absent.
libnpmpublish-based clients submit dist.tarball with the scoped
filename (.../-/@scope/name-1.0.0.tgz). The registry never serves that
string verbatim: rewrite_dist_tarball rebuilds the URL from the
basename, so consumers always see the routable 4-segment form. The
batch publish scoped test now submits the real client wire form and
asserts the served packument exposes the canonical URL.
…locking

Project-level .npmrc request destinations (registry=, proxy keys) no
longer go through env expansion at all — they are dropped with a
dedicated warning — so registry=${VAR} can't produce the generic
'Failed to replace env in config' warning anymore. Exercise the
env-replace warning through cafile= (still expanded) and pin the
request-destination ignore warning in its own test.
Two reliability fixes from PR review:

- Journal recovery treated any I/O error probing the commit marker as
  "unsealed" (`try_exists(...).unwrap_or(false)`), so a transient error
  could roll back an already-committed transaction and delete its staged
  tarballs. Propagate the error instead, so startup fails loudly rather
  than risk losing a sealed publish.

- After sealing, the commit loop applied packages with `?`; a mid-loop
  failure returned an error while earlier packages were already visible,
  leaving a running server partially published until the next restart's
  recovery. On apply failure, complete the sealed transaction immediately
  via the same idempotent roll-forward, falling back to the original
  error with startup recovery as the final backstop.
Comment thread pnpr/crates/pnpr/src/journal.rs
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 13, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 4d4830f

roll_forward treated any fs::try_exists error on a staged tarball as
"missing", so a transient I/O error would skip promotion, still write the
packument, and delete the journal entry — advertising a tarball with
nothing on disk and no journal state left to retry from. Propagate the
error instead, mirroring the commit-marker probe, so recovery aborts and
the transaction survives for a later attempt.
Comment thread releasing/commands/test/publish/batchPublish.test.ts
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 13, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 87a82c5

Comment thread pnpm/test/getConfig.test.ts Outdated
cafile=${ENV_VAR_123} asserted the registry request-destination warning,
which is only emitted for registry/proxy URLs; cafile is still
env-expanded, so an unresolved placeholder surfaces the generic
"Failed to replace env in config" warning. Assert that instead and
retitle the test (the request-destination case has its own test).
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 13, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e6ceed5

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pnpr/crates/pnpr/src/server.rs (1)

933-951: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require a string name on the single-package publish path.

publish_package() only rejects a mismatch when incoming["name"] is already a string. A body with a missing or non-string name still reaches validate_publish_doc() and can be merged into the hosted packument, while the batch path already rejects that exact shape at Lines 1009-1013. This leaves the single-request endpoint able to persist a malformed packument that the batch endpoint would refuse.

Suggested fix
-    let body_name = incoming.get("name").and_then(Value::as_str);
-    if body_name.is_some_and(|body_name| body_name != name.as_str()) {
+    let Some(body_name) = incoming.get("name").and_then(Value::as_str) else {
+        return error_response(&RegistryError::BadRequest {
+            reason: "body must have a string `name`".to_string(),
+        });
+    };
+    if body_name != name.as_str() {
         return error_response(&RegistryError::BadRequest {
             reason: format!(
                 "package in URL ({:?}) does not match body ({:?})",
                 name.as_str(),
-                body_name.unwrap_or(""),
+                body_name,
             ),
         });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pnpr/crates/pnpr/src/server.rs` around lines 933 - 951, The single-package
publish path currently only rejects a name mismatch when incoming.get("name") is
a string, allowing missing or non-string "name" values to reach
validate_publish_doc(); update the check around incoming.get("name") in
publish_package so it requires a string name and returns a
RegistryError::BadRequest (same shape/message as the mismatch case) when the
"name" field is missing or not a string, i.e. change the logic that computes
body_name and the conditional so non-string or absent values produce an
immediate error response instead of calling validate_publish_doc().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pnpr/crates/pnpr/src/server.rs`:
- Around line 953-957: delete_package() and delete_tarball() mutate the same
package state but don't take the package_locks lock, so they can race with
stage_publish()/commit_publishes() and packument updates; update both
delete_package and delete_tarball to acquire the same package lock used
elsewhere (i.e., call
state.inner.package_locks.lock(package_name.as_str()).await and hold the guard
for the duration of the on-disk mutations) so deletes/unpublishes serialize with
stage_publish()/commit_publishes() and the packument update (no change needed to
the existing lock call in stage_publish/commit_publishes()).

In `@releasing/commands/test/publish/batchPublish.test.ts`:
- Around line 58-63: The stub constructs a URL using 'localhost' while the
server is bound to 127.0.0.1 (server.listen(0, '127.0.0.1', ...)), causing
intermittent IPv6 resolution failures; update the returned url property on the
stub to use the same loopback host ('127.0.0.1')—i.e., change url:
`http://localhost:${port}/` to url: `http://127.0.0.1:${port}/` (the rest of the
stub, including close, remains unchanged).

---

Outside diff comments:
In `@pnpr/crates/pnpr/src/server.rs`:
- Around line 933-951: The single-package publish path currently only rejects a
name mismatch when incoming.get("name") is a string, allowing missing or
non-string "name" values to reach validate_publish_doc(); update the check
around incoming.get("name") in publish_package so it requires a string name and
returns a RegistryError::BadRequest (same shape/message as the mismatch case)
when the "name" field is missing or not a string, i.e. change the logic that
computes body_name and the conditional so non-string or absent values produce an
immediate error response instead of calling validate_publish_doc().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 31d12f53-3dc6-4e21-a25a-b225e043083b

📥 Commits

Reviewing files that changed from the base of the PR and between 85e347b and 87a82c5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (16)
  • .changeset/batch-publish-single-request.md
  • pnpm-workspace.yaml
  • pnpm/test/getConfig.test.ts
  • pnpr/crates/pnpr/src/journal.rs
  • pnpr/crates/pnpr/src/lib.rs
  • pnpr/crates/pnpr/src/server.rs
  • pnpr/crates/pnpr/src/storage.rs
  • pnpr/crates/pnpr/tests/batch_publish.rs
  • pnpr/crates/pnpr/tests/journal_recovery.rs
  • releasing/commands/package.json
  • releasing/commands/src/publish/batchPublish.ts
  • releasing/commands/src/publish/publish.ts
  • releasing/commands/src/publish/publishPackedPkg.ts
  • releasing/commands/src/publish/recursivePublish.ts
  • releasing/commands/src/tarball/publishSummary.ts
  • releasing/commands/test/publish/batchPublish.test.ts
✅ Files skipped from review due to trivial changes (2)
  • .changeset/batch-publish-single-request.md
  • pnpm-workspace.yaml
🚧 Files skipped from review as they are similar to previous changes (9)
  • releasing/commands/package.json
  • pnpr/crates/pnpr/src/lib.rs
  • pnpr/crates/pnpr/src/storage.rs
  • releasing/commands/src/publish/publishPackedPkg.ts
  • pnpr/crates/pnpr/tests/journal_recovery.rs
  • releasing/commands/src/publish/recursivePublish.ts
  • releasing/commands/src/publish/publish.ts
  • pnpr/crates/pnpr/src/journal.rs
  • pnpr/crates/pnpr/tests/batch_publish.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Code Coverage
  • GitHub Check: Audit dependencies
  • GitHub Check: Analyze (javascript)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with trailing commas, prefer functions over classes, declare functions after they are used (relying on hoisting), limit functions to no more than two or three arguments, and use a single options object for functions needing more parameters
Follow import order: standard libraries first, then external dependencies (sorted alphabetically), then relative imports
Do not write comments that restate what the code already says; rename variables, split helpers, or move checks to more obvious places instead
Do not repeat documentation at call sites that already lives on the callee; update the JSDoc once and let every call site benefit
Use JSDoc for the function's contract (preconditions, postconditions, edge cases, why the function exists), not for re-narrating the function body
Do not record past implementation shape, refactor history, or removed code in comments; use git log and git blame for that information instead
Write comments only when the reason for code is non-obvious, a hidden invariant exists, a workaround for a known bug is needed, or an exception to surrounding pattern is deliberate

Files:

  • pnpm/test/getConfig.test.ts
  • releasing/commands/src/publish/batchPublish.ts
  • releasing/commands/src/tarball/publishSummary.ts
  • releasing/commands/test/publish/batchPublish.test.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Do not use instanceof Error for checking if a caught error is an Error object in Jest tests; use util.types.isNativeError() instead to work across realms

Files:

  • pnpm/test/getConfig.test.ts
  • releasing/commands/test/publish/batchPublish.test.ts
pnpr/**/pnpr/**/*.rs

📄 CodeRabbit inference engine (pnpr/AGENTS.md)

pnpr/**/pnpr/**/*.rs: Follow the pacquet code-style guide (../pacquet/CODE_STYLE_GUIDE.md) for Rust-level conventions including imports, naming, ownership, and error handling
Follow the pacquet contributing guide (../pacquet/CONTRIBUTING.md) for test layout and Rust conventions

Files:

  • pnpr/crates/pnpr/src/server.rs
🧠 Learnings (6)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.

Applied to files:

  • pnpm/test/getConfig.test.ts
  • releasing/commands/src/publish/batchPublish.ts
  • releasing/commands/src/tarball/publishSummary.ts
  • releasing/commands/test/publish/batchPublish.test.ts
📚 Learning: 2026-05-24T08:18:00.622Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11895
File: pnpm/test/deploy.ts:91-95
Timestamp: 2026-05-24T08:18:00.622Z
Learning: In pnpm/pnpm integration tests that intentionally hit the real npm registry (registry.npmjs.org)—for example when the test passes `--config.registry=https://registry.npmjs.org/` to `execPnpm` and simply increases the timeout—do not request adding a runtime environment-variable gate (e.g., `PNPM_RUN_PUBLIC_REGISTRY_TESTS`). This is the established pattern for those tests (see files like `pnpm/test/install/pacquet.ts` and `pnpm/test/deploy.ts`).

Applied to files:

  • pnpm/test/getConfig.test.ts
📚 Learning: 2026-06-05T13:47:05.929Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/test/install/frozenStore.ts:2-17
Timestamp: 2026-06-05T13:47:05.929Z
Learning: In the pnpm/pnpm repository, the shared Jest preset keeps `injectGlobals` at its default (`true`), so `test` and `expect` are available as Jest globals. Therefore, reviewers should not flag (or treat as TypeScript/compilation errors) missing `import { test, expect } from 'jest/globals'` when a test file uses `test`/`expect` without importing them. Importing from `jest/globals` may still be used for consistency with sibling files, but it is not required for execution in this repo unless a Jest preset is explicitly configured with `injectGlobals: false`.

Applied to files:

  • pnpm/test/getConfig.test.ts
  • releasing/commands/test/publish/batchPublish.test.ts
📚 Learning: 2026-06-05T13:47:26.046Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/src/install/index.ts:2337-2343
Timestamp: 2026-06-05T13:47:26.046Z
Learning: In the pnpm/pnpm codebase, `PnpmError` automatically prefixes `err.code` with `ERR_PNPM_` when you pass a code that does not already start with `ERR_PNPM_` (it normalizes `this.code` via `code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}``). Therefore, during code review you should NOT flag `new PnpmError(...)` call sites for passing a bare error code (e.g., `new PnpmError('FROZEN_STORE_INCOMPATIBLE_WITH_PNPR', ...)`); the resulting `err.code` will still be `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`.

Applied to files:

  • pnpm/test/getConfig.test.ts
  • releasing/commands/src/publish/batchPublish.ts
  • releasing/commands/src/tarball/publishSummary.ts
  • releasing/commands/test/publish/batchPublish.test.ts
📚 Learning: 2026-06-06T18:58:37.156Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12243
File: pacquet/crates/package-manager/src/install_package_by_snapshot.rs:319-322
Timestamp: 2026-06-06T18:58:37.156Z
Learning: When reviewing Rust code, do not assume `matches!(expr, Pattern(_))` will move out of `expr` if `Pattern(_)` contains no by-value bindings. `matches!` desugars to a `match` that auto-borrows the scrutinee for discrimination, so even if `expr` is a non-`Copy` value behind a shared reference (e.g., `&T`), the macro should not move-out of the borrowed data purely due to `matches!`. Treat `matches!(&expr, Pattern(_))` as a readability/clarity improvement, not a correctness requirement. Only flag potential move-out-of-borrow risks when the pattern includes by-value bindings that would require moving the matched value.

Applied to files:

  • pnpr/crates/pnpr/src/server.rs
📚 Learning: 2026-06-12T20:41:57.558Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12364
File: pacquet/crates/package-manager/src/install/tests.rs:5717-5717
Timestamp: 2026-06-12T20:41:57.558Z
Learning: In the pnpm/pnpm Rust workspace (toolchain Rust 1.95.0), keep using `std::time::Duration::from_mins(...)` and `std::time::Duration::from_hours(...)` as-is. Do not flag these as invalid or suggest replacing them with `Duration::from_secs(...)`, because Clippy’s `clippy::duration_suboptimal_units` lint is denied by `-D warnings` in CI, and changing to seconds may trigger CI failures.

Applied to files:

  • pnpr/crates/pnpr/src/server.rs
🪛 ast-grep (0.43.0)
releasing/commands/src/publish/batchPublish.ts

[warning] 227-227: Avoid SHA1 security protocol
Context: createHash('sha1').update(tarballData).digest('hex')
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 227-227: Avoid SHA1 security protocol
Context: createHash('sha1').update(tarballData)
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 227-227: Avoid SHA1 security protocol
Context: createHash('sha1')
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 227-227: Avoid SHA1 security protocol
Context: 'sha1'
Note: Security best practice.

(avoid-crypto-sha1-typescript)

releasing/commands/src/tarball/publishSummary.ts

[warning] 54-54: Avoid SHA1 security protocol
Context: createHash('sha1').update(tarballData).digest('hex')
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 54-54: Avoid SHA1 security protocol
Context: createHash('sha1').update(tarballData)
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 54-54: Avoid SHA1 security protocol
Context: createHash('sha1')
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 54-54: Avoid SHA1 security protocol
Context: 'sha1'
Note: Security best practice.

(avoid-crypto-sha1-typescript)

releasing/commands/test/publish/batchPublish.test.ts

[info] 35-55: Make sure your server uses the https protocol
Context: http.createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
received.push({
method: req.method!,
url: req.url!,
body: rawBody.length > 0 ? JSON.parse(rawBody.toString()) : undefined,
})
if (req.method === 'PUT' && req.url === '/-/pnpm/v1/publish') {
res.statusCode = stub.multiPublishStatusCode
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: true, success: true }))
return
}
res.statusCode = 404
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ error: 'not found' }))
})
})
Note: Security best practice.

(https-protocol-missing-typescript)


[warning] 98-150: Avoid SHA1 security protocol
Context: test('batch publish sends all packages in a single batch publish request', async () => {
preparePackages([
{
name: '@pnpmtest/batch-pkg-1',
version: '1.0.0',
},
{
name: 'batch-pkg-2',
version: '2.0.0',
},
{
name: 'i-am-private',
version: '1.0.0',
private: true,
},
])

await publish.handler({
...batchPublishOpts(),
...await filterProjectsBySelectorObjectsFromDir(process.cwd(), []),
tag: 'next',
}, [])

const publishRequests = registry.received.filter(({ url }) => url === '/-/pnpm/v1/publish')
expect(publishRequests).toHaveLength(1)
expect(publishRequests[0].method).toBe('PUT')

const { packages } = publishRequests[0].body as {
packages: Array<{
_id: string
name: string
'dist-tags': Record<string, string>
versions: Record<string, { dist: { integrity: string, shasum: string, tarball: string } }>
_attachments: Record<string, { content_type: string, data: string, length: number }>
}>
}
expect(packages.map(({ name }) => name).sort()).toStrictEqual(['@pnpmtest/batch-pkg-1', 'batch-pkg-2'])

const scopedPkg = packages.find(({ name }) => name === '@pnpmtest/batch-pkg-1')!
expect(scopedPkg._id).toBe('@pnpmtest/batch-pkg-1')
expect(scopedPkg['dist-tags']).toStrictEqual({ next: '1.0.0' })

// The attachment is keyed by the full (scoped) name and its bytes match dist.integrity,
// the same wire shape libnpmpublish produces for a single-package publish.
const attachment = scopedPkg._attachments['@pnpmtest/batch-pkg-1-1.0.0.tgz']
expect(attachment).toBeDefined()
const tarballData = Buffer.from(attachment.data, 'base64')
expect(attachment).toHaveLength(tarballData.length)
const { dist } = scopedPkg.versions['1.0.0']
expect(dist.integrity).toBe(sha512-${createHash('sha512').update(tarballData).digest('base64')})
expect(dist.shasum).toBe(createHash('sha1').update(tarballData).digest('hex'))
expect(dist.tarball).toBe(${registry.url}@pnpmtest/batch-pkg-1/-/@pnpmtest/batch-pkg-1-1.0.0.tgz)
})
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 148-148: Avoid SHA1 security protocol
Context: expect(dist.shasum).toBe(createHash('sha1').update(tarballData).digest('hex'))
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 148-148: Avoid SHA1 security protocol
Context: createHash('sha1').update(tarballData).digest('hex')
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 148-148: Avoid SHA1 security protocol
Context: createHash('sha1').update(tarballData)
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 148-148: Avoid SHA1 security protocol
Context: createHash('sha1')
Note: Security best practice.

(avoid-crypto-sha1-typescript)


[warning] 148-148: Avoid SHA1 security protocol
Context: 'sha1'
Note: Security best practice.

(avoid-crypto-sha1-typescript)

🔇 Additional comments (4)
pnpm/test/getConfig.test.ts (2)

67-73: LGTM!


55-65: Clarify the warning/key pairing: cafile is not a request-destination key/value, so the request-destination warning expectation doesn’t belong to the .npmrc fixture using cafile=${ENV_VAR_123}. The Ignored project-level request destination "registry" assertion is made in the separate test that writes registry=${ENV_VAR_123} (and warnIgnoredRequestDestinationEnv is gated to request-destination keys like registry/https-proxy/proxy or //..., not cafile).

			> Likely an incorrect or invalid review comment.
releasing/commands/src/tarball/publishSummary.ts (1)

1-2: LGTM!

Also applies to: 35-63

releasing/commands/src/publish/batchPublish.ts (1)

1-249: LGTM!

Comment thread pnpr/crates/pnpr/src/server.rs
Comment thread releasing/commands/test/publish/batchPublish.test.ts
delete_package and delete_tarball mutated package storage without taking
the per-package lock that every publish and dist-tag path holds, so a
same-package DELETE could race a stage-and-commit and remove the package
or a tarball mid-write, leaving the on-disk state dependent on filesystem
timing. Acquire the lock before the removal, completing the same-package
serialization guarantee.

Also bind the batch-publish test stub URL to 127.0.0.1 to match the
listener, avoiding intermittent IPv6 connection failures.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 13, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 0bbfbb7

Comment thread releasing/commands/src/publish/batchPublish.ts
@zkochan zkochan merged commit f1521cf into main Jun 13, 2026
20 of 23 checks passed
@zkochan zkochan deleted the multi-publish branch June 13, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants