fix(node): support pnpm 11+ global bin directory layout (#581)

* feat: add reproduction case for pnpm 11 engines mismatch

- Add a new example project to reproduce issues when using pnpm 11 in engines configuration with '.x' notation.
- Include a test definition to verify node and pnpm versions as detected by the environment.

Generated-by: aiautocommit

* test: add snapshot for node-pnpm-11-engines example

Generated-by: aiautocommit

* feat(node): support pnpm 11+ global bin directory layout

* Resolve pnpm version via the context resolver to handle non-semver range notation (e.g., "11.5.x") found in `engines.pnpm`.
* Update global bin directory path for pnpm 11+ to `PNPM_HOME/bin` to ensure `node-gyp` installation succeeds.
* Add test case for resolved mise versions.

Generated-by: aiautocommit
This commit is contained in:
Michael Bianco
2026-06-04 12:34:04 +02:00
committed by GitHub
parent 7ba1675972
commit 95e3a0f1ea
7 changed files with 275 additions and 4 deletions
@@ -0,0 +1,179 @@
{
"caches": {
"apt": {
"directory": "/var/cache/apt",
"type": "locked"
},
"apt-lists": {
"directory": "/var/lib/apt/lists",
"type": "locked"
},
"node-modules": {
"directory": "/app/node_modules/.cache",
"type": "shared"
},
"pnpm-install": {
"directory": "/opt/pnpm/store",
"type": "shared"
}
},
"deploy": {
"base": {
"step": "packages:apt:runtime"
},
"inputs": [
{
"include": [
"/mise/shims",
"/mise/installs",
"/usr/local/bin/mise",
"/etc/mise/config.toml",
"/root/.local/state/mise"
],
"step": "packages:mise"
},
{
"include": [
"/app/node_modules"
],
"step": "build"
},
{
"exclude": [
"node_modules",
".yarn"
],
"include": [
"/root/.cache",
"."
],
"step": "build"
}
],
"startCommand": "pnpm run start",
"variables": {
"CI": "true",
"NODE_ENV": "production",
"NPM_CONFIG_FUND": "false",
"NPM_CONFIG_PRODUCTION": "false",
"NPM_CONFIG_UPDATE_NOTIFIER": "false"
}
},
"steps": [
{
"assets": {
"generated-mise-toml": "[generated-mise-toml]"
},
"commands": [
{
"path": "/mise/shims"
},
{
"customName": "create mise config",
"name": "generated-mise-toml",
"path": "/etc/mise/config.toml"
},
{
"cmd": "mise install",
"customName": "install mise packages: node, pnpm"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:mise-2026.6.0"
}
],
"name": "packages:mise",
"variables": {
"MISE_CACHE_DIR": "/mise/cache",
"MISE_CONFIG_DIR": "/mise",
"MISE_DATA_DIR": "/mise",
"MISE_INSTALLS_DIR": "/mise/installs",
"MISE_SHIMS_DIR": "/mise/shims"
}
},
{
"caches": [
"pnpm-install"
],
"commands": [
{
"path": "/app/node_modules/.bin"
},
{
"cmd": "mkdir -p /app/node_modules/.cache"
},
{
"dest": "package.json",
"src": "package.json"
},
{
"dest": "pnpm-lock.yaml",
"src": "pnpm-lock.yaml"
},
{
"path": "/opt/pnpm/bin"
},
{
"cmd": "pnpm add -g node-gyp"
},
{
"cmd": "pnpm install --frozen-lockfile --prefer-offline"
}
],
"inputs": [
{
"step": "packages:mise"
}
],
"name": "install",
"variables": {
"CI": "true",
"NODE_ENV": "production",
"NPM_CONFIG_FUND": "false",
"NPM_CONFIG_PRODUCTION": "false",
"NPM_CONFIG_UPDATE_NOTIFIER": "false",
"PNPM_HOME": "/opt/pnpm",
"PNPM_STORE_DIR": "/opt/pnpm/store"
}
},
{
"caches": [
"node-modules"
],
"inputs": [
{
"step": "install"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
},
{
"caches": [
"apt",
"apt-lists"
],
"commands": [
{
"cmd": "sh -c 'apt-get update \u0026\u0026 apt-get install -y libatomic1'",
"customName": "install apt packages: libatomic1"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-runtime:mise-2026.6.0"
}
],
"name": "packages:apt:runtime"
}
]
}
+5
View File
@@ -285,6 +285,11 @@ func TestUsesPnpmBinSubdir(t *testing.T) {
version: "workspace:^",
want: false,
},
{
name: "resolved mise version",
version: "11.5.1",
want: true,
},
}
for _, tt := range tests {
+33 -4
View File
@@ -146,12 +146,18 @@ func (p PackageManager) installDeps(ctx *generate.GenerateContext, install *gene
if !usingCorepack {
pnpmBinPath := PNPM_HOME
// newer versions of pnpm use a different bin directory
if requestedPnpm := ctx.Resolver.Get("pnpm"); requestedPnpm != nil && usesPnpmBinSubdir(requestedPnpm.Version) {
// pnpm 11+ installs global bins under PNPM_HOME/bin.
//
// We compare against the mise-resolved version rather than the requested one solely to handle
// the Node ecosystem's "x-range" engines notation (e.g. `engines.pnpm: "11.5.x"`), which is not
// valid semver and so cannot be parsed directly. Mise resolves it to a concrete version
// (e.g. "11.5.1") that we can compare. For any other source (exact version, mise.toml, etc.)
// resolving vs. using the requested string would yield the same result.
if usesPnpmBinSubdir(resolvePnpmVersion(ctx)) {
pnpmBinPath = PNPM_HOME + "/bin"
}
// binaries are installed in the /bin subpath. If this is not added to PATH `pnpm add -g` will fail
// The path must be added before `pnpm add -g`, otherwise pnpm errors that the global bin dir is not in PATH
install.AddPaths([]string{pnpmBinPath})
install.AddCommand(plan.NewExecCommand("pnpm add -g node-gyp"))
}
@@ -171,7 +177,7 @@ func (p PackageManager) installDeps(ctx *generate.GenerateContext, install *gene
}
}
// pnpm <= 11 used PNPM_HOME for bins, but pnpm 11+ uses a "bin" subdirectory within the PNPM_HOME directory
// pnpm < 11 used PNPM_HOME for bins; pnpm 11+ uses a "bin" subdirectory within PNPM_HOME
func usesPnpmBinSubdir(version string) bool {
version = strings.TrimSpace(version)
if version == "" {
@@ -190,6 +196,29 @@ func usesPnpmBinSubdir(version string) bool {
return pnpmVersion.Compare(pnpmPathLayoutVersion) >= 0
}
// resolvePnpmVersion returns the mise-resolved pnpm version (e.g. "11.5.1").
//
// This exists only to normalize the Node ecosystem's x-range engines notation (e.g. "11.5.x") into a
// concrete semver. We let mise do the resolution instead of reimplementing npm's range semantics ourselves.
// Falls back to the requested version string if resolution fails; the real error is surfaced later when
// the context resolves packages for the plan.
//
// Some build configuration depends on the exact pnpm version that exists, which is why this is critical.
// TODO we should make this a generic function with a mise tool name param
func resolvePnpmVersion(ctx *generate.GenerateContext) string {
if pkgs, err := ctx.Resolver.ResolvePackages(); err == nil {
if pnpm, ok := pkgs["pnpm"]; ok && pnpm.ResolvedVersion != nil {
return *pnpm.ResolvedVersion
}
}
if requested := ctx.Resolver.Get("pnpm"); requested != nil {
return requested.Version
}
return ""
}
func (p PackageManager) PruneDeps(ctx *generate.GenerateContext, prune *generate.CommandStepBuilder) {
prune.AddCache(p.GetInstallCache(ctx))
+23
View File
@@ -0,0 +1,23 @@
const { execSync } = require('child_process');
function getPnpmVersion() {
const ua = process.env.npm_config_user_agent || "";
return /\bpnpm\/([^\s]+)/.exec(ua)?.[1] ?? null;
}
const nodeVersion = process.version;
console.log(`Node.js version: ${nodeVersion}`);
const pnpmVersionFromUA = getPnpmVersion();
if (pnpmVersionFromUA) {
console.log(`PNPM version (from user agent): v${pnpmVersionFromUA}`);
} else {
console.log('PNPM version (from user agent): not detected');
}
try {
const pnpmVersion = execSync('pnpm --version', { encoding: 'utf8' }).trim();
console.log(`PNPM version (from CLI): v${pnpmVersion}`);
} catch (error) {
console.log('PNPM version (from CLI): not available');
}
@@ -0,0 +1,17 @@
{
"name": "node-pnpm-11-engines",
"private": true,
"version": "1.0.0",
"description": "Reproduces railpack#577: pnpm 11 via engines (mise) fails on pnpm add -g node-gyp when engines uses .x notation",
"main": "main.js",
"scripts": {
"start": "node main.js"
},
"engines": {
"node": "24.16.0",
"pnpm": "11.5.x"
},
"keywords": [],
"author": "",
"license": "ISC"
}
+9
View File
@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
+9
View File
@@ -0,0 +1,9 @@
[
{
"expectedOutput": [
"Node.js version: v24.16.0",
"PNPM version (from CLI): v11."
],
"stderrAllowed": true
}
]