feat(publish): add --batch flag to publish all packages in a single request#12299
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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. ChangesBatch publish and journal recovery
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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
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
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
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
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
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
]
}
]
} |
|
| Branch | pr/12299 |
| Testbed | pacquet |
Click to view all benchmark results
| Benchmark | Latency | Benchmark 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%) |
|
| Branch | pr/12299 |
| Testbed | pnpr |
⚠️ 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-thresholdsflag.
Click to view all benchmark results
| Benchmark | Latency | milliseconds (ms) |
|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot | 2,166.98 ms |
| isolated-linker.fresh-install.cold-cache.hot-store | 📈 view plot | 637.84 ms |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot | 647.12 ms |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot | 2,147.86 ms |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot | 699.65 ms |
Code Review by Qodo
1. Version normalization mismatch
|
PR Summary by QodoAdd WalkthroughsDescription• 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. Diagramgraph 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
High-Level AssessmentThe following are alternative approaches to this PR: 1. HTTP/2 keep-alive + limited parallel per-package publishes
2. Two-phase batch protocol (upload tarballs separately, then commit)
3. Server-generated packuments from tarball metadata
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. File ChangesEnhancement (6)
Refactor (3)
Tests (3)
Documentation (1)
Other (3)
|
|
Code review by qodo was updated up to the latest commit 3b6e231 |
|
Code review by qodo was updated up to the latest commit bba3066 |
ranm8
left a comment
There was a problem hiding this comment.
I tested end to end and it works perfectly
|
Code review by qodo was updated up to the latest commit 85e347b |
…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.
|
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.
|
Code review by qodo was updated up to the latest commit 87a82c5 |
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).
|
Code review by qodo was updated up to the latest commit e6ceed5 |
There was a problem hiding this comment.
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 winRequire a string
nameon the single-package publish path.
publish_package()only rejects a mismatch whenincoming["name"]is already a string. A body with a missing or non-stringnamestill reachesvalidate_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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (16)
.changeset/batch-publish-single-request.mdpnpm-workspace.yamlpnpm/test/getConfig.test.tspnpr/crates/pnpr/src/journal.rspnpr/crates/pnpr/src/lib.rspnpr/crates/pnpr/src/server.rspnpr/crates/pnpr/src/storage.rspnpr/crates/pnpr/tests/batch_publish.rspnpr/crates/pnpr/tests/journal_recovery.rsreleasing/commands/package.jsonreleasing/commands/src/publish/batchPublish.tsreleasing/commands/src/publish/publish.tsreleasing/commands/src/publish/publishPackedPkg.tsreleasing/commands/src/publish/recursivePublish.tsreleasing/commands/src/tarball/publishSummary.tsreleasing/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.tsreleasing/commands/src/publish/batchPublish.tsreleasing/commands/src/tarball/publishSummary.tsreleasing/commands/test/publish/batchPublish.test.ts
**/*.test.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
Do not use
instanceof Errorfor checking if a caught error is an Error object in Jest tests; useutil.types.isNativeError()instead to work across realms
Files:
pnpm/test/getConfig.test.tsreleasing/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.tsreleasing/commands/src/publish/batchPublish.tsreleasing/commands/src/tarball/publishSummary.tsreleasing/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.tsreleasing/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.tsreleasing/commands/src/publish/batchPublish.tsreleasing/commands/src/tarball/publishSummary.tsreleasing/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:cafileis not a request-destination key/value, so the request-destination warning expectation doesn’t belong to the.npmrcfixture usingcafile=${ENV_VAR_123}. TheIgnored project-level request destination "registry"assertion is made in the separate test that writesregistry=${ENV_VAR_123}(andwarnIgnoredRequestDestinationEnvis gated to request-destination keys likeregistry/https-proxy/proxyor//..., notcafile).> 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!
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.
|
Code review by qodo was updated up to the latest commit 0bbfbb7 |
What
Adds an opt-in
--batchflag topnpm publish --recursivethat sends all selected packages to the registry in a single request instead of one request per package, and implements the corresponding endpoint in pnpr.Client side (
@pnpm/releasing.commands)batchPublish.ts: packs every selected package first (runningprepublishOnly/prepublish), builds one npm publish document per package — exactly the JSON bodylibnpmpublishwould send for a single-packagePUT /:pkg(same_attachmentsbase64 tarball,dist.integrity/shasum/tarball) — groups the documents by target registry, and sendsPUT <registry>/-/pnpm/v1/publishwith the body{"packages": [...]}vianpm-registry-fetch.publish/postpublishscripts run only after the request succeeds.--tag,--access,--dry-run,--report-summary, and--jsonwork the same as the per-package path. The summary-building code is shared via a newcreatePublishSummary()helper.ERR_PNPM_BATCH_PUBLISH_UNSUPPORTEDand a hint to retry without--batch.--batchwithout--recursive→ERR_PNPM_BATCH_PUBLISH_REQUIRES_RECURSIVE.--provenanceand 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 (createPublishOptionsgained anoidc: falsemode).Registry side (pnpr)
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 inpackagesis processed by the same pipeline as a single-package publish, which was refactored into sharedvalidate_publish_doc→stage_publish→commit_publishsteps.PackageLocks::lock_manyacquires 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.versions)..pnpr-journal/<txn>/and sealed with a single atomic rename of thecommitmarker. Startup recovery (run byserve/serve_listenerbefore accepting requests, also exported aspnpr::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
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.test/publish/batchPublish.test.ts, 5 tests): wire-format assertions against a recording stub registry (single request, attachment bytes matchdist.integrity, dist-tags),--dry-runsends nothing, unsupported-registry error, flag validation, and an end-to-end test that publishes through a real pnpr server and reads the packages back withpnpm view.cargo test -p pnprsuite pass; eslint (from root), clippy, rustfmt, andcargo doc -D warningsare clean.Notes
publishis outside pacquet's current command surface (dependency-management commands only).pnpm.iopublish.mdshould document--batchwhen this lands.Written by an agent (Claude Code, claude-fable-5).
Summary by CodeRabbit
New Features
Reliability
Error Handling
Improvements
Tests
Chores