diff --git a/.changeset/runtime-node-prefix.md b/.changeset/runtime-node-prefix.md new file mode 100644 index 0000000000..3c205313ad --- /dev/null +++ b/.changeset/runtime-node-prefix.md @@ -0,0 +1,6 @@ +--- +"@pnpm/engine.runtime.node-resolver": patch +"pnpm": patch +--- + +Preserve the existing Node.js runtime version prefix when resolving `node@runtime:` to a concrete version. diff --git a/engine/runtime/node-resolver/src/index.ts b/engine/runtime/node-resolver/src/index.ts index f344809e1c..e5156cd4f7 100644 --- a/engine/runtime/node-resolver/src/index.ts +++ b/engine/runtime/node-resolver/src/index.ts @@ -64,7 +64,7 @@ export async function resolveNodeRuntime ( throw new PnpmError('NODEJS_VERSION_NOT_FOUND', `Could not find a Node.js version that satisfies ${versionSpec}`) } const variants = await readNodeAssets(ctx.fetchFromRegistry, nodeMirrorBaseUrl, version, releaseChannel) - const range = version === versionSpec ? version : `^${version}` + const range = createNodeRuntimeVersionSpec(versionSpec, version, wantedDependency) return { id: `node@runtime:${version}` as PkgResolutionId, normalizedBareSpecifier: `runtime:${range}`, @@ -96,6 +96,23 @@ export async function resolveLatestNodeRuntime ( return { latestManifest: { name: 'node', version } } } +function createNodeRuntimeVersionSpec ( + versionSpec: string, + resolvedVersion: string, + wantedDependency: WantedDependency +): string { + if (resolvedVersion === versionSpec || semver.parse(resolvedVersion)?.prerelease.length) { + return resolvedVersion + } + const source = wantedDependency.prevSpecifier?.startsWith('runtime:') + ? wantedDependency.prevSpecifier.substring('runtime:'.length) + : versionSpec + const spec = source.includes('/') ? source.split('/', 2)[1] : source + if (spec.startsWith('^')) return `^${resolvedVersion}` + if (spec.startsWith('~')) return `~${resolvedVersion}` + return resolvedVersion +} + async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string, releaseChannel: string): Promise { // The mirror is repository-configurable, so the SHASUMS file's hashes are only // trustworthy once its OpenPGP signature is verified against the Node.js diff --git a/engine/runtime/node-resolver/test/resolveNodeRuntime.test.ts b/engine/runtime/node-resolver/test/resolveNodeRuntime.test.ts new file mode 100644 index 0000000000..1b17f7f564 --- /dev/null +++ b/engine/runtime/node-resolver/test/resolveNodeRuntime.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@jest/globals' +import type { FetchFromRegistry } from '@pnpm/fetching.types' + +import { resolveNodeRuntime } from '../lib/index.js' + +const MIRROR = 'https://node.example/download/rc/' + +const fetch: FetchFromRegistry = async (url) => { + switch (url) { + case `${MIRROR}index.json`: + return new Response(JSON.stringify([ + { version: 'v22.11.0', lts: false }, + { version: 'v22.10.0', lts: false }, + ])) + case `${MIRROR}v22.11.0/SHASUMS256.txt`: + return new Response('ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e node-v22.11.0-linux-x64.tar.gz\n') + default: + throw new Error(`Unexpected URL: ${url}`) + } +} + +test.each([ + ['runtime:rc/22', undefined, 'runtime:22.11.0'], + ['runtime:rc/^22', undefined, 'runtime:^22.11.0'], + ['runtime:rc/22', 'runtime:~22.0.0', 'runtime:~22.11.0'], + ['runtime:rc/^22', 'runtime:22.0.0', 'runtime:22.11.0'], +])('resolveNodeRuntime() preserves runtime version prefix (%s, previous %s)', async (bareSpecifier, prevSpecifier, expected) => { + const resolution = await resolveNodeRuntime({ + fetchFromRegistry: fetch, + nodeDownloadMirrors: { + rc: MIRROR, + }, + }, { + alias: 'node', + bareSpecifier, + prevSpecifier, + }) + + expect(resolution?.normalizedBareSpecifier).toBe(expected) +}) diff --git a/pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs index a66080355c..04e24220e6 100644 --- a/pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs +++ b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver.rs @@ -13,6 +13,7 @@ use std::{ use derive_more::{Display, Error}; use miette::Diagnostic; +use node_semver::Version; use pacquet_crypto_shasums_file::{ FetchShasumsFileError, FetchVerifiedNodeShasumsError, fetch_shasums_file, fetch_verified_node_shasums_file, @@ -153,7 +154,11 @@ impl NodeResolver { as ResolveError })?; let variants = self.read_node_assets(&mirror, &version, &parsed.release_channel).await?; - let range = if version == version_spec { version.clone() } else { format!("^{version}") }; + let range = normalize_node_runtime_version_specifier( + version_spec, + &version, + wanted_dependency.prev_specifier.as_deref(), + ); let resolution = LockfileResolution::Variations(VariationsResolution { variants }); let manifest = serde_json::json!({ "name": "node", @@ -260,6 +265,30 @@ fn bare_runtime_spec<'a>(wanted: &'a WantedDependency, expected_alias: &str) -> wanted.bare_specifier.as_deref().and_then(|spec| spec.strip_prefix(BARE_SPEC_PREFIX)) } +fn normalize_node_runtime_version_specifier( + version_spec: &str, + resolved_version: &str, + prev_specifier: Option<&str>, +) -> String { + if resolved_version == version_spec + || matches!(Version::parse(resolved_version), Ok(version) if !version.pre_release.is_empty()) + { + return resolved_version.to_string(); + } + let source = prev_specifier + .and_then(|specifier| specifier.strip_prefix(BARE_SPEC_PREFIX)) + .unwrap_or(version_spec); + let spec = source.split_once('/').map_or(source, |(_, spec)| spec); + let prefix = if spec.starts_with('^') { + "^" + } else if spec.starts_with('~') { + "~" + } else { + "" + }; + format!("{prefix}{resolved_version}") +} + /// Read the asset list for one mirror version and decode each row /// into a [`PlatformAssetResolution`]. /// diff --git a/pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs index 5fbe9cffad..c65623975c 100644 --- a/pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs +++ b/pacquet/crates/engine-runtime-node-resolver/src/node_resolver/tests.rs @@ -5,8 +5,8 @@ use pacquet_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency use pretty_assertions::assert_eq; use super::{ - NodeResolver, NodeResolverError, bin_spec_for_platform, parse_node_file_name, - read_node_assets_from_mirror, + NodeResolver, NodeResolverError, bin_spec_for_platform, + normalize_node_runtime_version_specifier, parse_node_file_name, read_node_assets_from_mirror, }; fn resolver() -> NodeResolver { @@ -105,6 +105,27 @@ fn bin_spec_is_a_named_map() { ); } +#[test] +fn normalized_runtime_spec_preserves_version_prefix() { + let cases = [ + ("22", None, "22.11.0"), + ("^22", None, "^22.11.0"), + ("22", Some("runtime:~22.0.0"), "~22.11.0"), + ("^22", Some("runtime:22.0.0"), "22.11.0"), + ("rc/^22", None, "^22.11.0"), + ("22", Some("runtime:^22.0.0-rc.0"), "^22.11.0"), + ]; + for (version_spec, prev_specifier, expected) in cases { + assert_eq!( + normalize_node_runtime_version_specifier(version_spec, "22.11.0", prev_specifier), + expected, + "version_spec={version_spec:?}, prev_specifier={prev_specifier:?}", + ); + } + + assert_eq!(normalize_node_runtime_version_specifier("^22", "22.0.0-rc.0", None), "22.0.0-rc.0"); +} + #[tokio::test] async fn release_asset_reader_requires_signature_when_requested() { let mut server = mockito::Server::new_async().await;