A pipeline that installs the Salesforce CLI with npm install --global @salesforce/cli@latest on a Node 20 runner still exits 0. The install step goes green, the failure lands somewhere further down the job, and the only early signal was an EBADENGINE warning that most CI log viewers fold away. Since v2.147.7 the CLI declares a Node floor of >=22.0.0, and Node 18 and 20 are gone as supported runtimes. If you pin a Node version anywhere — runner image, Dockerfile, .nvmrc, a self-hosted agent someone built two years ago — this is a maintenance pass worth doing on a quiet Tuesday rather than during a release window.
What changed, and in which release
Salesforce CLI v2.147.7 shipped on 12 August 2026 with this note: "Salesforce CLI now requires Node.js version 22 or later. We've dropped support for Node.js 18 and 20, which reached end-of-life. The bundled Node.js runtime in the Salesforce CLI installers is now version 24."
That shows up in the package metadata rather than in any error handler. The version currently under npm's latest tag, 2.149.9 (26 August 2026), carries:
{
"engines": {
"node": ">=22.0.0"
}
}
The main branch of the CLI repo, 2.151.4 at the time of writing, declares the same floor. It also maps both commands to a single entry point:
{
"bin": {
"sf": "./bin/run.js",
"sfdx": "./bin/run.js"
}
}
So if part of your build still calls sfdx because a script from 2022 never got rewritten, that path sits under exactly the same Node requirement. There is no older, gentler binary hiding behind the alias.
Keep the installers separate in your head. Release v2.112.6 (12 November 2025) announced the plan: "Starting February 2026, the Salesforce CLI installer for Windows, macOS, and Linux tarballs will start bundling Node.js v24 (Current LTS) instead of Node.js v22 (Maintenance LTS)." The v2.147.7 note confirms that switch has since happened. Installers carry their own Node. The npm package borrows yours.
Why this breaks quietly
I went looking for the guard clause in bin/run.js and src/cli.ts on the main branch. There isn't one. Neither file checks process.version or compares a major number; src/cli.ts touches process.versions.node only to report it as version and debug information. No "your Node is too old" message exists to be printed.
Enforcement lives entirely in the engines field, and npm's engine-strict config defaults to false. From npm's own documentation: "If set to true, then npm will stubbornly refuse to install (or even consider installing) any package that claims to not be compatible with the current Node.js version." Default false means an unsatisfied engines field produces an EBADENGINE warning, not a refusal.
Put those two together and a Node 20 runner installs a CLI that has explicitly declared it does not support Node 20, reports success, and carries on. What that CLI then does at runtime on Node 20 is unconfirmed — I have not verified it, and I am not going to invent an error string for you. The narrower, more useful statement: you will not get a clear version error. EBADENGINE at install time is the only honest early signal, and by default it is a warning in a collapsed log group. I have watched a team spend most of a morning chasing a "flaky deploy" that was a folded-away EBADENGINE line from six steps earlier.
Audit on the runner, not on your laptop:
node -v
npm ls -g --depth=0 @salesforce/cli
sf --version
sf --version reports the CLI version alongside the Node version it is actually running under, which is the pairing you care about. Run it as a real step in the real job, on the real image.
The three install paths are not equally exposed
| Install path | Whose Node runs it | Affected by the >=22.0.0 floor | How you pin it |
|---|---|---|---|
npm install --global @salesforce/cli |
the runner's or laptop's Node | Yes, directly | .nvmrc, setup-node, or base image — plus an exact CLI version |
| Platform installer or tarball | the Node bundled with the CLI (now v24) | No, the runtime is self-contained | pin the installer or tarball version you download |
| Container image with the CLI baked in | the image's Node | Yes, but only when you rebuild | pin the image by digest and rebuild deliberately |
My position: if you do not want to repeat this exercise in eighteen months, stop installing the CLI from npm onto a host Node that other things also depend on. Use the installer or tarball with its bundled runtime, or bake a specific CLI version into a container image pinned by digest. The npm-global-on-host-Node path is the only one that couples your Salesforce tooling to an unrelated Node upgrade decision made by someone else.
The trade-off is real. A bundled runtime is a heavier install step, a second thing to keep patched, and harder to express in a lockfile. That cost is smaller than a silent version-floor failure discovered mid-release.
Bumping without another surprise
Target Node 24, not 22. Both satisfy >=22.0.0, but 24 is what Salesforce bundles in its own installers, which makes it the runtime their CLI gets exercised against hardest.
echo "24" > .nvmrc
In GitHub Actions that means actions/setup-node reading node-version-file: .nvmrc rather than a hardcoded node-version: 20, so the two can never drift apart. In a Dockerfile it means moving the base to something like node:24-bookworm-slim. Whichever mechanism you use, put the version in one place and have everything else read from it.
Then make the check loud, because npm will not:
printf 'engine-strict=true\n' >> .npmrc
And add a guard that fails the build with a sentence a tired person can act on:
#!/usr/bin/env bash
set -euo pipefail
major="$(node -p 'process.versions.node.split(".")[0]')"
if (( major < 22 )); then
echo "Salesforce CLI requires Node >=22.0.0 — this runner is on $(node -v)" >&2
echo "Bump the runner image or .nvmrc, then re-run." >&2
exit 1
fi
npm install --global "@salesforce/cli@${SF_CLI_VERSION:?set SF_CLI_VERSION}"
sf --version
Pin SF_CLI_VERSION to an exact version rather than @latest. On a shared runner, @latest means your build changes whenever Salesforce publishes — which is exactly how a version floor arrives without anyone deciding to accept it.
While you are in there: the SourceMember hang
There is a second reason to move your pin forward rather than to the oldest release that satisfies engines. From v2.146.3 (5 August 2026): "Source tracking now updates correctly after deploying platform events, big objects, external objects, or custom metadata types. Previously, the CLI would hang and eventually time out with the warning 'Polling for N SourceMembers timed out.'"
If you deploy any of those four metadata types into a tracked org — a scratch org in CI, or a sandbox with source tracking enabled — a deploy like this one on a CLI older than 2.146.3 could sit there until it gave up:
sf project deploy start \
--source-dir force-app/main/default/objects/ScanRecorded__e \
--source-dir force-app/main/default/customMetadata \
--target-org rc-scratch \
--wait 20
The metadata itself deploys fine. It is the source-tracking poll afterwards that stalls, which is why it reads like an infrastructure problem and why the reflex is to retry the job. I have seen that misread as org slowness for long enough that someone opened a support case for it.
Any release from 2.146.3 onward carries the fix, and anything current is comfortably past it. So the version you pin should be a recent 2.149.x or later. Staying on an old CLI does not dodge the Node floor anyway — the floor is a property of the version you install, and the runner is what you have to move.
What to watch for
sfandsfdxresolve to the same./bin/run.js. Falling back tosfdxin a legacy script changes nothing about the Node requirement.- Do not expect a readable version error on an old runner. There is no runtime guard, the behaviour on Node 20 is unconfirmed, and
EBADENGINEis the only signal you can rely on. engine-strict=trueapplies to every package installed under that.npmrc, not only the CLI. Turning it on in a repo with older dependencies converts existing warnings into failed installs. Scope it to the CI install step first, and budget time for a couple of unrelated packages if you set it repo-wide.- An installer-based CLI on a Node 20 host is genuinely unaffected by the floor, but an installer artefact you cached before February 2026 predates the Node 24 switch. Re-download rather than reuse a stale one.
- Long-lived self-hosted runners and devcontainers are where old Node survives longest. Check
node -vinside the image during the job, not in your own terminal.
Leave a Comment