Skip to main content
New tool CRON Expression Builder — preview next run times before you schedule Apex. Open the builder →
Salesforce CLI Node 22 requirement failing a CI runner still pinned to Node 20
DevOps

Salesforce CLI Node 22: Fix CI Runners Before They Break

Salesforce CLI v2.147.7 drops Node 18 and 20 and declares a floor of >=22.0.0, but there is no runtime guard and npm only warns, so pinned runners fail quietly instead of loudly. Here is how to audit your install path, bump it safely, and pick up the v2.146.3 source-tracking fix in the same pass.

The short answer

Salesforce CLI v2.147.7 (12 August 2026) raised its engines floor to Node.js >=22.0.0 and dropped Node 18 and 20 as end-of-life, so any npm-installed CLI on an older runner Node is now unsupported. Because there is no runtime version guard and npm's engine-strict defaults to false, the only early signal is an EBADENGINE warning at install time.

Key takeaways Bump pinned runner Node to 24 rather than the bare minimum 22 — it is the version Salesforce bundles in its own installers. Add engine-strict=true to the CI .npmrc and a shell guard on the Node major version, because npm will not fail the install on its own. Pin an exact @salesforce/cli version instead of @latest so a Salesforce release never changes your build without a decision. Move the pin to 2.146.3 or later to clear the 'Polling for N SourceMembers timed out.' hang after deploying platform events, big objects, external objects or custom metadata types. To stop chasing Node floors, install via the platform installer or tarball with its bundled runtime, or bake the CLI into a digest-pinned image.

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

  • sf and sfdx resolve to the same ./bin/run.js. Falling back to sfdx in 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 EBADENGINE is the only signal you can rely on.
  • engine-strict=true applies 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 -v inside the image during the job, not in your own terminal.

Originally reported by raw.githubusercontent.com

Frequently asked questions

Which Salesforce CLI version requires Node 22?

v2.147.7, released 12 August 2026. Its release note states that the CLI now requires Node.js 22 or later and that support for Node.js 18 and 20 has been dropped because they reached end-of-life.

Will npm block installing @salesforce/cli on Node 20?

Not by default. npm's engine-strict config defaults to false, so an unsatisfied engines field produces an EBADENGINE warning and the install still exits 0. Set engine-strict=true in .npmrc if you want the install to fail instead.

Do I need Node 22 to use the Salesforce CLI installer?

No. The Windows, macOS and Linux installers and tarballs bundle their own Node runtime, which is now version 24, so they are insulated from whatever Node the host has. Only the npm-installed package uses your machine's Node.

Does the Node 22 requirement also apply to sfdx?

Yes. The package.json bin block maps both sf and sfdx to the same entry point, ./bin/run.js, so both commands run identical code under the same engines floor of >=22.0.0.

How do I fix 'Polling for N SourceMembers timed out.' after deploying platform events?

Upgrade the CLI to v2.146.3 or later. That release fixed source tracking failing to update after deploying platform events, big objects, external objects, or custom metadata types, which previously caused the CLI to hang until the SourceMember poll timed out.

Newsletter

One email every Tuesday

New guides, tool updates, and the release-note changes that break things.

No spam. Unsubscribe in one click.

Comments

Loading comments...

Leave a Comment