feat: support package.json engines for package manager and node versions (#251)

* refactor(node): check engines after file-based detection for package manager

Engines are now considered only if file-based detection keeps default npm,
so lockfiles/configs take precedence over engines as a hint.

* feat: extract yarn version from package.json > engines > yarn

* test: add bun version to bun test assertion

* test: update snapshots

* test: add engines version extraction test

* test: assert against two distinct versions

we couldn't do this before the recent testing harness improvement

* test: snapshot update

* fix: properly handle corepack version definitions

* docs: update docs on node provider detection

* docs: add docs command to claude
This commit is contained in:
Michael Bianco
2025-10-22 13:12:55 -06:00
committed by GitHub
parent c3b8a45727
commit 0bc3bce09a
14 changed files with 321 additions and 25 deletions
+3
View File
@@ -0,0 +1,3 @@
Take a look at changes on this branch (compared to upstream/main) and update any relevant documentation in @docs/.
Only update markdown documentation, do not make any code changes.
@@ -1,11 +1,19 @@
{
"caches": {
"node-modules": {
"directory": "/app/node_modules/.cache",
"apt": {
"directory": "/var/cache/apt",
"type": "locked"
},
"apt-lists": {
"directory": "/var/lib/apt/lists",
"type": "locked"
},
"bun-install": {
"directory": "/root/.bun/install/cache",
"type": "shared"
},
"npm-install": {
"directory": "/root/.npm",
"node-modules": {
"directory": "/app/node_modules/.cache",
"type": "shared"
}
},
@@ -42,7 +50,7 @@
"step": "build"
}
],
"startCommand": "npm run start",
"startCommand": "bun run start",
"variables": {
"CI": "true",
"NODE_ENV": "production",
@@ -52,6 +60,24 @@
}
},
"steps": [
{
"caches": [
"apt",
"apt-lists"
],
"commands": [
{
"cmd": "sh -c 'apt-get update \u0026\u0026 apt-get install -y nodejs'",
"customName": "install apt packages: nodejs"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:latest"
}
],
"name": "packages:apt:build"
},
{
"assets": {
"mise.toml": "[mise.toml]"
@@ -67,12 +93,12 @@
},
{
"cmd": "sh -c 'mise trust -a \u0026\u0026 mise install'",
"customName": "install mise packages: bun, node"
"customName": "install mise packages: bun"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:latest"
"step": "packages:apt:build"
}
],
"name": "packages:mise",
@@ -87,7 +113,7 @@
},
{
"caches": [
"npm-install"
"bun-install"
],
"commands": [
{
@@ -101,7 +127,7 @@
"src": "package.json"
},
{
"cmd": "npm install"
"cmd": "bun install --frozen-lockfile"
}
],
"inputs": [
@@ -0,0 +1,142 @@
{
"caches": {
"node-modules": {
"directory": "/app/node_modules/.cache",
"type": "shared"
},
"pnpm-install": {
"directory": "/root/.local/share/pnpm/store/v3",
"type": "shared"
}
},
"deploy": {
"base": {
"image": "ghcr.io/railwayapp/railpack-runtime:latest"
},
"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": {
"mise.toml": "[mise.toml]"
},
"commands": [
{
"path": "/mise/shims"
},
{
"customName": "create mise config",
"name": "mise.toml",
"path": "/etc/mise/config.toml"
},
{
"cmd": "sh -c 'mise trust -a \u0026\u0026 mise install'",
"customName": "install mise packages: node, pnpm"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:latest"
}
],
"name": "packages:mise",
"variables": {
"MISE_CACHE_DIR": "/mise/cache",
"MISE_CONFIG_DIR": "/mise",
"MISE_DATA_DIR": "/mise",
"MISE_INSTALLS_DIR": "/mise/installs",
"MISE_NODE_VERIFY": "false",
"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"
},
{
"cmd": "pnpm install"
}
],
"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"
}
},
{
"caches": [
"node-modules"
],
"inputs": [
{
"step": "install"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
}
]
}
+31 -7
View File
@@ -260,6 +260,8 @@ func (p *NodeProvider) InstallNodeDeps(ctx *generate.GenerateContext, install *g
install.AddCommands([]plan.Command{
plan.NewCopyCommand("package.json"),
// corepack will detect the package manager version from package.json, safe to assume the user is properly
// specifying the version they want there, no need to check other version specifications.
plan.NewExecShellCommand("npm i -g corepack@latest && corepack enable && corepack prepare --activate"),
})
}
@@ -313,6 +315,11 @@ func (p *NodeProvider) InstallMisePackages(ctx *generate.GenerateContext, miseSt
miseStep.Version(bun, string(bunVersionFile), ".bun-version")
}
if bunVersionFile, err := ctx.App.ReadFile(".bun-version"); err == nil {
miseStep.Version(bun, string(bunVersionFile), ".bun-version")
}
// TODO why don't we install this via mise?
// If we don't need node in the final image, we still want to include it for the install steps
// since many packages need node-gyp to install native modules
// in this case, we don't need a specific version, so we'll just pull from apt
@@ -352,6 +359,7 @@ func (p *NodeProvider) hasDependency(dependency string) bool {
return p.packageJson.hasDependency(dependency)
}
// if packageManager config exists in package.json, then assume corepack
func (p *NodeProvider) usesCorepack() bool {
return p.packageJson != nil && p.packageJson.PackageManager != nil && p.packageManager != PackageManagerBun
}
@@ -361,8 +369,6 @@ func (p *NodeProvider) usesPuppeteer() bool {
}
func (p *NodeProvider) getPackageManager(app *app.App) PackageManager {
packageManager := PackageManagerNpm
// Check packageManager field first
if packageJson, err := p.GetPackageJson(app); err == nil && packageJson.PackageManager != nil {
pmName, pmVersion := packageJson.GetPackageManagerInfo()
@@ -382,16 +388,34 @@ func (p *NodeProvider) getPackageManager(app *app.App) PackageManager {
// Fall back to file-based detection
if app.HasMatch("pnpm-lock.yaml") {
packageManager = PackageManagerPnpm
return PackageManagerPnpm
} else if app.HasMatch("bun.lockb") || app.HasMatch("bun.lock") {
packageManager = PackageManagerBun
return PackageManagerBun
} else if app.HasMatch(".yarnrc.yml") || app.HasMatch(".yarnrc.yaml") {
packageManager = PackageManagerYarnBerry
return PackageManagerYarnBerry
} else if app.HasMatch("yarn.lock") {
packageManager = PackageManagerYarn1
return PackageManagerYarn1
}
return packageManager
// Finally, consider engines as a last-resort
if packageJson, err := p.GetPackageJson(app); err == nil && packageJson.Engines != nil {
if engine := strings.TrimSpace(packageJson.Engines["pnpm"]); engine != "" {
return PackageManagerPnpm
}
if engine := strings.TrimSpace(packageJson.Engines["bun"]); engine != "" {
return PackageManagerBun
}
if engine := strings.TrimSpace(packageJson.Engines["yarn"]); engine != "" {
// Decide yarn major: 1 -> yarn1, otherwise default to berry
major := strings.Split(engine, ".")[0]
if major == "1" {
return PackageManagerYarn1
}
return PackageManagerYarnBerry
}
}
return PackageManagerNpm
}
func (p *NodeProvider) GetPackageJson(app *app.App) (*PackageJson, error) {
+3
View File
@@ -72,6 +72,9 @@ func (p *PackageJson) hasLocalDependency() bool {
return false
}
// parse a packageManager string in the format "name@version" or "name@version+extra".
// Returns the package manager name and version as separate strings.
// returns empty strings for both name and version if it can't be parsed
func (p *PackageJson) GetPackageManagerInfo() (string, string) {
if p == nil || p.PackageManager == nil {
return "", ""
+25 -3
View File
@@ -266,6 +266,11 @@ func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext,
if p == PackageManagerPnpm {
pnpm := packages.Default("pnpm", DEFAULT_PNPM_VERSION)
// Prefer explicit version from package.json engines over defaults/lockfile
if packageJson != nil && packageJson.Engines != nil && packageJson.Engines["pnpm"] != "" {
packages.Version(pnpm, packageJson.Engines["pnpm"], "package.json > engines > pnpm")
}
lockfile, err := ctx.App.ReadFile("pnpm-lock.yaml")
if err == nil {
if strings.HasPrefix(lockfile, "lockfileVersion: 5.3") {
@@ -280,25 +285,37 @@ func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext,
if pmName == "pnpm" && pmVersion != "" {
packages.Version(pnpm, pmVersion, "package.json > packageManager")
// We want to skip installing with Mise and just install with corepack instead
// skip installing via Mise and install with corepack instead
// https://github.com/railwayapp/railpack/issues/201
packages.SkipMiseInstall(pnpm)
}
}
// Yarn
if p == PackageManagerYarn1 || p == PackageManagerYarnBerry {
var defaultMajor string
if p == PackageManagerYarn1 {
packages.Default("yarn", "1")
defaultMajor = "1"
packages.AddSupportingAptPackage("tar")
packages.AddSupportingAptPackage("gpg")
} else {
packages.Default("yarn", "2")
defaultMajor = "2"
}
yarn := packages.Default("yarn", defaultMajor)
// Prefer explicit version from package.json engines over defaults
if packageJson != nil && packageJson.Engines != nil && packageJson.Engines["yarn"] != "" {
packages.Version(yarn, packageJson.Engines["yarn"], "package.json > engines > yarn")
}
// TODO we should use SemVer at this point
if pmName == "yarn" && pmVersion != "" {
majorVersion := strings.Split(pmVersion, ".")[0]
yarn := packages.Default("yarn", majorVersion)
packages.Version(yarn, pmVersion, "package.json > packageManager")
// skip installing via Mise and install with corepack instead
// https://github.com/railwayapp/railpack/issues/201
packages.SkipMiseInstall(yarn)
}
}
@@ -307,6 +324,11 @@ func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext,
if p == PackageManagerBun {
bun := packages.Default("bun", "latest")
// Prefer explicit version from package.json engines over defaults
if packageJson != nil && packageJson.Engines != nil && packageJson.Engines["bun"] != "" {
packages.Version(bun, packageJson.Engines["bun"], "package.json > engines > bun")
}
if pmName == "bun" && pmVersion != "" {
packages.Version(bun, pmVersion, "package.json > packageManager")
}
+12 -3
View File
@@ -25,6 +25,8 @@ The Node.js version is determined in the following order:
The Bun version is determined in the following order:
- Set via the `RAILPACK_BUN_VERSION` environment variable
- Read from the `.bun-version` file
- Read from the `engines.bun` field in `package.json`
- Defaults to `latest`
If Bun is used as the package manager, Node.js will still be installed in the
@@ -86,16 +88,23 @@ Railpack determines the start command in the following order:
Railpack detects your package manager in the following order:
1. **packageManager field**: Reads the `packageManager` field from
`package.json`
`package.json` (uses Corepack to install the specified version)
2. **Lock files**: Falls back to detecting based on lock files:
- `pnpm-lock.yaml` for pnpm
- `bun.lockb` or `bun.lock` for Bun
- `.yarnrc.yml` or `.yarnrc.yaml` for Yarn Berry (2+)
- `yarn.lock` for Yarn 1
- Defaults to npm if no lock file is found
3. **engines field**: As a fallback, checks the `engines` field in
`package.json` for package manager versions:
- `engines.pnpm` for pnpm version
- `engines.bun` for Bun version
- `engines.yarn` for Yarn version
- Defaults to npm if no package manager is detected
When the `packageManager` field is present, Railpack will use Corepack to
install the specified package manager version.
install the specified package manager version. When a package manager is
detected via the `engines` field, the specified version constraint will be
used.
### Install
+2 -1
View File
@@ -1 +1,2 @@
console.log("hi from bun without a lock")
const bunVersion = Bun.version.split('.')[0];
console.log(`hi from bun without a lock - using Bun major version ${bunVersion}`)
+1 -1
View File
@@ -1,5 +1,5 @@
[
{
"expectedOutput": "hi from bun without a lock"
"expectedOutput": "hi from bun without a lock - using Bun major version 1"
}
]
+26
View File
@@ -0,0 +1,26 @@
const { execSync } = require('child_process');
function getPnpmVersion() {
const ua = process.env.npm_config_user_agent || "";
return /\bpnpm\/([^\s]+)/.exec(ua)?.[1] ?? null; // e.g. "8.15.6"
}
// Get Node.js version
const nodeVersion = process.version;
console.log(`Node.js version: ${nodeVersion}`);
// Get PNPM version from user agent
const pnpmVersionFromUA = getPnpmVersion();
if (pnpmVersionFromUA) {
console.log(`PNPM version (from user agent): v${pnpmVersionFromUA}`);
} else {
console.log('PNPM version (from user agent): not detected');
}
// Get PNPM version from CLI (fallback)
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');
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "node-pnpm-engines",
"version": "1.0.0",
"description": "Example testing engines specification for Node.js and PNPM",
"main": "main.js",
"scripts": {
"start": "node main.js"
},
"engines": {
"node": "22.19.0",
"pnpm": "8.15.9"
},
"keywords": [],
"author": "",
"license": "ISC"
}
+9
View File
@@ -0,0 +1,9 @@
[
{
"expectedOutput": [
"Node.js version: v22.19.0",
"PNPM version (from user agent): v8.15.9",
"PNPM version (from CLI): v8.15.9"
]
}
]
+6 -1
View File
@@ -115,7 +115,12 @@ func ExtractSemverVersion(version string) string {
// ParseSemver parses a semantic version string and returns a Semver struct.
func ParseSemver(version string) (*Semver, error) {
// Remove potential prefixes like "v" or "ruby-"
// Handle corepack version format like "[email protected]"
if idx := strings.Index(version, "@"); idx != -1 {
version = version[idx+1:]
}
// Remove potential prefixes like "v"
version = strings.TrimPrefix(version, "v")
// Split by dots
+10
View File
@@ -78,6 +78,16 @@ func TestParseSemver(t *testing.T) {
wantErr: true,
errorPrefix: "invalid major version",
},
{
name: "corepack version",
version: "[email protected]",
want: &Semver{
Major: 8,
Minor: 15,
Patch: 4,
},
wantErr: false,
},
}
for _, tt := range tests {