Skip to content

Commit 8908c5e

Browse files
authored
fix: overriding version of "pnpm" repo (#2883)
The default `pnpm` repo was always taking priority and could not be overridden. See #2349 (comment) ### Changes are visible to end-users: no ### Test plan - Covered by existing test cases - New test cases added
1 parent 427b524 commit 8908c5e

7 files changed

Lines changed: 242 additions & 26 deletions

File tree

e2e/bzlmod/BUILD.bazel

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,49 @@ js_test(
2626
entry_point = "main.mjs",
2727
)
2828

29+
# Capture the versions of the pnpm binaries resolved by the pnpm module extension
30+
# so :pnpm_versions_test can assert on them.
31+
js_run_binary(
32+
name = "pnpm_version",
33+
args = ["--version"],
34+
stdout = "pnpm_version.txt",
35+
tool = "@pnpm",
36+
)
37+
38+
js_run_binary(
39+
name = "other_pnpm_version",
40+
args = ["--version"],
41+
stdout = "other_pnpm_version.txt",
42+
tool = "@other_pnpm//:pnpm",
43+
)
44+
45+
# Run npm via the resolved @pnpm to prove it was bundled (include_npm). This only
46+
# succeeds if npm is on PATH, which requires the version-less include_npm tag to be
47+
# honored even though @pnpm's version comes from a different tag. A package.json must
48+
# be present for `pnpm exec` to run.
49+
js_run_binary(
50+
name = "pnpm_npm_version",
51+
srcs = ["package.json"],
52+
args = [
53+
"exec",
54+
"npm",
55+
"--version",
56+
],
57+
stdout = "pnpm_npm_version.txt",
58+
tool = "@pnpm",
59+
)
60+
61+
js_test(
62+
name = "pnpm_versions_test",
63+
data = [
64+
"other_pnpm_version.txt",
65+
"package.json",
66+
"pnpm_npm_version.txt",
67+
"pnpm_version.txt",
68+
],
69+
entry_point = "pnpm_versions.test.mjs",
70+
)
71+
2972
lessc(
3073
name = "styles",
3174
css = "my.less",

e2e/bzlmod/MODULE.bazel

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ pnpm.pnpm(
5555
name = "pnpm",
5656
pnpm_version_from = "//:package.json",
5757
)
58+
59+
# A second, version-less tag for the same repo that only asks to bundle npm. The
60+
# version is selected by the tag above; include_npm must still be honored even
61+
# though it comes from a different (version-less) tag.
62+
pnpm.pnpm(
63+
name = "pnpm",
64+
include_npm = True,
65+
)
5866
pnpm.pnpm(
5967
name = "other_pnpm",
6068
pnpm_version = "9.15.9",

e2e/bzlmod/other_module/MODULE.bazel

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ npm.npm_translate_lock(
1111
)
1212
use_repo(npm, "npm_other_module")
1313

14-
pnpm = use_extension(
15-
"@aspect_rules_js//npm:extensions.bzl",
16-
"pnpm",
17-
dev_dependency = True,
18-
)
14+
# A non-root module may request a pnpm version for the default "pnpm" repo, but
15+
# the version requested by the e2e test root module takes priority. The e2e test
16+
# root module uses pnpm_version_from, which must win over this registration; this
17+
# is asserted by //:pnpm_versions_test in the e2e test root module.
18+
pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm")
1919
pnpm.pnpm(
2020
name = "pnpm",
2121
pnpm_version = "9.15.9",

e2e/bzlmod/pnpm_versions.test.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { readFileSync } from 'fs'
2+
3+
// The version of the @pnpm repo is resolved from `pnpm_version_from = "//:package.json"`
4+
// on the root module tag, and must match the package.json `packageManager` field.
5+
// It takes priority over both the version requested by the non-root module
6+
// `other_module` and the default version registered by rules_js itself.
7+
// See https://github.com/aspect-build/rules_js/pull/2349#discussion_r3362093839
8+
const packageManager = JSON.parse(
9+
readFileSync('package.json', 'utf8')
10+
).packageManager
11+
const expected = packageManager.split('@')[1].split('+')[0]
12+
const pnpmVersion = readFileSync('pnpm_version.txt', 'utf8').trim()
13+
14+
if (pnpmVersion !== expected) {
15+
throw new Error(
16+
`Incorrect @pnpm version: got ${pnpmVersion}, expected ${expected} from package.json#packageManager`
17+
)
18+
}
19+
20+
// The root module may register additional pnpm repos with their own pinned version.
21+
const otherPnpmVersion = readFileSync('other_pnpm_version.txt', 'utf8').trim()
22+
23+
if (otherPnpmVersion !== '9.15.9') {
24+
throw new Error(
25+
`Incorrect @other_pnpm version: got ${otherPnpmVersion}, expected 9.15.9`
26+
)
27+
}
28+
29+
// @pnpm was configured with include_npm via a separate, version-less tag, so the
30+
// bundled npm must be runnable. `pnpm exec npm --version` (captured above) only
31+
// produces a version string if npm is on PATH; otherwise the include_npm request
32+
// was dropped because the selected version came from a different tag.
33+
const npmVersion = readFileSync('pnpm_npm_version.txt', 'utf8').trim()
34+
35+
if (!/^\d+\.\d+\.\d+/.test(npmVersion)) {
36+
throw new Error(`Expected a bundled npm version, got: ${npmVersion}`)
37+
}

npm/extensions.bzl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,10 @@ pnpm = module_extension(
393393
default = False,
394394
),
395395
"pnpm_version": attr.string(
396-
doc = "pnpm version to use. The string `latest` will be resolved to LATEST_PNPM_VERSION.",
397-
default = DEFAULT_PNPM_VERSION,
396+
doc = """pnpm version to use. The string `latest` will be resolved to LATEST_PNPM_VERSION.
397+
398+
If unset, a default version chosen by rules_js is used, but only if no other
399+
module requests an explicit version for this repository.""",
398400
),
399401
"pnpm_version_from": attr.label(
400402
doc = """Label to a package.json file to read the pnpm version from.

npm/private/pnpm_extension.bzl

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""pnpm extension logic (the extension itself is in npm/extensions.bzl)."""
22

3-
load("@bazel_lib//lib:lists.bzl", "unique")
43
load(":pnpm_repository.bzl", "DEFAULT_PNPM_VERSION", "LATEST_PNPM_VERSION")
54
load(":utils.bzl", "utils")
65
load(":versions.bzl", "PNPM_VERSIONS")
@@ -27,30 +26,47 @@ def resolve_pnpm_repositories(mctx):
2726

2827
# Collect all the module tags and associated versions/integrities/options
2928
integrity = {}
30-
registrations = {}
29+
30+
# Whether npm should be bundled with pnpm, OR'd across all tags for a repo
31+
# (name -> bool). Like patches, this is a per-repo setting, not per-version.
32+
include_npm_by_repo = {}
3133

3234
# Patches and patch_args are per-repo-name (NOT per-version) and only
3335
# accepted from the root module. Multiple tags for the same repo name
3436
# concatenate their patches and the last patch_args wins.
3537
patches_by_repo = {}
3638
patch_args_by_repo = {}
39+
40+
# Versions explicitly requested for each repo: name -> version -> is_root. Tags
41+
# that carry no version (the default, e.g. the always-present registration from
42+
# rules_js itself) are excluded, so they only matter as a fallback. A version
43+
# requested by the root module (is_root = True) takes priority over the rest.
44+
requested = {}
3745
for mod in mctx.modules:
3846
for attr in mod.tags.pnpm:
3947
if attr.name != DEFAULT_PNPM_REPO_NAME and not mod.is_root:
4048
fail("""\
4149
Only the root module may override the default name for the pnpm repository.
4250
This prevents conflicting registrations in the global namespace of external repos.
4351
""")
44-
if not registrations.get(attr.name, False):
45-
registrations[attr.name] = {}
52+
if attr.name not in requested:
53+
requested[attr.name] = {}
54+
include_npm_by_repo[attr.name] = False
55+
include_npm_by_repo[attr.name] = include_npm_by_repo[attr.name] or attr.include_npm
4656
if mod.is_root and (getattr(attr, "patches", None) or getattr(attr, "patch_args", None)):
4757
patches_by_repo.setdefault(attr.name, [])
4858
patches_by_repo[attr.name].extend(getattr(attr, "patches", []) or [])
4959
patch_args_by_repo[attr.name] = list(attr.patch_args)
5060

51-
if attr.pnpm_version_from and attr.pnpm_version and attr.pnpm_version != DEFAULT_PNPM_VERSION:
61+
if attr.pnpm_version_from and attr.pnpm_version:
5262
fail("Cannot specify both pnpm_version = {} and pnpm_version_from = {}".format(attr.pnpm_version, attr.pnpm_version_from))
5363

64+
# A tag that specifies no version (neither pnpm_version nor pnpm_version_from)
65+
# carries the default version, e.g. the registration from rules_js itself.
66+
# Such registrations must not influence version selection when any module
67+
# explicitly requests a version.
68+
is_default_registration = attr.pnpm_version == "" and attr.pnpm_version_from == None
69+
5470
# Handle pnpm_version_from by reading package.json
5571
if attr.pnpm_version_from != None:
5672
# Read package.json and extract packageManager field
@@ -77,17 +93,15 @@ def resolve_pnpm_repositories(mctx):
7793

7894
elif attr.pnpm_version == "latest":
7995
v = LATEST_PNPM_VERSION
96+
elif attr.pnpm_version == "":
97+
v = DEFAULT_PNPM_VERSION
8098
else:
8199
v = attr.pnpm_version
82100

83-
# Avoid inserting the default version from a non-root module
84-
# (likely rules_js itself) if the root module already has a version.
85-
if mod.is_root or len(registrations[attr.name]) == 0:
86-
if v not in registrations[attr.name]:
87-
registrations[attr.name][v] = []
88-
registrations[attr.name][v].append(attr.include_npm)
101+
if not is_default_registration:
102+
requested[attr.name][v] = requested[attr.name].get(v, False) or mod.is_root
89103
if attr.pnpm_version_integrity:
90-
integrity[attr.pnpm_version] = attr.pnpm_version_integrity
104+
integrity[v] = attr.pnpm_version_integrity
91105

92106
# If no integrity was provided or found via package.json load from known versions
93107
if not integrity.get(v, False) and PNPM_VERSIONS.get(v, False):
@@ -96,11 +110,15 @@ def resolve_pnpm_repositories(mctx):
96110
# From the collected registrations, resolve to a single version per repository name
97111
notes = []
98112
repositories = {}
99-
for name, versions_map in registrations.items():
100-
# Disregard repeated version numbers and convert {version:include_npm} to version[]
101-
versions = unique(versions_map.keys())
102-
103-
# Use "Minimal Version Selection" like bzlmod does for resolving module conflicts
113+
for name, version_requests in requested.items():
114+
# Versions requested by the root module take priority, then versions requested
115+
# by any other module, falling back to the default version when no module
116+
# requested a specific version.
117+
root_versions = [v for v in version_requests if version_requests[v]]
118+
versions = root_versions or version_requests.keys() or [DEFAULT_PNPM_VERSION]
119+
120+
# Within the selected tier, pick the highest version like bzlmod does. This
121+
# runs after the tiering above, so a root request wins even if it is lower.
104122
# Note, the 'sorted(list)' function in starlark doesn't allow us to provide a custom comparator
105123
if len(versions) > 1:
106124
selected = versions[0]
@@ -116,7 +134,7 @@ def resolve_pnpm_repositories(mctx):
116134

117135
selected = {
118136
"version": selected,
119-
"include_npm": 0 < len([i for i in versions_map[selected] if i]),
137+
"include_npm": include_npm_by_repo[name],
120138
"integrity": integrity.get(selected, None),
121139
"patches": patches_by_repo.get(name, []),
122140
"patch_args": patch_args_by_repo.get(name, ["-p1"]),

npm/private/test/pnpm_test.bzl

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ load("//npm/private:pnpm_extension.bzl", "DEFAULT_PNPM_REPO_NAME", "resolve_pnpm
66
load("//npm/private:pnpm_repository.bzl", "DEFAULT_PNPM_VERSION", "LATEST_PNPM_VERSION")
77
load("//npm/private:versions.bzl", "PNPM_VERSIONS")
88

9-
def _fake_pnpm_tag(version = None, name = DEFAULT_PNPM_REPO_NAME, integrity = None, pnpm_version_from = None, include_npm = False, patches = [], patch_args = ["-p1"]):
9+
# NB: version defaults to the empty sentinel, mirroring the real tag attribute, so a
10+
# bare _fake_pnpm_tag() is equivalent to the default registration from rules_js itself.
11+
def _fake_pnpm_tag(version = "", name = DEFAULT_PNPM_REPO_NAME, integrity = None, pnpm_version_from = None, include_npm = False, patches = [], patch_args = ["-p1"]):
1012
return struct(
1113
name = name,
1214
pnpm_version = version,
@@ -129,6 +131,21 @@ def _include_npm(ctx):
129131
],
130132
)
131133

134+
def _include_npm_other_version_wins(ctx):
135+
# include_npm is a per-repo setting, not per-version: a version-less tag asking to
136+
# bundle npm must be honored even when another module's explicit version is the one
137+
# selected. Otherwise the npm request would be silently dropped.
138+
return _resolve_test(
139+
ctx,
140+
repositories = {"pnpm": {"version": "9.1.0", "integrity": "sha512-Z/WHmRapKT5c8FnCOFPVcb6vT3U8cH9AyyK+1fsVeMaq07bEEHzLO6CzW+AD62IaFkcayDbIe+tT+dVLtGEnJA==", "include_npm": True, "patches": [], "patch_args": ["-p1"]}},
141+
modules = [
142+
# Root brings the repo into scope and wants npm, but expresses no version.
143+
_fake_mod(True, _fake_pnpm_tag(include_npm = True)),
144+
# A dependency pins the version that ends up selected.
145+
_fake_mod(False, _fake_pnpm_tag(version = "9.1.0")),
146+
],
147+
)
148+
132149
def _custom_name(ctx):
133150
return _resolve_test(
134151
ctx,
@@ -148,6 +165,85 @@ def _custom_name(ctx):
148165
],
149166
)
150167

168+
def _dep_version_with_default_registration(ctx):
169+
# A non-root module explicitly requests a pnpm version while another non-root
170+
# module (rules_js itself, which always registers `pnpm.pnpm(name = "pnpm")`)
171+
# carries the default version.
172+
#
173+
# The rules_js registration comes first in BFS order ("aspect_rules_js" sorts
174+
# before nearly all module names) and must not mask the explicit request,
175+
# even when the explicit request is for a lower version than the default.
176+
# See https://github.com/aspect-build/rules_js/pull/2349#discussion_r3362093839
177+
return _resolve_test(
178+
ctx,
179+
repositories = {"pnpm": {"version": "9.1.0", "integrity": "sha512-Z/WHmRapKT5c8FnCOFPVcb6vT3U8cH9AyyK+1fsVeMaq07bEEHzLO6CzW+AD62IaFkcayDbIe+tT+dVLtGEnJA==", "include_npm": False, "patches": [], "patch_args": ["-p1"]}},
180+
modules = [
181+
_fake_mod(True),
182+
# rules_js itself: a bare pnpm.pnpm(name = "pnpm") tag carrying the default version
183+
_fake_mod(False, _fake_pnpm_tag()),
184+
_fake_mod(False, _fake_pnpm_tag(version = "9.1.0")),
185+
],
186+
)
187+
188+
def _dep_versions_mvs(ctx):
189+
# Multiple non-root modules explicitly request different pnpm versions:
190+
# the highest wins, with a note. The default registration from rules_js
191+
# does not participate in the selection.
192+
return _resolve_test(
193+
ctx,
194+
repositories = {"pnpm": {"version": "9.2.0", "integrity": "sha512-mKgP0RwucJZ0d2IwQQZDKz3cZ9z1S1qMAck/aKLNXgXmghhJUioG+3YoTUGiZg1eM08u47vykYO/LnObHa+ncQ==", "include_npm": False, "patches": [], "patch_args": ["-p1"]}},
195+
notes = ["""NOTE: repo 'pnpm' has multiple versions ["9.1.0", "9.2.0"]; selected 9.2.0"""],
196+
modules = [
197+
_fake_mod(True),
198+
_fake_mod(False, _fake_pnpm_tag()),
199+
_fake_mod(False, _fake_pnpm_tag(version = "9.1.0")),
200+
_fake_mod(False, _fake_pnpm_tag(version = "9.2.0")),
201+
],
202+
)
203+
204+
def _root_default_dep_version(ctx):
205+
# The root module registers a bare tag (no version preference, e.g. just to
206+
# bring the repo into scope). A dep's explicit version wins over the default
207+
# version carried by the root's bare tag.
208+
return _resolve_test(
209+
ctx,
210+
repositories = {"pnpm": {"version": "9.1.0", "integrity": "sha512-Z/WHmRapKT5c8FnCOFPVcb6vT3U8cH9AyyK+1fsVeMaq07bEEHzLO6CzW+AD62IaFkcayDbIe+tT+dVLtGEnJA==", "include_npm": False, "patches": [], "patch_args": ["-p1"]}},
211+
modules = [
212+
_fake_mod(True, _fake_pnpm_tag()),
213+
_fake_mod(False, _fake_pnpm_tag(version = "9.1.0")),
214+
],
215+
)
216+
217+
def _root_explicit_default_beats_dep(ctx):
218+
# Explicitly requesting the default version is distinct from not requesting a
219+
# version at all: it is a real request from the root module and therefore wins
220+
# over a (higher) version requested by a non-root module.
221+
# See https://github.com/aspect-build/rules_js/pull/2883#discussion_r3367024654
222+
return _resolve_test(
223+
ctx,
224+
repositories = {"pnpm": {"version": DEFAULT_PNPM_VERSION, "integrity": PNPM_VERSIONS[DEFAULT_PNPM_VERSION], "include_npm": False, "patches": [], "patch_args": ["-p1"]}},
225+
modules = [
226+
_fake_mod(True, _fake_pnpm_tag(version = DEFAULT_PNPM_VERSION)),
227+
_fake_mod(False, _fake_pnpm_tag(version = "11.0.9")),
228+
],
229+
)
230+
231+
def _root_lower_than_default(ctx):
232+
# The realistic override case: the root module pins the default "pnpm" repo to
233+
# a version *lower* than DEFAULT_PNPM_VERSION, while rules_js itself carries the
234+
# default registration. The root's explicit (lower) pin must win; a naive
235+
# "Minimal Version Selection across all registrations" would wrongly keep the
236+
# higher default and silently ignore the user's pin.
237+
return _resolve_test(
238+
ctx,
239+
repositories = {"pnpm": {"version": "9.1.0", "integrity": "sha512-Z/WHmRapKT5c8FnCOFPVcb6vT3U8cH9AyyK+1fsVeMaq07bEEHzLO6CzW+AD62IaFkcayDbIe+tT+dVLtGEnJA==", "include_npm": False, "patches": [], "patch_args": ["-p1"]}},
240+
modules = [
241+
_fake_mod(True, _fake_pnpm_tag(version = "9.1.0")),
242+
# rules_js itself: a bare pnpm.pnpm(name = "pnpm") tag carrying DEFAULT_PNPM_VERSION
243+
_fake_mod(False, _fake_pnpm_tag()),
244+
],
245+
)
246+
151247
def _integrity_conflict(ctx):
152248
# What happens if two modules define the same version with conflicting integrity parameters.
153249
# Currently we print nothing to indicate this, we trust whichever integrity wins.
@@ -228,9 +324,15 @@ def _os_cpu_constraints(ctx):
228324

229325
basic_test = unittest.make(_basic)
230326
override_test = unittest.make(_override)
327+
dep_version_with_default_registration_test = unittest.make(_dep_version_with_default_registration)
328+
dep_versions_mvs_test = unittest.make(_dep_versions_mvs)
329+
root_default_dep_version_test = unittest.make(_root_default_dep_version)
330+
root_explicit_default_beats_dep_test = unittest.make(_root_explicit_default_beats_dep)
331+
root_lower_than_default_test = unittest.make(_root_lower_than_default)
231332
latest_test = unittest.make(_latest)
232333
custom_name_test = unittest.make(_custom_name)
233334
include_npm_test = unittest.make(_include_npm)
335+
include_npm_other_version_wins_test = unittest.make(_include_npm_other_version_wins)
234336
integrity_conflict_test = unittest.make(_integrity_conflict)
235337
from_package_json_simple_test = unittest.make(_from_package_json_simple)
236338
from_package_json_with_hash_test = unittest.make(_from_package_json_with_hash)
@@ -245,9 +347,15 @@ def pnpm_tests(name):
245347
name,
246348
basic_test,
247349
override_test,
350+
dep_version_with_default_registration_test,
351+
dep_versions_mvs_test,
352+
root_default_dep_version_test,
353+
root_explicit_default_beats_dep_test,
354+
root_lower_than_default_test,
248355
latest_test,
249356
custom_name_test,
250357
include_npm_test,
358+
include_npm_other_version_wins_test,
251359
integrity_conflict_test,
252360
from_package_json_simple_test,
253361
from_package_json_with_hash_test,

0 commit comments

Comments
 (0)