Skip to content

fix(npm): raise engines.node floor to 24.15.0 so npm ci resolves - #226

Merged
guarzo merged 1 commit into
mainfrom
worktree-fix-npm-engines-node-floor
Aug 24, 2026
Merged

fix(npm): raise engines.node floor to 24.15.0 so npm ci resolves#226
guarzo merged 1 commit into
mainfrom
worktree-fix-npm-engines-node-floor

Conversation

@guarzo

@guarzo guarzo commented Aug 24, 2026

Copy link
Copy Markdown
Owner

What changed and why

npm ci failed on a clean checkout with EUSAGE, naming a version that appears
nowhere in package-lock.json:

npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json or npm-shrinkwrap.json are in sync.
npm error Missing: esbuild@0.28.2 from lock file
npm error Missing: @esbuild/aix-ppc64@0.28.2 from lock file
... (+25 more @esbuild/* platform packages, all 0.28.2)

…while npm install --package-lock-only --dry-run said up to date in 1s.

The lockfile is not at fault and is unchanged in this PR.

vitest's nested vite@8.2.2 declares esbuild as an optional peer:

node_modules/vitest/node_modules/vite  8.2.2
  peerDependencies:     { "esbuild": "^0.27.0 || ^0.28.0" }
  peerDependenciesMeta: { "esbuild": { "optional": true } }

Nothing in the lock satisfies that edge at that location — top-level esbuild is
0.25.12, and the 0.28.1 is nested under tsx. That is correct: an unsatisfied
optional peer should be skipped. Older npm instead resolves it against the
registry (arborist's loadPeerSet), picks up whatever is current — 0.28.2 —
and then rejects the lock. That is also the install-vs-ci contradiction: the two
commands build the tree by different paths and disagree about optional peers.
npm does not document this difference; npm-ci's docs never mention optional
peers.

Fixed upstream by npm/cli#8981 (npm
11.10.1, peerOptional resolution in buildIdealTree) and
npm/cli#9083 (npm 11.11.1, ci counting
the lockfile's optional deps as missing). Node 24.15.0 is the first 24.x
bundling npm ≥ 11.11.1
(it ships 11.12.1).

This regressed at 7142704 (vitest 3.2.7 → 4.1.10), which took vite 7.3.6 → 8.
vite 7 had esbuild as a hard dependency and the lock carried
node_modules/vite/node_modules/esbuild@0.28.1; vite 8 made it an optional
peer, and the entry correctly went away.

.nvmrc deliberately stays bare 24. The repo pins by major and
scripts/check-node-version.sh compares majors only, so it still passes —
pinning .nvmrc to a minor is a known way to break every CI job at npm ci.

What CI cannot check

CI was never broken by this — that is the main thing to know. .nvmrc is
bare 24, so setup-node floats to the newest 24.x. The last green run on
another PR:

node: v24.19.0
added 280 packages, and audited 281 packages in 8s

Only environments on an old-but-in-range Node 24 were affected, which
engines.node: ">=24" permitted. Reproduced on node v24.0.0 / npm 11.3.0.

The lockfile is already canonical. Regenerated with CI's npm on a pristine
clone:

$ npm --version
11.17.0
$ npm install --package-lock-only
$ cmp package-lock.json ../lock.orig && echo IDENTICAL
IDENTICAL

Lockfile diff review — the trap. "Just regenerate the lock" is the wrong
fix. Regenerating under the affected npm 11.3.0:

 package-lock.json | 603 ++++++++++++++++++++++++++++++++++--------
 1 file changed, 513 insertions(+), 90 deletions(-)

node_modules/vitest/node_modules/esbuild            0.28.2   ← new
node_modules/vitest/node_modules/@esbuild/*@0.28.2  ×26      ← new

That commits 27 packages CI does not install, to satisfy a peer that is
optional. Hence: no lockfile change here, and an explicit "don't" in .npmrc.

Why 24.15.0 and not the empirical boundary. Measured npm ci --dry-run
against this exact lockfile, pristine clone per run:

npm ships with Node result
11.3.0 24.0.0–24.2.0 FAIL (27 missing)
11.4.2 24.3.0–24.4.1 FAIL
11.5.1 / 11.5.2 24.5.0–24.7.0 PASS
11.6.0 24.8.0–24.9.0 PASS
11.6.1 / 11.6.2 24.10.0–24.13.0 FAIL (26 missing)
11.7.0 – 11.13.0 24.13.1+ PASS
11.17.0 24.19.0 PASS ← CI

Today's lock in fact survives down to 24.13.1, but the passing range is not
contiguous
(11.5.x/11.6.0 pass, 11.6.1/11.6.2 fail), so that boundary is
incidental to this tree shape — and dependabot reshapes it weekly. The floor is
set where npm documents the fix instead. Verified none of the passing npms
silently deviate from the lock (no 0.28.2 in any install plan).

The fix produces the intended failure. Same tree, node v24.0.0:

npm error code EBADENGINE
npm error Required: {"node":">=24.15.0"}
npm error Actual:   {"npm":"11.3.0","node":"v24.0.0"}

.npmrc already had engine-strict=true, so this is a hard error, not a
warning — and it names Node instead of a phantom esbuild version.

Docker gate. .npmrc is COPYed before npm ci in both stages with
engine-strict on, so a floor above the base image would break the image build.
node:24-alpine resolves to v24.19.0; docker build run three times across
the edits, all exited with code 0.

Full suite, node 24.19.0 locally:

$ bash scripts/check-node-version.sh
node version OK: Dockerfile=24 .nvmrc=24 engines='>=24.15.0'

$ npm run format:check   → All matched files use Prettier code style!
$ npm run typecheck      → tsc --noEmit, clean
$ npm run lint           → eslint ., clean
$ npm test               → Test Files 99 passed (99) / Tests 1582 passed (1582)
$ npm run build          → 20 routes, all ƒ (Dynamic)
$ npx playwright test    → 409 passed, 1 failed

The one e2e failure is e2e/not-found.spec.ts:229 on
expect(seen.focused).toBe(true) — the documented ~40% pre-existing flake, not
this change. Alternated branch/base runs of the filtered spec, --repeat-each=6
each, per docs/e2e-flake-triage.md:

branch(>=24.15.0) run1 ::  2 failed  4 passed
base  (>=24)      run1 ::  1 failed  5 passed
branch(>=24.15.0) run2 ::  1 failed  5 passed
base  (>=24)      run2 ::  6 passed

Both sides fail; indistinguishable. Mechanically a JSON engines string cannot
affect a focus ring.

Deploy notes

None. No secret, no migration, no fly.toml change. The engines floor is
enforced at npm ci time only; node:24-alpine is already v24.19.0, so the
image build and the db:migrate release command are unaffected — verified by
building the image.

Flags

  • The premise that CI was blocked was wrong, and that is worth knowing
    before anyone treats this as urgent: every job was green throughout. This is
    a local-developer-experience fix plus a correctness fix to an understated
    floor.
  • docs/ops.md and the Dockerfile comment both stated the old >=24 and
    were invalidated by this diff. Both updated. The Dockerfile comment now
    states "the engines floor" without repeating a value, so it cannot re-drift on
    the next bump.
  • npm has an open regression in the same area
    npm/cli#9846, affecting 11.16.0 and
    12.0.2 for unavailable optional deps. It prints an empty version rather than
    a concrete one, so it is a different variant, and CI's 11.17.0 is unaffected
    today. Worth remembering if a similar EUSAGE reappears: raising the floor
    further would not necessarily help.
  • Out of scope, deliberately: the not-found focus-ring flake (own change,
    per the triage doc) and the four moderate npm audit advisories that
    npm ci reports.

`npm ci` failed on a clean checkout with EUSAGE, naming a version that
appears nowhere in package-lock.json:

    npm error Missing: esbuild@0.28.2 from lock file
    ... (+26 @esbuild/* platform packages, all 0.28.2)

while `npm install --package-lock-only --dry-run` reported "up to date".

The lockfile is not at fault and is unchanged here. `vitest`'s nested
`vite@8.2.2` declares esbuild as an *optional* peerDependency
(`^0.27.0 || ^0.28.0`, `peerDependenciesMeta.esbuild.optional = true`),
and nothing in the lock satisfies that edge at that location — which is
correct, because an unsatisfied optional peer should be skipped. Older
npm instead resolves it against the registry (arborist's `loadPeerSet`),
picks up whatever is current, then rejects the lock as out of sync. That
is also the install-vs-ci contradiction: the two commands build the tree
by different paths and disagree about optional peers.

Fixed upstream by npm/cli#8981 (npm 11.10.1) and npm/cli#9083
(npm 11.11.1). Node 24.15.0 is the first 24.x bundling npm >= 11.11.1.

CI was never affected: `.nvmrc` is bare `24`, so setup-node floats to
v24.19.0 / npm 11.17.0. The failure only reached environments sitting on
an old-but-in-range Node 24, which `engines.node: ">=24"` permitted.
Regenerating the lockfile under npm 11.17.0 reproduces it byte for byte;
regenerating under an affected npm instead commits esbuild 0.28.2 and 26
platform packages that CI does not install.

`.nvmrc` deliberately stays bare `24` — the repo pins by major, and
scripts/check-node-version.sh compares majors only, so it still passes.

Also corrects two references the new floor invalidated: the Dockerfile
comment that quoted "node >=24" (now states the floor without repeating
a value that can drift) and the docs/ops.md local-dev requirement.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 8 minutes.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 82 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7b860b2e-7a2d-40ff-9a32-267eba49ca83

📥 Commits

Reviewing files that changed from the base of the PR and between 5ec871a and 75a98a5.

📒 Files selected for processing (4)
  • .npmrc
  • Dockerfile
  • docs/ops.md
  • package.json

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo merged commit 79d1ea6 into main Aug 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant