Skip to content

Commit 4ca9247

Browse files
authored
fix: preserve node runtime version prefix (#12444)
1 parent 179ebc4 commit 4ca9247

5 files changed

Lines changed: 117 additions & 4 deletions

File tree

.changeset/runtime-node-prefix.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/engine.runtime.node-resolver": patch
3+
"pnpm": patch
4+
---
5+
6+
Preserve the existing Node.js runtime version prefix when resolving `node@runtime:<range>` to a concrete version.

engine/runtime/node-resolver/src/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export async function resolveNodeRuntime (
6464
throw new PnpmError('NODEJS_VERSION_NOT_FOUND', `Could not find a Node.js version that satisfies ${versionSpec}`)
6565
}
6666
const variants = await readNodeAssets(ctx.fetchFromRegistry, nodeMirrorBaseUrl, version, releaseChannel)
67-
const range = version === versionSpec ? version : `^${version}`
67+
const range = createNodeRuntimeVersionSpec(versionSpec, version, wantedDependency)
6868
return {
6969
id: `node@runtime:${version}` as PkgResolutionId,
7070
normalizedBareSpecifier: `runtime:${range}`,
@@ -96,6 +96,23 @@ export async function resolveLatestNodeRuntime (
9696
return { latestManifest: { name: 'node', version } }
9797
}
9898

99+
function createNodeRuntimeVersionSpec (
100+
versionSpec: string,
101+
resolvedVersion: string,
102+
wantedDependency: WantedDependency
103+
): string {
104+
if (resolvedVersion === versionSpec || semver.parse(resolvedVersion)?.prerelease.length) {
105+
return resolvedVersion
106+
}
107+
const source = wantedDependency.prevSpecifier?.startsWith('runtime:')
108+
? wantedDependency.prevSpecifier.substring('runtime:'.length)
109+
: versionSpec
110+
const spec = source.includes('/') ? source.split('/', 2)[1] : source
111+
if (spec.startsWith('^')) return `^${resolvedVersion}`
112+
if (spec.startsWith('~')) return `~${resolvedVersion}`
113+
return resolvedVersion
114+
}
115+
99116
async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string, releaseChannel: string): Promise<PlatformAssetResolution[]> {
100117
// The mirror is repository-configurable, so the SHASUMS file's hashes are only
101118
// trustworthy once its OpenPGP signature is verified against the Node.js
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from '@jest/globals'
2+
import type { FetchFromRegistry } from '@pnpm/fetching.types'
3+
4+
import { resolveNodeRuntime } from '../lib/index.js'
5+
6+
const MIRROR = 'https://node.example/download/rc/'
7+
8+
const fetch: FetchFromRegistry = async (url) => {
9+
switch (url) {
10+
case `${MIRROR}index.json`:
11+
return new Response(JSON.stringify([
12+
{ version: 'v22.11.0', lts: false },
13+
{ version: 'v22.10.0', lts: false },
14+
]))
15+
case `${MIRROR}v22.11.0/SHASUMS256.txt`:
16+
return new Response('ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e node-v22.11.0-linux-x64.tar.gz\n')
17+
default:
18+
throw new Error(`Unexpected URL: ${url}`)
19+
}
20+
}
21+
22+
test.each([
23+
['runtime:rc/22', undefined, 'runtime:22.11.0'],
24+
['runtime:rc/^22', undefined, 'runtime:^22.11.0'],
25+
['runtime:rc/22', 'runtime:~22.0.0', 'runtime:~22.11.0'],
26+
['runtime:rc/^22', 'runtime:22.0.0', 'runtime:22.11.0'],
27+
])('resolveNodeRuntime() preserves runtime version prefix (%s, previous %s)', async (bareSpecifier, prevSpecifier, expected) => {
28+
const resolution = await resolveNodeRuntime({
29+
fetchFromRegistry: fetch,
30+
nodeDownloadMirrors: {
31+
rc: MIRROR,
32+
},
33+
}, {
34+
alias: 'node',
35+
bareSpecifier,
36+
prevSpecifier,
37+
})
38+
39+
expect(resolution?.normalizedBareSpecifier).toBe(expected)
40+
})

pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::{
1313

1414
use derive_more::{Display, Error};
1515
use miette::Diagnostic;
16+
use node_semver::Version;
1617
use pacquet_crypto_shasums_file::{
1718
FetchShasumsFileError, FetchVerifiedNodeShasumsError, fetch_shasums_file,
1819
fetch_verified_node_shasums_file,
@@ -153,7 +154,11 @@ impl NodeResolver {
153154
as ResolveError
154155
})?;
155156
let variants = self.read_node_assets(&mirror, &version, &parsed.release_channel).await?;
156-
let range = if version == version_spec { version.clone() } else { format!("^{version}") };
157+
let range = normalize_node_runtime_version_specifier(
158+
version_spec,
159+
&version,
160+
wanted_dependency.prev_specifier.as_deref(),
161+
);
157162
let resolution = LockfileResolution::Variations(VariationsResolution { variants });
158163
let manifest = serde_json::json!({
159164
"name": "node",
@@ -260,6 +265,30 @@ fn bare_runtime_spec<'a>(wanted: &'a WantedDependency, expected_alias: &str) ->
260265
wanted.bare_specifier.as_deref().and_then(|spec| spec.strip_prefix(BARE_SPEC_PREFIX))
261266
}
262267

268+
fn normalize_node_runtime_version_specifier(
269+
version_spec: &str,
270+
resolved_version: &str,
271+
prev_specifier: Option<&str>,
272+
) -> String {
273+
if resolved_version == version_spec
274+
|| matches!(Version::parse(resolved_version), Ok(version) if !version.pre_release.is_empty())
275+
{
276+
return resolved_version.to_string();
277+
}
278+
let source = prev_specifier
279+
.and_then(|specifier| specifier.strip_prefix(BARE_SPEC_PREFIX))
280+
.unwrap_or(version_spec);
281+
let spec = source.split_once('/').map_or(source, |(_, spec)| spec);
282+
let prefix = if spec.starts_with('^') {
283+
"^"
284+
} else if spec.starts_with('~') {
285+
"~"
286+
} else {
287+
""
288+
};
289+
format!("{prefix}{resolved_version}")
290+
}
291+
263292
/// Read the asset list for one mirror version and decode each row
264293
/// into a [`PlatformAssetResolution`].
265294
///

pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use pacquet_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency
55
use pretty_assertions::assert_eq;
66

77
use super::{
8-
NodeResolver, NodeResolverError, bin_spec_for_platform, parse_node_file_name,
9-
read_node_assets_from_mirror,
8+
NodeResolver, NodeResolverError, bin_spec_for_platform,
9+
normalize_node_runtime_version_specifier, parse_node_file_name, read_node_assets_from_mirror,
1010
};
1111

1212
fn resolver() -> NodeResolver {
@@ -105,6 +105,27 @@ fn bin_spec_is_a_named_map() {
105105
);
106106
}
107107

108+
#[test]
109+
fn normalized_runtime_spec_preserves_version_prefix() {
110+
let cases = [
111+
("22", None, "22.11.0"),
112+
("^22", None, "^22.11.0"),
113+
("22", Some("runtime:~22.0.0"), "~22.11.0"),
114+
("^22", Some("runtime:22.0.0"), "22.11.0"),
115+
("rc/^22", None, "^22.11.0"),
116+
("22", Some("runtime:^22.0.0-rc.0"), "^22.11.0"),
117+
];
118+
for (version_spec, prev_specifier, expected) in cases {
119+
assert_eq!(
120+
normalize_node_runtime_version_specifier(version_spec, "22.11.0", prev_specifier),
121+
expected,
122+
"version_spec={version_spec:?}, prev_specifier={prev_specifier:?}",
123+
);
124+
}
125+
126+
assert_eq!(normalize_node_runtime_version_specifier("^22", "22.0.0-rc.0", None), "22.0.0-rc.0");
127+
}
128+
108129
#[tokio::test]
109130
async fn release_asset_reader_requires_signature_when_requested() {
110131
let mut server = mockito::Server::new_async().await;

0 commit comments

Comments
 (0)