How to check node version of a project

Read all four declarations, not one. node -v gives the runtime in use, npm pkg get engines the range the package requires, .nvmrc the version a manager selects, and the CI workflow the version the pipeline installs. In the demo project below they read 22.23.2, a range ending at 21, 20.11.1 and 22.

Why check this

A project does not have a Node version, it has several declarations of one, and they drift apart independently. The runtime on your machine is set by whatever a version manager last selected. engines is edited when a dependency demands it. .nvmrc is edited when a developer upgrades. The CI workflow is edited when a runner image is deprecated.

Run this when you pick up an unfamiliar repository, and again when a test passes locally and fails in CI. The failure it prevents is the one that produces no useful message: a syntax or API difference between two Node majors surfaces as a stack trace inside a dependency, and the pipeline log never mentions a version at all.

Prerequisites

mkdir versiondemo && cd versiondemo && mkdir -p .github/workflows
printf '{\n  "name": "versiondemo",\n  "version": "1.0.0",\n  "private": true,\n  "engines": { "node": ">=18 <21" }\n}\n' > package.json
printf '20.11.1\n' > .nvmrc
printf "name: ci\non: [push]\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '22'\n      - run: npm ci\n" > .github/workflows/ci.yml
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { execSync } from 'node:child_process';

const say = (where, value) => console.log(where.padEnd(22) + ' ' + value);
const read = (f) => (existsSync(f) ? readFileSync(f, 'utf8').trim() : '(absent)');

say('installed node', process.version);
say('installed npm', execSync('npm -v').toString().trim());
const pkg = existsSync('package.json') ? JSON.parse(readFileSync('package.json', 'utf8')) : {};
say('package.json engines', pkg.engines?.node ?? '(absent)');
say('.nvmrc', read('.nvmrc'));
say('.node-version', read('.node-version'));

const dir = '.github/workflows';
const files = existsSync(dir) ? readdirSync(dir) : [];
for (const f of files) {
  for (const line of readFileSync(dir + '/' + f, 'utf8').split('\n')) {
    const m = /node-version:\s*'?"?([^'"\s]+)/.exec(line);
    if (m) say(dir + '/' + f, m[1]);
  }
}
if (files.length === 0) say(dir, '(absent)');

Steps

  1. Step 1.

    Read the runtime that is going to execute the code right now.

    node -v
    
    v22.23.2

    This is the only one of the four numbers that describes a fact rather than an intention.

  2. Step 2.

    Read the range the package declares.

    npm pkg get engines
    
    {
    "node": ">=18 <21"
    }

    npm pkg get parses the manifest, so it answers {} rather than an error when the field is absent.

  3. Step 3.

    Read what a version manager would select.

    cat .nvmrc
    
    20.11.1

    nvm, fnm and asdf read this file. npm does not, and neither does Node. Nothing enforces it until somebody types nvm use.

  4. Step 4.

    Read what the pipeline installs.

    grep -n "node-version" .github/workflows/ci.yml
    
    10:          node-version: '22'

    This is the number that decides whether the build goes green, and it is the one nobody looks at.

  5. Step 5.

    Collect all four in one pass with the script from Prerequisites.

    node node-version-sources.mjs
    
    installed node         v22.23.2
    installed npm          10.9.8
    package.json engines   >=18 <21
    .nvmrc                 20.11.1
    .node-version          (absent)
    .github/workflows/ci.yml 22

    Four declarations, three different answers, and the running version satisfies none of them. The same script on the h2check repository prints >=22 and (absent) three times, which is a different kind of finding: nothing states what CI installs.

  6. Step 6.

    Ask npm what it makes of the mismatch.

    npm install --no-audit --no-fund
    
    npm warn EBADENGINE Unsupported engine {
    npm warn EBADENGINE   package: 'versiondemo@1.0.0',
    npm warn EBADENGINE   required: { node: '>=18 <21' },
    npm warn EBADENGINE   current: { node: 'v22.23.2', npm: '10.9.8' }
    npm warn EBADENGINE }
    
    up to date in 392ms

    A warning, and exit code 0. The install succeeded on a runtime the package says it does not support.

  7. Step 7.

    Turn the warning into a failure, then run the same install again.

    printf 'engine-strict=true\n' > .npmrc && npm install --no-audit --no-fund
    
    npm error code EBADENGINE
    npm error engine Unsupported engine
    npm error engine Not compatible with your version of node/npm: versiondemo@1.0.0
    npm error notsup Not compatible with your version of node/npm: versiondemo@1.0.0
    npm error notsup Required: {"node":">=18 <21"}
    npm error notsup Actual:   {"npm":"10.9.8","node":"v22.23.2"}
    npm error A complete log of this run can be found in: …

    Exit code 1. Commit that .npmrc and the mismatch stops the pipeline instead of reaching a test run.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | engines absent | The project states no requirement | Add the range you test against, then add engine-strict=true. | | .nvmrc outside the engines range | Two files disagree | Pick one number and derive the other from it. | | CI version outside the engines range | The pipeline tests a runtime the package rejects | Point actions/setup-node at node-version-file: .nvmrc. | | EBADENGINE warning, exit 0 | engines is advisory here | Expected without engine-strict. It is a note, not a gate. | | node -v differs from .nvmrc | No nvm use was run in this shell | Run it, or the numbers on this page describe someone else's machine. |

Common mistakes

Sign: engines is treated as enforcement because npm printed a warning about it.Cause: Default npm installs and then warns. The same package.json produced EBADENGINE as a warning with exit 0, and as an error with exit 1 only after engine-strict=true was written to .npmrc. Without that line the range documents an intention.
Sign: A .nvmrc is added and the version still does not change in CI.Cause: Nothing reads .nvmrc automatically. nvm, fnm and asdf read it when invoked, and actions/setup-node reads it only when given node-version-file. A workflow with a literal node-version ignores the file completely, which is how the demo ends up on 22 while .nvmrc says 20.11.1.
Sign: A CI step runs npm pkg get engines and passes on a project that declares nothing.Cause: With no engines field the command prints {} and exits 0. An absent requirement and a satisfied one look the same to a shell that only reads the exit code. Compare the printed value against a non-empty string before treating the step as a gate.

What to check next

FAQ

How do I check what node version a project is using?

node -v in the project directory, but that is the shell you are in, not the project. Read engines, .nvmrc and the CI workflow as well, as in step 5, and treat a disagreement between them as the finding.

Which of the four wins?

The runtime that executes the code. engines only blocks an install when engine-strict=true, .nvmrc applies when a manager reads it, and the CI value applies inside the pipeline. Everything else is documentation.

Does npm read .nvmrc?

No. It is a convention shared by nvm, fnm and asdf. Step 6 shows npm comparing the running runtime against engines and never mentioning the file, which held a different version at the time.

How do I check the Node version a CI job used?

Add node -v as the first step of the job, before the install. The workflow file states what was requested, and actions/setup-node resolves a range such as 22 to whatever minor the runner image carries.

Why does the demo fail its own engines range?

It is built that way, so the warning in step 6 has something to report. Node 22.23.2 sits above the upper bound the manifest declares. In a healthy project the installed version satisfies engines, and so does the version CI installs.

Verified

Verified by Maks Vernynode 22.23.2npm 10.9.8git bash grep 3.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic5 minpublished updated Maks Verny