diff --git a/.changeset/pack-app-builder-version-pin.md b/.changeset/pack-app-builder-version-pin.md new file mode 100644 index 0000000000..7297fbbe19 --- /dev/null +++ b/.changeset/pack-app-builder-version-pin.md @@ -0,0 +1,10 @@ +--- +"@pnpm/releasing.commands": patch +"pnpm": patch +--- + +Fix the `@pnpm/exe` SEA executable crashing at startup on Node.js v25.7+. Two separate regressions in `@pnpm/exe@11.0.0-rc.4` are addressed: + +1. `pnpm pack-app` now pins the Node.js used to write the SEA blob to the exact embedded runtime version. The SEA blob format changed in Node.js v25.7 (ESM entry-point support added a `ModuleFormat` header byte), so a blob produced by a pre-25.7 builder cannot be deserialized by a 25.7+ runtime and vice versa. In rc.4 the CI host Node.js (v25.6.1) built blobs embedded in a v25.9.0 runtime, tripping `SeaDeserializer::Read() ... format_value <= kModule` on every invocation. `pack-app` now downloads a host-arch builder Node.js of the target version when the running Node.js doesn't already match. + +2. The pnpm CJS SEA entry shim now loads `dist/pnpm.mjs` through `Module.createRequire(process.execPath)` instead of `await import(pathToFileURL(...).href)`. In Node.js v25.7+, the ambient `require` and `import()` inside a CJS SEA entry are replaced with embedder hooks that only resolve built-in module names, causing external `file://` loads to fail with `ERR_UNKNOWN_BUILTIN_MODULE`. An explicit `createRequire()` bypasses those hooks. diff --git a/cspell.json b/cspell.json index c5ddd9de36..ff0f86dc8d 100644 --- a/cspell.json +++ b/cspell.json @@ -74,6 +74,7 @@ "eisdir", "elifecycle", "elit", + "embedder", "emfile", "enametoolong", "endregion", diff --git a/pnpm/artifacts/darwin-arm64/package.json b/pnpm/artifacts/darwin-arm64/package.json index 5fe0557391..c64851f2a7 100644 --- a/pnpm/artifacts/darwin-arm64/package.json +++ b/pnpm/artifacts/darwin-arm64/package.json @@ -17,7 +17,7 @@ "pnpm" ], "scripts": { - "prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs darwin arm64" }, "devDependencies": { "@pnpm/macos-arm64": "workspace:*" diff --git a/pnpm/artifacts/darwin-x64/package.json b/pnpm/artifacts/darwin-x64/package.json index 02144cc6e2..f32d593474 100644 --- a/pnpm/artifacts/darwin-x64/package.json +++ b/pnpm/artifacts/darwin-x64/package.json @@ -17,7 +17,7 @@ "pnpm" ], "scripts": { - "prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs darwin x64" }, "devDependencies": { "@pnpm/macos-x64": "workspace:*" diff --git a/pnpm/artifacts/exe/test/setup.test.ts b/pnpm/artifacts/exe/test/setup.test.ts index 1240da78b3..483e0ade45 100644 --- a/pnpm/artifacts/exe/test/setup.test.ts +++ b/pnpm/artifacts/exe/test/setup.test.ts @@ -17,6 +17,11 @@ const platformBin = path.join( isWindows ? 'pnpm.exe' : 'pnpm' ) const hasPlatformBinary = fs.existsSync(platformBin) +// dist/ is staged by the build-artifacts flow (not by `pn compile`), so +// ordinary test runs don't have it. The hardlink test is fine without it +// (existence + inode only), but the -v test actually executes the SEA, which +// loads dist/pnpm.mjs from next to the binary and would fail here. +const hasStagedBundle = fs.existsSync(path.join(exeDir, 'dist', 'pnpm.mjs')) describe('exePlatformPkgName', () => { test('uses linuxstatic- prefix for linux + musl libc family', () => { @@ -68,4 +73,18 @@ test('prepare writes correct content for all bin files', () => { const pnpmBin = path.join(exeDir, isWindows ? 'pnpm.exe' : 'pnpm') expect(fs.statSync(pnpmBin).ino).toBe(fs.statSync(platformBin).ino) +}); + +// Actually execute the hardlinked pnpm binary. Existence and inode-match are +// not enough — a SEA blob built by a Node.js version that differs from the +// embedded runtime deserializes on startup with a native assertion and an +// abort signal, not a clean error exit (see rc.4 regression). Running `-v` +// verifies the SEA payload is actually readable by the embedded Node. +(hasPlatformBinary && hasStagedBundle ? test : test.skip)('pnpm -v runs and prints a semver', () => { + execFileSync(process.execPath, [path.join(exeDir, 'prepare.js')], { cwd: exeDir }) + execFileSync(process.execPath, [path.join(exeDir, 'setup.js')], { cwd: exeDir }) + + const pnpmBin = path.join(exeDir, isWindows ? 'pnpm.exe' : 'pnpm') + const stdout = execFileSync(pnpmBin, ['-v'], { encoding: 'utf8', timeout: 30_000 }).trim() + expect(stdout).toMatch(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/) }) diff --git a/pnpm/artifacts/linux-arm64-musl/package.json b/pnpm/artifacts/linux-arm64-musl/package.json index 2c9f1b09b4..26ccc42b60 100644 --- a/pnpm/artifacts/linux-arm64-musl/package.json +++ b/pnpm/artifacts/linux-arm64-musl/package.json @@ -17,7 +17,7 @@ "pnpm" ], "scripts": { - "prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs linux arm64 musl" }, "devDependencies": { "@pnpm/linuxstatic-arm64": "workspace:*" diff --git a/pnpm/artifacts/linux-arm64/package.json b/pnpm/artifacts/linux-arm64/package.json index edabeaf97b..12add1c5b6 100644 --- a/pnpm/artifacts/linux-arm64/package.json +++ b/pnpm/artifacts/linux-arm64/package.json @@ -17,7 +17,7 @@ "pnpm" ], "scripts": { - "prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs linux arm64 glibc" }, "devDependencies": { "@pnpm/linux-arm64": "workspace:*" diff --git a/pnpm/artifacts/linux-x64-musl/package.json b/pnpm/artifacts/linux-x64-musl/package.json index 36aae6cba3..318e05a630 100644 --- a/pnpm/artifacts/linux-x64-musl/package.json +++ b/pnpm/artifacts/linux-x64-musl/package.json @@ -17,7 +17,7 @@ "pnpm" ], "scripts": { - "prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs linux x64 musl" }, "devDependencies": { "@pnpm/linuxstatic-x64": "workspace:*" diff --git a/pnpm/artifacts/linux-x64/package.json b/pnpm/artifacts/linux-x64/package.json index bec59b6da2..07047504b5 100644 --- a/pnpm/artifacts/linux-x64/package.json +++ b/pnpm/artifacts/linux-x64/package.json @@ -17,7 +17,7 @@ "pnpm" ], "scripts": { - "prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs linux x64 glibc" }, "devDependencies": { "@pnpm/linux-x64": "workspace:*" diff --git a/pnpm/artifacts/verify-binary.mjs b/pnpm/artifacts/verify-binary.mjs new file mode 100644 index 0000000000..d9a2b476c4 --- /dev/null +++ b/pnpm/artifacts/verify-binary.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// Prepublish gate for the @pnpm/ artifact packages. Runs from the +// package directory (cwd contains the built pnpm binary). Verifies: +// 1. The binary exists with the expected filename for the target. +// 2. If the host can execute the target, `pnpm -v` returns a semver. +// +// Existence alone is not sufficient — @pnpm/exe@11.0.0-rc.4 shipped a binary +// that was present but crashed with a native SEA deserialization assertion on +// any invocation. Executing -v would have caught it on the Linux CI host. +// +// Each platform package ships only the SEA binary (no dist/ or node_modules), +// but the SEA's CJS entry (pnpm.cjs) loads dist/pnpm.mjs from +// dirname(process.execPath). To run the binary in place we symlink +// ./dist -> ../exe/dist (the sibling @pnpm/exe package's staged bundle) for +// the duration of the test, then remove the symlink on exit. The platform +// package's "files" whitelist is "pnpm" only, so a stale symlink would never +// reach the published tarball, but we clean up anyway to leave the tree +// untouched for subsequent tools. +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +// Resolves via the pnpm CLI's own node_modules (which always contains +// symlink-dir — the CLI depends on it directly). symlink-dir handles Windows +// junctions internally, so the verifier doesn't need its own elevation / +// link-type branching. +import { symlinkDirSync } from 'symlink-dir' + +const [targetOs, targetArch, targetLibc] = process.argv.slice(2) +if (!targetOs || !targetArch) { + console.error('Usage: verify-binary.mjs [libc]') + process.exit(2) +} + +const binName = targetOs === 'win32' ? 'pnpm.exe' : 'pnpm' +if (!fs.existsSync(binName)) { + console.error(`Error: ${binName} is missing in ${process.cwd()}`) + process.exit(1) +} + +// Node populates header.glibcVersionRuntime only on glibc hosts, so its +// presence is a reliable glibc/musl discriminator without shelling out. +function detectHostLibc () { + if (process.platform !== 'linux') return null + const header = process.report.getReport().header + return header.glibcVersionRuntime ? 'glibc' : 'musl' +} +const hostLibc = detectHostLibc() + +// Cross-platform or cross-libc targets can't be executed from the publish +// host. Existence is the best we can verify — skip the -v check instead of +// failing, so a musl artifact published from a glibc CI still goes through. +const osMatches = process.platform === targetOs +const archMatches = process.arch === targetArch +const libcMatches = targetOs !== 'linux' || !targetLibc || targetLibc === hostLibc + +if (!osMatches || !archMatches || !libcMatches) { + const targetLabel = [targetOs, targetArch, targetLibc].filter(Boolean).join('/') + const hostLabel = [process.platform, process.arch, hostLibc].filter(Boolean).join('/') + console.log(`Skipping ${binName} -v: host ${hostLabel} cannot execute target ${targetLabel}`) + process.exit(0) +} + +const distLinkPath = path.resolve('dist') +const distLinkTarget = path.join('..', 'exe', 'dist') +let distLinkCreated = false +// Remove a prior symlink from an aborted run so cleanup ownership is always +// well-defined. A real dist/ directory (unlikely in a platform package, but +// possible during development) is preserved — we treat it as external and +// skip cleanup. +try { + if (fs.lstatSync(distLinkPath).isSymbolicLink()) fs.unlinkSync(distLinkPath) +} catch (err) { + if (err.code !== 'ENOENT') throw err +} +const distPreexists = fs.existsSync(distLinkPath) + +process.on('exit', () => { + if (!distLinkCreated) return + try { fs.unlinkSync(distLinkPath) } catch { /* nothing to clean up */ } +}) + +// Relocation check: before staging dist/, confirm the binary reads its bundle +// path from process.execPath at runtime and not from a build-time constant. +// A pnpm.cjs shim that accidentally captured __filename or a cwd-relative +// path during packaging would keep working on the build machine but break on +// every end-user machine. Asserting the error references the *runtime* cwd +// catches that regression here instead of after publish. +// +// Skipped when a real dist/ is already present (developer layout); in that +// case we can't distinguish a correctly-resolved dist from a hardcoded one. +if (!distPreexists) { + const expectedRuntimeDist = path.join(fs.realpathSync(process.cwd()), 'dist', 'pnpm.mjs') + let sansDistStdout + try { + sansDistStdout = execFileSync(`./${binName}`, ['-v'], { + encoding: 'utf8', + timeout: 30_000, + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (err) { + const stderr = String(err?.stderr ?? '') + // Expected: binary tried to require the runtime dist path and failed + // because it isn't there. Anything else (a spawn error, crash signal, + // timeout) is NOT evidence of a non-relocatable pnpm.cjs — it's an + // unrelated failure that would hide the regression we actually care + // about. Surface it with the raw diagnostic so the operator can tell + // which one they're looking at. + if (!stderr.includes(expectedRuntimeDist)) { + const status = err?.status ?? 'none' + const signal = err?.signal ?? 'none' + const code = err?.code ?? 'none' + console.error(`Error: ${binName} -v without dist/ did not fail with a missing-runtime-dist error. Either pnpm.cjs regressed to a non-relocatable form, or the binary failed for an unrelated reason. status=${status} signal=${signal} code=${code}\nstderr:\n${stderr}`) + process.exit(1) + } + } + if (sansDistStdout !== undefined) { + console.error(`Error: ${binName} -v unexpectedly succeeded without dist/ alongside the binary. Output: ${JSON.stringify(sansDistStdout.trim())}. pnpm.cjs is loading a bundle from somewhere other than dirname(process.execPath); the published binary would ignore the dist shipped in @pnpm/exe.`) + process.exit(1) + } +} + +// Only stage the symlink when nothing's there already — symlink-dir will +// atomically rename away any existing dir/file, which would silently drop a +// developer's staged dist/ directory. +if (!distPreexists) { + try { + symlinkDirSync(distLinkTarget, distLinkPath) + distLinkCreated = true + } catch (err) { + console.error(`Error: could not stage dist/ symlink: ${String(err)}`) + process.exit(1) + } +} + +let stdout +try { + stdout = execFileSync(`./${binName}`, ['-v'], { encoding: 'utf8', timeout: 30_000 }).trim() +} catch (err) { + console.error(`Error: ${binName} -v failed: ${String(err)}`) + process.exit(1) +} + +// Accept SemVer 2 with optional prerelease and build-metadata suffixes so a +// future `11.0.0-rc.4+sha.` release doesn't fail this gate spuriously. +if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(stdout)) { + console.error(`Error: ${binName} -v produced unexpected output: ${JSON.stringify(stdout)}`) + process.exit(1) +} + +console.log(`${binName} -v OK (${stdout})`) diff --git a/pnpm/artifacts/win32-arm64/package.json b/pnpm/artifacts/win32-arm64/package.json index 548bf05f7c..a1cb7b36d8 100644 --- a/pnpm/artifacts/win32-arm64/package.json +++ b/pnpm/artifacts/win32-arm64/package.json @@ -17,7 +17,7 @@ "pnpm.exe" ], "scripts": { - "prepublishOnly": "test -f pnpm.exe || (echo 'Error: pnpm.exe is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs win32 arm64" }, "devDependencies": { "@pnpm/win-arm64": "workspace:*" diff --git a/pnpm/artifacts/win32-x64/package.json b/pnpm/artifacts/win32-x64/package.json index 4f4e047981..8e0e3db2a3 100644 --- a/pnpm/artifacts/win32-x64/package.json +++ b/pnpm/artifacts/win32-x64/package.json @@ -17,7 +17,7 @@ "pnpm.exe" ], "scripts": { - "prepublishOnly": "test -f pnpm.exe || (echo 'Error: pnpm.exe is missing' && exit 1)" + "prepublishOnly": "node ../verify-binary.mjs win32 x64" }, "devDependencies": { "@pnpm/win-x64": "workspace:*" diff --git a/pnpm/pnpm.cjs b/pnpm/pnpm.cjs index 3d53a1aa8f..6c16b40358 100644 --- a/pnpm/pnpm.cjs +++ b/pnpm/pnpm.cjs @@ -3,10 +3,17 @@ // In a SEA binary, relative import specifiers resolve against the *build-time* // path of the embedded script, not the runtime location of the executable. // We must resolve against process.execPath so the import works on any machine. +// +// Goes through Module.createRequire() rather than the ambient require() or +// dynamic import(). In Node.js >=25.7, the ambient require() and import() +// inside a CJS SEA entry are replaced with embedder hooks that only know how +// to resolve built-in module names, so any attempt to load an external file +// fails with ERR_UNKNOWN_BUILTIN_MODULE. A createRequire() bound to the +// running binary returns a normal module loader that bypasses those hooks, +// and the pnpm bundle has no top-level await so synchronous require() of it +// (Node.js 22+ feature) loads cleanly. const { join, dirname } = require('path') -const { pathToFileURL } = require('url') +const { createRequire } = require('module') -;(async () => { - const distPath = join(dirname(process.execPath), 'dist', 'pnpm.mjs') - await import(pathToFileURL(distPath).href) -})() +const distPath = join(dirname(process.execPath), 'dist', 'pnpm.mjs') +createRequire(process.execPath)(distPath) diff --git a/releasing/commands/src/pack-app/packApp.ts b/releasing/commands/src/pack-app/packApp.ts index 51876667eb..2255acec5a 100644 --- a/releasing/commands/src/pack-app/packApp.ts +++ b/releasing/commands/src/pack-app/packApp.ts @@ -20,12 +20,6 @@ import { renderHelp } from 'render-help' /** Minimum Node.js version that supports `node --build-sea`. */ const MIN_BUILDER_VERSION = { major: 25, minor: 5 } as const -// Range to download when the running Node is too old. Constrained to the -// current major so we don't silently jump majors across releases, and pinned -// above MIN_BUILDER_VERSION.minor so older point releases (e.g. 25.0.x) that -// don't support `--build-sea` aren't picked. -const DEFAULT_BUILDER_SPEC = `>=${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0 <${MIN_BUILDER_VERSION.major + 1}.0.0` - // Target OS names match `process.platform`. That keeps the CLI surface // consistent with pnpm's own `--os` flag (which also takes platform constants) // and with `supportedArchitectures.os` in pnpm-workspace.yaml. @@ -61,16 +55,17 @@ export function help (): string { 'Pack a CommonJS entry file into a standalone executable for one or more target platforms.\n\n' + 'The executable embeds a Node.js binary via the Node.js Single Executable Applications API.\n' + `Requires Node.js v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}+ to perform ` + - 'the injection. The running Node.js is used when it is new enough; otherwise, the ' + - `latest Node.js v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}+ in the ` + - `v${MIN_BUILDER_VERSION.major}.x line is downloaded automatically.\n\n` + + 'the injection. SEA blobs are not compatible across Node.js minor releases, so the ' + + 'builder Node.js must match the embedded runtime version exactly. The running Node.js ' + + 'is used when it already matches; otherwise a host-arch Node.js of the embedded runtime ' + + 'version is downloaded automatically.\n\n' + 'Defaults for --entry, --target, --runtime, --output-dir, and --output-name can be ' + 'set in the package.json under "pnpm.app". CLI flags override the config; --target entirely ' + 'replaces the configured list so you can narrow it at invocation time.', url: docsUrl('pack-app'), usages: [ 'pnpm pack-app --entry dist/index.cjs --target linux-x64 --target win32-x64', - 'pnpm pack-app --entry dist/index.cjs --target linux-x64-musl --runtime node@22', + `pnpm pack-app --entry dist/index.cjs --target linux-x64-musl --runtime node@${MIN_BUILDER_VERSION.major}`, ], descriptionLists: [ { @@ -89,7 +84,8 @@ export function help (): string { { description: 'Runtime to embed in the output executables, as a "@" spec ' + - '(e.g. "node@22", "node@22.0.0", "node@lts"). Only "node" is supported today. ' + + `(e.g. "node@${MIN_BUILDER_VERSION.major}", "node@${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0"). ` + + `Only "node" is supported today, and the version must be >= v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor} (the minimum that supports --build-sea). ` + 'Defaults to the running Node.js version.', name: '--runtime', }, @@ -186,8 +182,17 @@ export async function handler (opts: PackAppOptions, params: string[]): Promise< const fetch = createFetchFromRegistry(opts) const buildRoot = path.join(opts.pnpmHomeDir, 'pack-app') - const builderBin = await resolveBuilderBinary({ fetch, nodeDownloadMirrors: opts.nodeDownloadMirrors, buildRoot }) + // Resolve the embedded target version first so the builder can be pinned to + // the same version. SEA blobs carry no version header and the serialized + // format has changed across Node.js minor releases (e.g. v25.7 added a + // ModuleFormat byte for ESM entry points), so a blob produced by a builder + // of a different version than the embedded runtime will fail deserialization + // at startup with an opaque native assertion. const resolvedTargetVersion = await resolveVersion(fetch, requestedNodeSpec, opts.nodeDownloadMirrors) + const builderBin = await resolveBuilderBinary({ + buildRoot, + targetVersion: resolvedTargetVersion, + }) const results: string[] = [] for (const target of targets) { @@ -243,21 +248,32 @@ export async function handler (opts: PackAppOptions, params: string[]): Promise< } /** - * Returns a Node.js binary that supports `--build-sea`. Prefers the running - * interpreter to avoid a download; falls back to downloading Node.js v25. + * Returns a Node.js binary that supports `--build-sea` AND produces a SEA + * blob the embedded runtime can deserialize. The second constraint forces the + * builder to match the target runtime version exactly: blobs are versioned by + * the writer's internal struct layout with no header, and Node bumps that + * layout in minor releases (e.g. v25.7 added a ModuleFormat byte for ESM + * entries), so a cross-version blob crashes at startup. + * + * Prefers the running interpreter when it already matches the target version; + * otherwise downloads the target version for the host platform. */ async function resolveBuilderBinary (ctx: { - fetch: ReturnType - nodeDownloadMirrors?: Record buildRoot: string + targetVersion: string }): Promise { - if (runningNodeCanBuildSea()) { + if (runningNodeCanBuildSea() && process.version === `v${ctx.targetVersion}`) { return process.execPath } - const version = await resolveVersion(ctx.fetch, DEFAULT_BUILDER_SPEC, ctx.nodeDownloadMirrors) + if (!builderVersionCanBuildSea(ctx.targetVersion)) { + throw new PnpmError('PACK_APP_RUNTIME_TOO_OLD', + `The embedded runtime "node@${ctx.targetVersion}" is older than Node.js v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}, which is the minimum version that supports --build-sea.`, + { hint: `Pass --runtime node@${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0 (or newer) or set "pnpm.app.runtime" in package.json.` } + ) + } return ensureNodeRuntime({ buildRoot: ctx.buildRoot, - version, + version: ctx.targetVersion, platform: process.platform, arch: process.arch, // Pin libc to the host's. Otherwise a caller that had set @@ -274,7 +290,11 @@ function hostLinuxLibc (): 'glibc' | 'musl' | undefined { } function runningNodeCanBuildSea (): boolean { - const [majorStr, minorStr] = process.version.slice(1).split('.') + return builderVersionCanBuildSea(process.version.slice(1)) +} + +function builderVersionCanBuildSea (version: string): boolean { + const [majorStr, minorStr] = version.split('.') const major = Number(majorStr) const minor = Number(minorStr) return ( @@ -393,7 +413,7 @@ function parseRuntime (spec: string): ParsedRuntime { const match = RUNTIME_PATTERN.exec(spec) if (!match) { throw new PnpmError('PACK_APP_INVALID_RUNTIME', - `Invalid runtime "${spec}". Expected format: @ (supported runtimes: ${SUPPORTED_RUNTIMES.join(', ')}; e.g. "node@22.0.0", "node@lts").`) + `Invalid runtime "${spec}". Expected format: @ (supported runtimes: ${SUPPORTED_RUNTIMES.join(', ')}; e.g. "node@${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0").`) } return { name: match[1] as ParsedRuntime['name'], version: match[2] } }