feat(node): gracefully handle out of sync package-lock.json (#643)

* feat(node): add out-of-sync package-lock npm example

- Add new example demonstrating npm ci failure with an out-of-sync lockfile
- Include test configuration and documentation for the scenario

Generated-by: aiautocommit

* fix(node): fallback to npm install when lockfile is missing

- Always use `npm install` for npm projects to prevent build failures when `package-lock.json` is missing.
- Log a warning when the lockfile is absent to encourage generating one.
- Clean up outdated comments regarding package manifest parsing.

Generated-by: aiautocommit

* test(core): update test snapshots for npm install changes

Generated-by: aiautocommit

* feat(node): add npm ci recommendation and railpack example

- Document recommendations for preferring `npm ci` over `npm install` for Node.js projects using npm.
- Add a corresponding `railpack.json` example demonstrating how to configure deterministic installs with `npm ci`.

Generated-by: aiautocommit

* refactor(core): use npm ci in build plan for node npm examples

- Switch from `npm install` to `npm ci` for deterministic builds when package-lock.json is present.
- Remove redundant cache directory creation step.

Generated-by: aiautocommit

* feat(node): add suggestions for missing lockfiles

- Log suggestions when `package-lock.json` or `pnpm-lock.yaml` is missing to encourage deterministic installs.
- Update architecture documentation with guidance on committing `package-lock.json`.

Generated-by: aiautocommit

* test(core): update out-of-sync package-lock snapshot for builder changes

Generated-by: aiautocommit

* feat(node): allow overriding npm install command via config variable

Generated-by: aiautocommit

* docs(node): document RAILPACK_NODE_NPM_INSTALL environment variable

Generated-by: aiautocommit

* docs: recommend committing all package manager lockfiles

Generated-by: aiautocommit
This commit is contained in:
Michael Bianco
2026-07-27 06:50:07 -06:00
committed by GitHub
parent 5814dcc0e6
commit ac1fbbafa3
32 changed files with 357 additions and 32 deletions
@@ -105,7 +105,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -109,7 +109,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -105,7 +105,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -125,7 +125,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -129,7 +129,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -109,7 +109,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -118,7 +118,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -114,7 +114,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -137,7 +137,7 @@
"src": "packages/web/package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -102,9 +102,6 @@
{
"path": "/app/node_modules/.bin"
},
{
"cmd": "mkdir -p /app/node_modules/.cache"
},
{
"dest": "package-lock.json",
"src": "package-lock.json"
@@ -114,7 +111,8 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "sh -c 'npm ci'",
"customName": "npm ci"
}
],
"inputs": [
@@ -106,7 +106,7 @@
"cmd": "mkdir -p /app/node_modules/.cache"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -118,7 +118,7 @@
"src": "prisma"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -114,7 +114,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -122,7 +122,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -109,7 +109,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -153,7 +153,7 @@
"src": "packages/utils/package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -109,7 +109,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -0,0 +1,174 @@
{
"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"
},
"npm-install": {
"directory": "/root/.npm",
"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": "npm run start",
"variables": {
"CI": "true",
"NODE_ENV": "production",
"NPM_CONFIG_FETCH_RETRIES": "5",
"NPM_CONFIG_FUND": "false",
"NPM_CONFIG_PRODUCTION": "false",
"NPM_CONFIG_UPDATE_NOTIFIER": "false",
"RAILPACK_VERSION": "dev"
}
},
"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"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:mise-2026.7.11"
}
],
"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": [
"npm-install"
],
"commands": [
{
"path": "/app/node_modules/.bin"
},
{
"cmd": "mkdir -p /app/node_modules/.cache"
},
{
"dest": "package-lock.json",
"src": "package-lock.json"
},
{
"dest": "package.json",
"src": "package.json"
},
{
"cmd": "npm install"
}
],
"inputs": [
{
"step": "packages:mise"
}
],
"name": "install",
"variables": {
"CI": "true",
"NODE_ENV": "production",
"NPM_CONFIG_FETCH_RETRIES": "5",
"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": [
"*"
]
},
{
"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.7.11"
}
],
"name": "packages:apt:runtime"
}
]
}
@@ -228,7 +228,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -228,7 +228,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
@@ -181,7 +181,7 @@
"src": "package.json"
},
{
"cmd": "npm ci"
"cmd": "npm install"
}
],
"inputs": [
-2
View File
@@ -33,8 +33,6 @@ var (
// 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"}
)
+12 -7
View File
@@ -128,12 +128,17 @@ func (p PackageManager) installDeps(ctx *generate.GenerateContext, install *gene
switch p {
case PackageManagerNpm:
hasLockfile := ctx.App.HasFile("package-lock.json")
if hasLockfile {
install.AddCommand(plan.NewExecCommand("npm ci"))
} else {
install.AddCommand(plan.NewExecCommand("npm install"))
if !ctx.App.HasFile("package-lock.json") {
ctx.Logger.LogSuggestion("Add a `package-lock.json` for more deterministic installs", "/architecture/recommendations")
}
// ideally, `npm ci` should be used instead of `npm install`, but we default to npm install to avoid build failures
// https://github.com/railwayapp/railpack/pull/643
installCmd := "npm install"
if customInstallCmd, _ := ctx.Env.GetConfigVariable("NODE_NPM_INSTALL"); customInstallCmd != "" {
installCmd = customInstallCmd
}
install.AddCommand(plan.NewExecCommand(installCmd))
case PackageManagerPnpm:
install.AddEnvVars(map[string]string{
"PNPM_HOME": PNPM_HOME,
@@ -162,10 +167,10 @@ func (p PackageManager) installDeps(ctx *generate.GenerateContext, install *gene
install.AddCommand(plan.NewExecCommand("pnpm add -g node-gyp"))
}
hasLockfile := ctx.App.HasFile("pnpm-lock.yaml")
if hasLockfile {
if ctx.App.HasFile("pnpm-lock.yaml") {
install.AddCommand(plan.NewExecCommand("pnpm install --frozen-lockfile --prefer-offline"))
} else {
ctx.Logger.LogSuggestion("Add a `pnpm-lock.yaml` for more deterministic installs", "/architecture/recommendations")
install.AddCommand(plan.NewExecCommand("pnpm install"))
}
case PackageManagerBun:
@@ -5,11 +5,26 @@ import (
"path/filepath"
"testing"
"github.com/railwayapp/railpack/core/plan"
"github.com/railwayapp/railpack/core/resolver"
testingUtils "github.com/railwayapp/railpack/core/testing"
"github.com/stretchr/testify/require"
)
func TestInstallDeps_NpmInstallOverride(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, "package-lock.json"), []byte("{}"), 0o644))
ctx := testingUtils.CreateGenerateContext(t, tmpDir)
ctx.Env.Variables["RAILPACK_NODE_NPM_INSTALL"] = "npm ci"
install := ctx.NewCommandStep("install")
PackageManagerNpm.installDeps(ctx, install, false)
require.Contains(t, install.Commands, plan.NewExecCommand("npm ci"))
}
func TestGetPackageManagerPackages_PnpmLockfileVersion(t *testing.T) {
tests := []struct {
name string
@@ -115,3 +115,63 @@ locked = true
Commit the generated `mise.lock` file alongside your `mise.toml`. Railpack
automatically includes `mise.lock` files in the build when present.
## Commit Package Manager Lockfiles
Always generate and commit a lockfile from your package manager
(`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`,
`poetry.lock`, etc.), and keep it in sync with your dependency manifests.
Without a lockfile (or with one that has drifted) installs resolve versions
at build time. That leads to non-deterministic images and therefore possible
bugs that don't occur locally.
## Prefer `npm ci` for npm Node Projects
When using npm as your package manager, set `RAILPACK_NODE_NPM_INSTALL` to
opt into `npm ci` instead of the default `npm install`:
```bash
RAILPACK_NODE_NPM_INSTALL="npm ci"
```
You can also customize the install command in `railpack.json` if you need more
control over the install step:
```json
{
"$schema": "https://schema.railpack.com",
"steps": {
"install": {
// Configuring a step auto-adds it to deploy with include: ["."]. Use an empty
// deployOutputs so install is not copied into the final image; node_modules
// still reach the image via the build step.
"deployOutputs": [],
"commands": [
// auto-generated commands from railpack
{ "path": "/app/node_modules/.bin" },
{ "src": "package-lock.json", "dest": "package-lock.json" },
{ "src": "package.json", "dest": "package.json" },
// by default, `npm install` is used here; use `npm ci` for increased determinism
"npm ci"
]
}
}
}
```
Railpack defaults to `npm install` because `package.json` and
`package-lock.json` often drift out of sync — usually from npm bugs rather than
intentional local changes. That mismatch rarely shows up in development and only
fails at image build time with:
> `npm ci` can only install packages when your package.json and package-lock.json
> or npm-shrinkwrap.json are in sync.
Using `npm install` avoids build failures at the cost of weaker
determinism. You should keep your lockfile in sync and opt into `npm ci` for:
- **Deterministic installs** — exact versions from the lockfile, matching local
and CI environments, with no silent drift in production
- **Faster installs** — `npm ci` installs from the lockfile instead of resolving
`package.json` again
+1
View File
@@ -92,6 +92,7 @@ Railpack determines the start command in the following order:
| `RAILPACK_NO_SPA` | Disable SPA mode | `true` |
| `RAILPACK_SPA_OUTPUT_DIR` | Directory containing built static files | `dist` |
| `RAILPACK_PRUNE_DEPS` | Remove development dependencies | `true` |
| `RAILPACK_NODE_NPM_INSTALL` | Custom npm install command | `npm ci` |
| `RAILPACK_NODE_PRUNE_CMD` | Custom command to prune dependencies | `npm prune --omit=dev --ignore-scripts` |
| `RAILPACK_NODE_INSTALL_PATTERNS` | Custom patterns to install dependencies | `prisma` |
| `RAILPACK_ANGULAR_PROJECT` | Name of the Angular project to build | `my-app` |
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://schema.railpack.com",
"steps": {
"install": {
// Railpack defaults to `npm install` so out-of-sync lockfiles still succeed.
// Opt into `npm ci` when the lockfile is kept in sync for deterministic installs.
//
// Configuring a step auto-adds it to deploy with include: ["."]. Use an empty
// deployOutputs so install is not copied into the final image; node_modules
// still reach the image via the build step.
"deployOutputs": [],
"commands": [
{ "path": "/app/node_modules/.bin" },
{ "src": "package-lock.json", "dest": "package-lock.json" },
{ "src": "package.json", "dest": "package.json" },
"npm ci"
]
}
}
}
@@ -0,0 +1,9 @@
# Out-of-sync npm lockfile
This example intentionally keeps `package-lock.json` out of sync with
`package.json` to reproduce an `npm ci` failure. The lockfile was generated
with only `dayjs` installed; `is-number` was then added to `package.json`
without regenerating the lockfile.
Do not run `npm install` in this directory unless you are recreating the
out-of-sync fixture.
@@ -0,0 +1,4 @@
import dayjs from "dayjs";
import isNumber from "is-number";
console.log(`npm dependencies loaded: ${dayjs("2026-07-21").format("YYYY-MM-DD")} ${isNumber(42)}`);
+21
View File
@@ -0,0 +1,21 @@
{
"name": "npm-out-of-sync-package-lock",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "npm-out-of-sync-package-lock",
"version": "1.0.0",
"dependencies": {
"dayjs": "1.11.13"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
}
}
}
@@ -0,0 +1,13 @@
{
"name": "npm-out-of-sync-package-lock",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"dayjs": "1.11.13",
"is-number": "7.0.0"
}
}
@@ -0,0 +1,7 @@
[
{
"expectedOutput": "npm dependencies loaded: 2026-07-21 true",
// NPM_CONFIG_PRODUCTION currently emits a deprecation warning during installation.
"stderrAllowed": true
}
]