fix: preserve node runtime version prefix (#12444)

This commit is contained in:
Zoltan Kochan
2026-06-16 12:43:37 +02:00
committed by GitHub
parent 179ebc40aa
commit 4ca9247a9b
5 changed files with 117 additions and 4 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/engine.runtime.node-resolver": patch
"pnpm": patch
---
Preserve the existing Node.js runtime version prefix when resolving `node@runtime:<range>` to a concrete version.

View File

@@ -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<PlatformAssetResolution[]> {
// The mirror is repository-configurable, so the SHASUMS file's hashes are only
// trustworthy once its OpenPGP signature is verified against the Node.js

View File

@@ -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)
})

View File

@@ -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`].
///

View File

@@ -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;