feat(node): support package.json5 manifests (#566)

* feat(node): add support for package.json5

- introduce findPackageManifest to prioritize package.json over package.json5
- update dependency detection and installation logic to include package.json5
- ensure local file detection and workspace parsing support the new manifest format

Generated-by: aiautocommit

* feat(example): add node-pnpm-json5 project

* Add a new example project to demonstrate native pnpm support for package.json5 manifests.
* Include a test configuration to verify environment detection and CLI version reporting.

Generated-by: aiautocommit

* chore: bump node and pnpm versions in json5 example

Generated-by: aiautocommit

* test: add build plan snapshot for pnpm-json5 project

Generated-by: aiautocommit

* test: update snapshots

* chore(examples): update engine versions and test expectations

Generated-by: aiautocommit

* chore(deps): update pnpm-lock.yaml

Generated-by: aiautocommit

* docs: update mise configuration documentation and add package resolution notes

* Update the `install_before` setting name to `minimum_release_age` to match current configuration.
* Add clarification on how Railpack handles semver constraints from manifest files by simplifying ranges to major versions.

Generated-by: aiautocommit

* feat(example): log pnpm version in pnpm workspaces example

- Add checks for pnpm version via npm_config_user_agent and the pnpm CLI.
- Update test expectations to verify pnpm version reporting.

Generated-by: aiautocommit

* feat(node): support more pnpm lockfile version mapping

- Add support for pnpm lockfile versions 6.1 and 9.0 to ensure correct tool version detection.
- Refactor the lockfile version check into a switch statement to improve readability and maintainability.
- Add logging for cases where the pnpm lockfile version cannot be identified.

Generated-by: aiautocommit

* test(node): add tests for pnpm version resolution logic

- Verify that pnpm versions are correctly derived from pnpm-lock.yaml versioning.
- Ensure proper precedence hierarchy between lockfile, engines, and packageManager fields.

Generated-by: aiautocommit

* docs: move mise installation instruction to workflow section

Generated-by: aiautocommit

* fix(node): ensure engines.pnpm overrides lockfile version inference

- Reprioritize pnpm version resolution to prefer `engines` over lockfile-based inference.
- Lockfile version parsing can be ambiguous across major versions, whereas `engines` provides an explicit developer intent.

Generated-by: aiautocommit
This commit is contained in:
Michael Bianco
2026-05-30 13:12:36 +02:00
committed by GitHub
parent 76d9036a33
commit f99b48a960
14 changed files with 456 additions and 29 deletions
+1 -4
View File
@@ -36,12 +36,9 @@ with support for Node, Python, Go, PHP, and more.
or update lockfiles. Manual edits can result in invalid checksums and broken
builds.
# Mise Setup
If mise is not available in your environment, install it using the instructions at https://mise.jdx.dev/installing-mise.html.
# Workflow
- If mise is not available in your environment, install it using the instructions at https://mise.jdx.dev/installing-mise.html.
- Take a careful look at @mise.toml to understand what commands should be run at different points in the project lifecycle
- Do not worry about docker cache, etc. Never run `docker system prune` or any other similar commands.
- Do not run `go` directly. Instead, inspect @mise.toml and use `mise run <task>` to run various dev lifecycle commands. For instance, you should not run `go vet`, `go fmt`, `go test`, etc directly.
@@ -0,0 +1,178 @@
{
"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": "/root/.local/share/pnpm/store/v3",
"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.5.16"
}
],
"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.json5",
"src": "package.json5"
},
{
"dest": "pnpm-lock.yaml",
"src": "pnpm-lock.yaml"
},
{
"path": "/pnpm"
},
{
"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": "/pnpm"
}
},
{
"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.5.16"
}
],
"name": "packages:apt:runtime"
}
]
}
+24 -5
View File
@@ -29,8 +29,26 @@ const (
var (
// bunCommandRegex matches "bun" or "bunx" as a command (not part of another word)
bunCommandRegex = regexp.MustCompile(`(^|\s|;|&|&&|\||\|\|)bunx?\s`)
// PackageManifestFiles is the precedence order Railpack searches for a Node
// package manifest. package.json wins if both are present; package.json5 is
// supported because pnpm reads it natively (https://pnpm.io/package_json).
// Railpack's App.ReadJSON pipes through hujson, which accepts comments and
// trailing commas — the common subset of JSON5 used in real manifests.
PackageManifestFiles = []string{"package.json", "package.json5"}
)
// findPackageManifest returns the first manifest file present in the app,
// honoring PackageManifestFiles precedence. Returns "" if none are present.
func findPackageManifest(app *app.App) string {
for _, f := range PackageManifestFiles {
if app.HasFile(f) {
return f
}
}
return ""
}
type NodeProvider struct {
packageJson *PackageJson
packageManager PackageManager
@@ -60,7 +78,7 @@ func (p *NodeProvider) Initialize(ctx *generate.GenerateContext) error {
}
func (p *NodeProvider) Detect(ctx *generate.GenerateContext) (bool, error) {
return ctx.App.HasFile("package.json"), nil
return findPackageManifest(ctx.App) != "", nil
}
func (p *NodeProvider) Plan(ctx *generate.GenerateContext) error {
@@ -267,7 +285,7 @@ func (p *NodeProvider) InstallNodeDeps(ctx *generate.GenerateContext, install *g
ctx.Logger.LogInfo("Installing %s@%s with Corepack", pmName, pmVersion)
install.AddCommands([]plan.Command{
plan.NewCopyCommand("package.json"),
plan.NewCopyCommand(findPackageManifest(ctx.App)),
// 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.
// corepack *used* to be bundled with node, but as of v25 it's not, so we install it explicitly
@@ -457,13 +475,14 @@ func (p *NodeProvider) getPackageManager(app *app.App) PackageManager {
func (p *NodeProvider) GetPackageJson(app *app.App) (*PackageJson, error) {
packageJson := NewPackageJson()
if !app.HasFile("package.json") {
manifest := findPackageManifest(app)
if manifest == "" {
return packageJson, nil
}
err := app.ReadJSON("package.json", packageJson)
err := app.ReadJSON(manifest, packageJson)
if err != nil {
return nil, fmt.Errorf("error reading package.json: %w", err)
return nil, fmt.Errorf("error reading %s: %w", manifest, err)
}
return packageJson, nil
+25 -14
View File
@@ -51,8 +51,9 @@ func (p PackageManager) RunScriptCommand(cmd string) string {
}
// Map the active package manager to the mise tool key whose app-local version
// should override Railpack's inferred/default version. We can't use Name() here
// because it answers "which command do we run"; for npm that would return
// should override Railpack's inferred/default version.
//
// Can't use Name() because it answers "which command do we run"; for npm that would return
// "npm", but npm is not installed as a separate mise-managed tool in this flow.
// This lets us ask mise for the app's resolved tool version and keep Railpack's
// package-manager selection consistent with the app's mise config.
@@ -247,11 +248,12 @@ func (p PackageManager) pruneYarnBerry(ctx *generate.GenerateContext, prune *gen
func (p PackageManager) getPackageJsonFromContext(ctx *generate.GenerateContext) (*PackageJson, error) {
packageJson := NewPackageJson()
if !ctx.App.HasFile("package.json") {
manifest := findPackageManifest(ctx.App)
if manifest == "" {
return packageJson, nil
}
err := ctx.App.ReadJSON("package.json", packageJson)
err := ctx.App.ReadJSON(manifest, packageJson)
if err != nil {
return nil, err
}
@@ -275,7 +277,7 @@ func (p PackageManager) GetInstallFolder(ctx *generate.GenerateContext) []string
// SupportingInstallFiles returns a list of files that are needed to install dependencies
func (p PackageManager) SupportingInstallFiles(ctx *generate.GenerateContext) []string {
// Use brace expansion for single filesystem traversal instead of 16 separate globs
pattern := "**/{package.json,package-lock.json,pnpm-workspace.yaml,yarn.lock,pnpm-lock.yaml,bun.lockb,bun.lock,bunfig.toml,.yarn,.pnp.*,.yarnrc.yml,.npmrc,.node-version,.nvmrc,patches,.pnpm-patches,prisma}"
pattern := "**/{package.json,package.json5,package-lock.json,pnpm-workspace.yaml,yarn.lock,pnpm-lock.yaml,bun.lockb,bun.lock,bunfig.toml,.yarn,.pnp.*,.yarnrc.yml,.npmrc,.node-version,.nvmrc,patches,.pnpm-patches,prisma}"
var allFiles []string
@@ -312,22 +314,31 @@ 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") {
switch {
case strings.HasPrefix(lockfile, "lockfileVersion: 5.3"):
packages.Version(pnpm, "6", "pnpm-lock.yaml")
} else if strings.HasPrefix(lockfile, "lockfileVersion: 5.4") {
case strings.HasPrefix(lockfile, "lockfileVersion: 5.4"):
packages.Version(pnpm, "7", "pnpm-lock.yaml")
} else if strings.HasPrefix(lockfile, "lockfileVersion: '6.0'") {
case strings.HasPrefix(lockfile, "lockfileVersion: '6.0'") || strings.HasPrefix(lockfile, "lockfileVersion: 6.0"):
packages.Version(pnpm, "8", "pnpm-lock.yaml")
case strings.HasPrefix(lockfile, "lockfileVersion: '6.1'"):
packages.Version(pnpm, "8", "pnpm-lock.yaml")
case strings.HasPrefix(lockfile, "lockfileVersion: '9.0'") || strings.HasPrefix(lockfile, "lockfileVersion: 9.0"):
// pnpm 9 introduced lockfileVersion 9.0.
// pnpm 10 and 11 continue using the same 9.0 format (no bump).
packages.Version(pnpm, "9", "pnpm-lock.yaml")
default:
log.Warnf("could not detect pnpm lockfile version")
}
}
// engines.pnpm overrides lockfile inference because lockfileVersion is ambiguous across majors
if packageJson != nil && packageJson.Engines != nil && packageJson.Engines["pnpm"] != "" {
packages.Version(pnpm, packageJson.Engines["pnpm"], "package.json > engines > pnpm")
}
if pmName == "pnpm" && pmVersion != "" {
packages.Version(pnpm, pmVersion, "package.json > packageManager")
@@ -383,7 +394,7 @@ func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext,
// usesLocalFile returns true if the package.json has a local dependency (e.g. file:./path/to/package)
func (p PackageManager) usesLocalFile(ctx *generate.GenerateContext) bool {
files, err := ctx.App.FindFiles("**/package.json")
files, err := ctx.App.FindFiles("**/{package.json,package.json5}")
if err != nil {
return false
}
+130
View File
@@ -0,0 +1,130 @@
package node
import (
"os"
"path/filepath"
"testing"
"github.com/railwayapp/railpack/core/resolver"
testingUtils "github.com/railwayapp/railpack/core/testing"
"github.com/stretchr/testify/require"
)
func TestGetPackageManagerPackages_PnpmLockfileVersion(t *testing.T) {
tests := []struct {
name string
lockfile string
wantVersion string
wantSource string
}{
{
name: "lockfile 5.3",
lockfile: "lockfileVersion: 5.3\n",
wantVersion: "6",
wantSource: "pnpm-lock.yaml",
},
{
name: "lockfile 5.4",
lockfile: "lockfileVersion: 5.4\n",
wantVersion: "7",
wantSource: "pnpm-lock.yaml",
},
{
name: "lockfile 6.0 quoted",
lockfile: "lockfileVersion: '6.0'\n",
wantVersion: "8",
wantSource: "pnpm-lock.yaml",
},
{
name: "lockfile 6.0 unquoted",
lockfile: "lockfileVersion: 6.0\n",
wantVersion: "8",
wantSource: "pnpm-lock.yaml",
},
{
name: "lockfile 6.1",
lockfile: "lockfileVersion: '6.1'\n",
wantVersion: "8",
wantSource: "pnpm-lock.yaml",
},
{
name: "lockfile 9.0 quoted",
lockfile: "lockfileVersion: '9.0'\n",
wantVersion: "9",
wantSource: "pnpm-lock.yaml",
},
{
name: "lockfile 9.0 unquoted",
lockfile: "lockfileVersion: 9.0\n",
wantVersion: "9",
wantSource: "pnpm-lock.yaml",
},
{
name: "unknown lockfile keeps default",
lockfile: "lockfileVersion: '99.0'\n",
wantVersion: DEFAULT_PNPM_VERSION,
wantSource: resolver.DefaultSource,
},
{
name: "no lockfile keeps default",
lockfile: "",
wantVersion: DEFAULT_PNPM_VERSION,
wantSource: resolver.DefaultSource,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte("{}"), 0o644))
if tt.lockfile != "" {
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "pnpm-lock.yaml"), []byte(tt.lockfile), 0o644))
}
ctx := testingUtils.CreateGenerateContext(t, tmpDir)
miseStep := ctx.NewMiseStepBuilder("test")
PackageManagerPnpm.GetPackageManagerPackages(ctx, NewPackageJson(), miseStep)
pnpm := ctx.Resolver.Get("pnpm")
require.NotNil(t, pnpm)
require.Equal(t, tt.wantVersion, pnpm.Version)
require.Equal(t, tt.wantSource, pnpm.Source)
})
}
}
func TestGetPackageManagerPackages_PnpmVersionPrecedence(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte("{}"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "pnpm-lock.yaml"), []byte("lockfileVersion: '6.0'\n"), 0o644))
t.Run("engines override lockfile", func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, tmpDir)
miseStep := ctx.NewMiseStepBuilder("test")
packageJson := NewPackageJson()
packageJson.Engines["pnpm"] = "10"
PackageManagerPnpm.GetPackageManagerPackages(ctx, packageJson, miseStep)
pnpm := ctx.Resolver.Get("pnpm")
require.NotNil(t, pnpm)
require.Equal(t, "10", pnpm.Version)
require.Equal(t, "package.json > engines > pnpm", pnpm.Source)
})
t.Run("packageManager overrides lockfile", func(t *testing.T) {
ctx := testingUtils.CreateGenerateContext(t, tmpDir)
miseStep := ctx.NewMiseStepBuilder("test")
pm := "[email protected]"
packageJson := &PackageJson{PackageManager: &pm}
PackageManagerPnpm.GetPackageManagerPackages(ctx, packageJson, miseStep)
pnpm := ctx.Resolver.Get("pnpm")
require.NotNil(t, pnpm)
require.Equal(t, "10.4.1", pnpm.Version)
require.Equal(t, "package.json > packageManager", pnpm.Source)
require.True(t, pnpm.SkipMiseInstall)
})
}
+7 -3
View File
@@ -21,11 +21,15 @@ type PnpmWorkspace struct {
Packages []string `yaml:"packages"`
}
// NewWorkspace creates a new workspace from a package.json file
// NewWorkspace creates a new workspace from a package.json (or package.json5) file
func NewWorkspace(app *app.App) (*Workspace, error) {
packageJson, err := readPackageJson(app, "package.json")
manifest := findPackageManifest(app)
if manifest == "" {
manifest = "package.json"
}
packageJson, err := readPackageJson(app, manifest)
if err != nil {
return nil, fmt.Errorf("error reading root package.json: %w", err)
return nil, fmt.Errorf("error reading root %s: %w", manifest, err)
}
workspace := &Workspace{
@@ -14,7 +14,8 @@ Railpack and alternative installation methods are possible (for example PHP will
use Mise to resolve a valid version and then start from a PHP base image).
Railpack enables Mise paranoid mode for stricter security validation.
For more information on how Railpack utilizes Mise and our philosophy on tool defaults, see the [Mise Configuration](/config/mise) guide.
For more information on how Railpack utilizes Mise and our philosophy on tool
defaults, see the [Mise Configuration](/config/mise) guide.
## Previous and default versions
@@ -40,4 +41,13 @@ Passing in a previous version will only be used in place of the default. If a
more specific version of a package is requested (e.g. through a package.json
engines field or env var), then we will always use that.
This is done on Railway automatically.
This is done on Railway automatically.
## Semver constraints from app manifests
Manifest constraints (for example `engines.pnpm`) are not resolved with
npm-style semver. Caret (`^`) and range (`>=`, `<`) notation are simplified to
the major version only (`^10.34.0` → `10`) before Mise picks a release. This
is done because Mise does not support compound version constraints right now.
Use an exact version or `mise.toml` to pin. See also
[`minimum_release_age`](/config/mise#minimum_release_age).
+1 -1
View File
@@ -28,7 +28,7 @@ Railpack sets the following mise settings by default in the generated
| `paranoid` | `true` | Enforces HTTPS and stricter security validation |
| `trusted_config_paths` | `["/app"]` | Trusts app config files to avoid warnings during build |
| `idiomatic_version_file_enable_tools` | *(language list)* | Auto-reads version files like `.node-version`, `.python-version`, etc. |
| `install_before` | `"14d"` | Only resolves tool versions released more than 14 days ago, avoiding newly-released versions that may be broken |
| `minimum_release_age` | `"14d"` | Mise will omit language, package manager, etc versions released in the last two weeks. You can override this settings in your application's mise configuration. |
| `node.verify` | `false` | Skips asset signature verification for Node, since recently released versions may not yet have a public key |
## Customization
+22
View File
@@ -0,0 +1,22 @@
const { execSync } = require('child_process');
function getPnpmVersion() {
const ua = process.env.npm_config_user_agent || "";
return /\bpnpm\/([^\s]+)/.exec(ua)?.[1] ?? null;
}
console.log(`Node.js version: ${process.version}`);
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');
}
+15
View File
@@ -0,0 +1,15 @@
{
// pnpm reads package.json5 natively — https://pnpm.io/package_json
"name": "node-pnpm-json5",
"private": true,
"version": "1.0.0",
"description": "Example exercising pnpm's package.json5 manifest support",
"main": "main.js",
"scripts": {
"start": "node main.js",
},
"engines": {
"node": "26.1.0",
"pnpm": "^10.33.0",
},
}
+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: v26.1.0",
"PNPM version (from user agent): v10.",
"PNPM version (from CLI): v10."
]
}
]
+21
View File
@@ -1,7 +1,28 @@
import { execSync } from "node:child_process";
import { greet as greetA } from "pkg-a";
import { greet as greetB } from "pkg-b";
function getPnpmVersion() {
const ua = process.env.npm_config_user_agent || "";
return /\bpnpm\/([^\s]+)/.exec(ua)?.[1] ?? null;
}
console.log("Node v:", process.version);
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 {
console.log("PNPM version (from CLI): not available");
}
console.log("Hello from pnpm workspaces");
console.log(greetA());
+2
View File
@@ -2,6 +2,8 @@
{
"expectedOutput": [
"Node v: v22.2.0",
"PNPM version (from user agent): v9.",
"PNPM version (from CLI): v9.",
"Hello from pnpm workspaces",
"pkg-a loaded",
"pkg-b loaded",