An action declaring using: node20 does not fail on a hosted runner after GitHub's 23 September 2026 removal date. It runs on Node 24 instead, silently, and the environment variable many teams set last June to control that is no longer read at all. What follows separates that forced upgrade from the three failures that genuinely produce exec /__e/node20/bin/node: no such file or directory, with one test per cause that you can run without waiting for a flag rollout.

The mechanism matters because it explains a run that changed behaviour on a day you shipped nothing. In the runner's NodeUtil.DetermineActionsNodeVersion, the requireNode24 feature-flag check is the first statement in the method, sitting above both environment-variable lookups. No opt-out is consulted, and the warning tuple comes back null, so there is no annotation either. That ordering lives in the source, not in the docs.

I checked this against three things rather than the documentation: the deprecation changelog GitHub published on 19 September 2025, actions/runner#4303 where the enforcement and the ARM32 kill switch landed, and NodeUtil.cs on main.

Prerequisites

  • A repository with GitHub Actions workflows and read access to a recent run log.
  • gh CLI 2.x authenticated (gh auth status), plus jq and grep.
  • For the container and self-hosted paths: shell access inside the job container, or to the runner host or ARC pod.
  • Runner v2.334.0 or newer on self-hosted fleets. PR #4303 merged 17 March 2026; the current release at the time of writing is v2.337.0, dated 26 August 2026.
  • You have not changed your workflows. If you did, revert first and re-read the failure.

Step-by-step

1. Read the removal date out of your own run log

gh run view <run-id> --log | grep -iE 'Node\.js (20|24)|node20 will be removed|Runner version'

September 23rd, 2026 is the value of Node20RemovalDate in src/Runner.Common/Constants.cs, and it is a fallback. The server can override it per job through the actions_runner_node20_removal_date job variable, which the runner interpolates into its own warning text. GitHub Enterprise Server and staged rollouts do exactly that, so two orgs can see different behaviour on the same afternoon. Whatever date the annotation prints governs your jobs.

2. Confirm the failure is not "the action stopped running"

If the log shows your action executing and then failing inside its own JavaScript, it ran, on Node 24. At job finalization the runner prints this from JobExtension.cs:

Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: <list>. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/

Nothing in that message mentions your opt-out, because the early return happens first:

if (requireNode24)
{
    return (Constants.Runner.NodeMigration.Node24, null);
}

The two GetEnvironmentVariableDetails calls for FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION sit below that block. Once actions.runner.requirenode24 is on for your job, neither is read, and the null second element means no warning is emitted about it. A green run after the removal date tells you nobody complained, not that your actions declare node24. The same failure mode as a CI pipeline that goes green and breaks later applies here.

3. Resolve every SHA-pinned action to the runtime it declares

SHA pins take this hardest. A tag like @v4 drifts to a maintainer's Node 24 release; a 40-character SHA never does. Enumerate the pins, then read runs.using at that exact commit:

grep -rhoE 'uses: [^ ]+@[0-9a-f]{40}' .github/workflows | sed 's/uses: //' | sort -u > /tmp/pins.txt

while IFS= read -r pin; do
  repo="${pin%@*}"; sha="${pin#*@}"
  using=$(gh api "repos/${repo%%/*}/${repo#*/}/contents/action.yml?ref=${sha}" \
    --jq '.content' 2>/dev/null | base64 -d | grep -m1 -oE 'node(16|20|24)')
  printf '%-60s %s\n' "$pin" "${using:-composite-or-docker}"
done < /tmp/pins.txt

Every row printing node20 is code written for Node 20 that now executes on Node 24. Most of it works. It breaks on removed globals, on punycode, on native addons compiled for the wrong ABI, and on heap behaviour, which is the same class of surprise behind Node 24.19.0 "Ineffective mark-compacts near heap limit". Re-pin to a newer SHA whose action.yml says node24. Do not unpin, for the reasons in Pin GitHub Actions by SHA: 10 Gaps the Pin Leaves Open.

4. Split the /__e/node20 ENOENT into its real causes

exec /__e/node20/bin/node:no such file or directoryls /__e/node20/bin/nodeinside the containerls /runner/externalson the host or podBinary exists, exec still failsthe ELF interpreter is missingRunner build shippedwithout the node20 treeMount gapARC externals volume not mappedreadelf -l showsld-linux-x86-64.so.2musl containerAlpine or distrolessArchitecture mismatcharm64 image on x64 runner"missing""present""also missing""present""loader absent""loader present"

Run the checks in that order, inside a failing container job:

ls -l /__e/node20/bin/node
readelf -l /__e/node20/bin/node | grep -A1 'INTERP'
ls -l /lib64/ld-linux-x86-64.so.2 /lib/ld-musl-x86-64.so.1 2>&1
file /__e/node20/bin/node

Branch D is the one that wastes the most time. Linux reports no such file or directory from execve when the interpreter named in the ELF header is absent, not only when the binary is. The runner mounts its glibc node build into an Alpine container at /__e, Alpine ships ld-musl-x86-64.so.1, and the kernel's ENOENT names a binary you can plainly see with ls. This predates the deprecation by years: actions/runner#3239 and actions-runner-controller#3425 are both this shape. A run failing this way on 23 September may have nothing to do with Node 20. Container jobs hide this class of problem well, in the same way a missing module inside a Docker build points at the wrong layer until you check what was actually copied in.

5. Check whether your runner tarball still contains node20

ls ~/actions-runner/externals/
# expect: node20  node24   (plus node20_alpine / node24_alpine on Alpine runners)

In src/Misc/externals.sh on actions/runner main today, NODE20_VERSION="20.20.2" and NODE24_VERSION="24.21.0" are both still downloaded for every platform. The removal is a server-side feature-flag flip first; the binary leaves the tarball in a later release. If node20/ is absent on a runner that still resolves node20, upgrade the runner and let it repopulate externals, then confirm ARC maps the volume instead of leaving /runner/_work/externals empty.

6. Handle the one hard throw: Linux ARM32

Linux ARM32 runners are no longer supported. Please migrate to a supported platform.

That string, gated by actions_runner_kill_linux_arm32, is the single place in this code path that stops a job outright. HandlerFactory.cs calls NodeUtil.CheckNodeVersionForLinuxArm32 and, on a null result, does both executionContext.Error(platformWarningMessage) and throw new InvalidOperationException(platformWarningMessage). Before the kill switch, actions_runner_deprecate_linux_arm32 only warns and keeps the job on node20. There is no fix inside Actions: the platform support table in nodejs/node's BUILDING.md downgraded armv7 to Experimental as of Node.js 24, so no Node 24 binary exists to run. Move those jobs to arm64 or x64.

7. macOS 13.4 and older

The same nodejs/node table sets the macOS floor at >= 13.5 for x64 and arm64 alike. A self-hosted macOS 13.4 runner cannot execute the Node 24 it is now required to use. Upgrade the OS; no runner-side flag changes this.

Verify it works

Force the end state on a branch and see what moves:

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

Push, then read the failed steps back:

gh run view <run-id> --json jobs \
  --jq '.jobs[].steps[] | select(.conclusion=="failure") | {name, conclusion}'

Green with that variable set means the forced upgrade is not your failure and you should work causes 4 through 7. Red only with it set names the exact actions that break on Node 24, and that list is your re-pin queue. Both runs are reproducible today, without waiting for the flag to reach your org.

Common pitfalls

  • Treating the changelog date as authoritative. It is a default. actions_runner_node20_removal_date overrides it per job.
  • Assuming silence means safety. The requireNode24 early return yields a null warning, so a clean log proves only that nothing complained.
  • Unpinning to "get the fix". Dropping to @v4 restores tag drift and reopens the mutation gap the pin was there to close. Re-pin to a newer SHA.
  • Adding apt-get install nodejs to an Alpine job container. That installs a node the runner never calls, because it executes the binary at /__e. Use a glibc base image, or make the runner itself Alpine so it mounts node24_alpine.
  • Reading no such file or directory as a missing file. Run readelf -l before concluding anything.
  • Leaving ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION in your workflows. It is inert now. Delete it so the next engineer does not believe it is holding something back.

FAQ

Do node20 actions fail on hosted runners after 23 September 2026? No. With actions.runner.requirenode24 on, the runner returns node24 before reading any environment variable, so the action runs on Node 24 with no warning and no opt-out.

Why did ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION stop working? DetermineActionsNodeVersion returns early on requireNode24, above both GetEnvironmentVariableDetails calls. The variable is never read, and nothing annotates that it expired.

Is 23 September 2026 really my date? It is the hardcoded fallback in Constants.cs. The server can override it per job, and the runner prints the effective date in its warning, so read your own log.

Why does the node binary exist but Linux still says no such file or directory? ENOENT from execve usually means the ELF interpreter is missing. An Alpine container gets the glibc node20 mounted at /__e with no /lib64/ld-linux-x86-64.so.2 to load it.

What breaks on macOS 13.4? Node 24 requires macOS 13.5 or newer per the nodejs/node support table, so a 13.4 self-hosted runner cannot execute the runtime it is now forced onto.

Wrap-up

You now have the effective removal date from your own log, an inventory of every SHA-pinned action and the runtime it declares, and a four-branch test that tells a forced Node 24 upgrade apart from a missing ELF interpreter, an unmapped externals volume, the ARM32 kill switch, and an unsupported macOS.

The next move is a tripwire rather than a fix. Put the runs.using scan from step 3 into CI as a scheduled weekly job that fails when any pin still reports node20, and delete that gate the day the scan comes back clean. Also diary the ARM32 timing: PR #4303 landed with October 2026 as a placeholder rather than a decision, so recheck actions_runner_kill_linux_arm32 in the runner source before you plan any armv7 migration around a date.