Skip to content
Latchkey LogoLatchkey home

actions/setup-node node version file does not exist, and where it looked

When setup-node node version file does not exist, the action is telling you the absolute path it looked at, and that path was built by joining your input onto GITHUB_WORKSPACE. Read the path in the message: it is almost always right about the file and wrong about where you thought you had put it.

The path join, the parse order inside the file, and the three messages each step can produce
node-version-file is joined onto GITHUB_WORKSPACE, then parsed as package.json, then as TOML, then by one regular expression.

What this error means

The setup step fails before any Node is installed, naming a path under /home/runner/work that ends in your version file. A second and very different shape of this failure exists and is easy to confuse with it: the file is found, cannot be parsed into a version, and the action carries on with an empty version string, logging a warning most people scroll past. In that case the step goes red later, on the download, with a message about being unable to find a Node version, and the version it names is blank or is a range rather than a number.

Reconstructed from the throw in getNodeVersionFromFile, actions/setup-node src/util.ts (v7.0.0); the path is a runtime value
Error: The specified node version file at: /home/runner/work/app/app/.nvmrc does not exist

Where the path comes from

The input is never used as given. It is joined onto GITHUB_WORKSPACE, which is the checkout root of the job, and the result is what existsSync is asked about. Two consequences follow, and they account for most reports.

An absolute path does not survive the join in the way people expect, so pointing the input at something outside the checkout does not work; this has been open as a feature request on the action for years. And a job that has not run actions/checkout yet has an empty workspace, so the file is missing for the ordinary reason that nothing has been cloned.

actions/setup-node, src/main.ts (v7.0.0)
if (versionFileInput) {
  const versionFilePath = path.join(
    process.env.GITHUB_WORKSPACE!,
    versionFileInput
  );

  const parsedVersion = getNodeVersionFromFile(versionFilePath);

  if (parsedVersion) {
    version = parsedVersion;
  } else {
    core.warning(
      `Could not determine node version from ${versionFilePath}. Falling back`
    );
  }

  core.info(`Resolved ${versionFileInput} as ${version}`);
}

Common causes

setup-node runs before the checkout

The most common shape by a distance. Steps that read a repository file have to come after actions/checkout, and setup steps feel like preamble, so they drift to the top of the job. The workspace is empty at that point and any version file is missing.

The path is relative to the wrong root

In a monorepo the input is often written as if it were relative to the working directory of the job or of the step, and it is not: it is joined to the checkout root. packages/api/.nvmrc works, .nvmrc with a working-directory set on the step does not.

The file exists but yields nothing parseable

A package.json without engines.node, volta.node or a devEngines.runtime node entry yields nothing on purpose, and the action warns and falls back rather than failing. The run then breaks further down with an empty version in the installer message.

The file holds a range rather than a version

The final fallback in the parser takes the text almost verbatim, so >=20 or ^22 comes straight through. The installer is given that string as a version specification and reports that it cannot find a Node version for it, which reads like a network problem and is not.

How to fix it

Read the path in the message, then list it

  1. Take the absolute path out of the error; it is the joined path, not your input.
  2. Add a step before setup-node that lists the directory that path is in.
  3. If the directory is empty, the checkout is missing or is in another job.
  4. If the file is there, the failure is the parse rather than the path, and the warning line will say so.
.github/workflows/ci.yml (illustrative)
      - uses: actions/checkout@v7
      - run: ls -la "$GITHUB_WORKSPACE"
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc

Point the input at a path under the checkout root

Write the path as it appears from the top of the repository, and do not rely on working-directory to shift it. This is the fix for a monorepo, and it is also the version that keeps working when somebody later moves the job to a different directory.

.github/workflows/ci.yml (illustrative)
      - uses: actions/setup-node@v7
        with:
          node-version-file: packages/api/.nvmrc
          cache: npm
          cache-dependency-path: packages/api/package-lock.json

Put a plain version in the file, not a range

Whatever the file is, the value the action ends up with should be something the installer can resolve. A bare 22.11.0 or v22.11.0 in .nvmrc, or an exact string in engines.node, removes both the parse warning and the installer failure. If your package manifest has to carry a range for consumers, keep a separate .nvmrc for CI.

Turn the fallback warning into a failure

The action treats an unparseable file as a warning and continues, which is how an empty version reaches the installer. If you would rather know immediately, read the file yourself and pass the value through node-version, which fails at the step that owns the problem instead of two steps later.

.github/workflows/ci.yml (illustrative)
      - id: node
        run: echo "version=$(cat .nvmrc)" >> "$GITHUB_OUTPUT"
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ steps.node.outputs.version }}

What the action tries to read out of the file

The parser is not a simple read. It tries JSON first, and if the contents parse as an object it walks four places in order: volta.node, then a node entry in devEngines.runtime, then engines.node, then volta.extends, which recurses into another file. If none of those are present it gives up deliberately and stops, on the grounds that a file which parsed as JSON is not going to yield a version by any other route.

If the contents are not JSON it tries TOML, for mise.toml, and reads tools.node. Failing that it falls back to one regular expression over the raw text, which accepts an optional node or nodejs prefix, an optional v, and then everything up to the first space. Anything left over is trimmed and used as is.

That last fallback is generous enough to return something for almost any file, which is why the second failure mode exists. A version file containing a range, or a stray comment, parses to a string that is not a version, and the action hands it to the installer, which then reports that it cannot find a Node version matching it.

What the file containsWhat setup-node readsThe line you get
no file at the joined pathnothingThe specified node version file at ... does not exist
package.json with no node fieldJSON, then nothing, on purposeCould not determine node version from ...
.nvmrc holding v22.11.0the regular expressionResolved .nvmrc as v22.11.0
.nvmrc holding >=20the regular expression, verbatimUnable to find Node version '>=20' for platform ...
mise.toml with tools.nodethe TOML branchResolved mise.toml as the value there

One phrase this error is often reported under

A lot of writing about this failure quotes a message about not resolving a dist for the version file. That string does not appear anywhere in the action. Grepping the whole of actions/setup-node at v7.0.0 returns nothing for it, and searching issue bodies for it and then checking the fetched text returns nothing either: the results are all bundler errors about a JavaScript package with dist in its path, which is a different thing entirely.

The three messages the action really writes are the ones in the table. It is worth being precise about this, because looking for a phrase the tool never emits is a good way to spend an hour, and because a page that quotes a message nobody can find teaches its readers to distrust every other quote on it.

The installer message at the end of the table is worth recognizing on sight. It names the version specification it was given, so an empty pair of quotes in it means the file parsed to nothing, and a range in it means the file contained a range.

Why there is no recorded run on this page

The decision this page is about is one fs.existsSync call against a path the action prints in the message itself. A recorded run would show that path, which the quoted line already shows, and would add the one thing a log cannot make useful: a workspace directory listing from a machine that no longer exists. The reader needs the listing from their own job, and the fix below produces it in one step.

There is nothing transient here either. The file is absent or it is not, the join is deterministic, and running the step again reads the same empty directory. A runner that retried this would simply fail twice.

How to prevent it

  • Keep every setup step that reads a repository file below actions/checkout.
  • Write node-version-file relative to the repository root, always.
  • Keep an exact version in the file CI reads, and leave ranges to the manifest.
  • Treat the Could not determine node version warning as a failure in review, because the action will not.

Frequently asked questions

Where does setup-node look for node-version-file?
At your input joined onto GITHUB_WORKSPACE, the checkout root of the job. It is not relative to the step working directory and it does not search upwards. The error message prints the joined path, which is the fastest way to see what it actually checked.
Can node-version-file be an absolute path?
Not usefully. The action joins the input onto the workspace root before touching the filesystem, so a path outside the checkout is not reachable this way. Supporting absolute paths has been an open request on the action; until it lands, read the file in a step and pass the value to node-version.
Why does setup-node say it could not determine a node version?
Because the file was found and parsed to nothing. A package.json with no volta.node, no node entry in devEngines.runtime and no engines.node yields nothing deliberately. The action warns, keeps an empty version, and the run fails later in the installer instead.
Which files can setup-node read a version from?
Anything, in practice. It tries package.json fields first, then mise.toml via tools.node, then a regular expression over the raw text that accepts an optional node prefix and an optional v. .nvmrc, .node-version and .tool-versions all land in that last branch.

Related guides

References

Setup runs at the top of every matrix job. Latchkey runs them at $0.0025/min at 2 vCPU against $0.006. Start free → 30-day trial · No credit card