GitHub Actions Node.js 20 support is scheduled for removal on September 23, 2026. Node.js 24 has been the default JavaScript action runtime since June 16, but a workflow can still depend on an old third-party or local action that declares runs.using: node20. Audit the complete action graph now, upgrade every incompatible action, and exercise the workflow with FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true before the removal date.
This migration does not change the version installed by actions/setup-node for your application. The runner’s JavaScript runtime executes actions such as checkout, setup, cache, and custom JavaScript actions; your project’s Node.js toolchain is a separate setting. Treat the temporary Node.js 20 opt-out as an emergency compatibility bridge, not as a migration plan.
Table of Contents
Start with the exact deadline and scope
GitHub originally announced September 16 as the Node.js 20 removal date and extended it to September 23 in actions/runner pull request 4685. The Node.js 24 default date remained June 16. The current runner source records both dates and the two migration environment variables, so use September 23 as the operational deadline.
The change affects JavaScript actions executed by the Actions runner. It can reach a workflow through four places:
- a JavaScript action referenced directly by a step;
- a local JavaScript action with an
action.ymloraction.yamlfile; - a composite action that invokes another action;
- a reusable workflow that invokes actions in its own repository.
Shell steps, container actions, and Dockerfile builds do not become Node.js 24 processes merely because the runner changes its JavaScript action runtime. They can still call JavaScript actions elsewhere in the same job, so audit the whole workflow rather than judging it by its main build command.
Inventory GitHub Actions Node.js 20 dependencies
Run the first query from the repository root to find action and reusable-workflow references. Run the second to find local JavaScript actions that explicitly declare Node.js 20:
rg -n --glob '*.yml' --glob '*.yaml' \
'^\s*(?:-\s*)?uses:\s*' \
.github/workflows .github/actions
rg -n --glob 'action.y*ml' \
"^\\s*using:\\s*['\\\"]?node20['\\\"]?\\s*$" \
.github/actions
Classify each uses: result instead of applying a blind search-and-replace:
owner/action@refis an external action. Find the maintainer’s Node.js 24-compatible release.owner/repository/.github/workflows/file.yml@refis a reusable workflow. Audit the called workflow’s action graph too../pathor$/pathis a local action. Inspect its metadata file and any actions called by its composite steps.docker://image:tagis a container action and does not use the runner’s embedded JavaScript runtime.
The scan shows declared references, not the fully resolved dependency graph. A third-party composite action can add more references, and a JavaScript action can bundle dependencies into dist/ without exposing its runtime in your repository. Record the action owner, repository, ref, action type, and proposed replacement in a migration worksheet.
Upgrade without weakening action pinning
For a third-party action, select a release whose metadata uses Node.js 24, review its release notes, then update the workflow. If your organization pins actions to immutable commit SHAs, preserve that control:
steps:
- name: Check out source
uses: actions/checkout@<full-commit-sha> # release supporting Node.js 24
Do not replace a full SHA with @main merely to obtain a newer runtime. GitHub’s secure-use guidance treats a full-length commit SHA as the only immutable release reference for an action. Resolve the selected release tag to its commit, review the change, and repin that SHA. Dependabot can maintain action references after the migration.
For a local JavaScript action, change its metadata only after its bundled application has been tested with Node.js 24:
name: Validate deployment manifest
description: Checks the generated deployment manifest before release
runs:
using: node24
main: dist/index.js
Rebuild and commit dist/ when the action’s repository policy requires checked-in bundles. A metadata-only change can hide incompatible dependencies inside an old bundle. Run the action’s unit tests on Node.js 24, regenerate the bundle with the locked package graph, and review the generated diff before changing runs.using.
Force Node.js 24 in a controlled test
GitHub exposes FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true so a repository can exercise Node.js 24 before removal. Add it at workflow scope in a temporary migration branch:
name: CI
on:
pull_request:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@<reviewed-full-sha>
- run: dotnet test --configuration Release
Run every workflow path that matters: pull requests, pushes, manual dispatch, scheduled jobs, release jobs in a non-production target, and reusable-workflow callers. A green pull-request job does not prove that a weekly workflow or a deployment-only action is compatible.
Capture evidence from the run rather than relying on the green summary alone:
run_id="<completed-run-id>"
gh run view "$run_id" --json conclusion,event,headSha,name,url
gh run view "$run_id" --log > "artifacts/actions-node24-$run_id.log"
rg -n 'Node\.js 20|node20|deprecated|unsupported' \
"artifacts/actions-node24-$run_id.log" || true
The acceptance record should contain the workflow name, event, commit SHA, runner labels, conclusion, and log search result. Keep a failed log as evidence too; it identifies the exact action and platform that still needs work.
Check self-hosted runner compatibility
The Node.js 24 transition also raises host constraints. GitHub’s migration notice says Node.js 24 is incompatible with macOS 13.4 and earlier, and the Node.js 20 removal ends support for self-hosted ARM32 runners. Inventory self-hosted runner labels and hosts before switching the workflow fleet.
On each self-hosted runner, collect the operating system and architecture with native commands:
uname -a
uname -m
# macOS only
sw_vers
# Windows PowerShell
Get-ComputerInfo | Select-Object OsName, OsVersion, OsArchitecture
Upgrade or replace macOS 13.4-or-earlier hosts. Move ARM32 workloads to a supported architecture or redesign the job; the temporary Node.js 20 escape hatch cannot make ARM32 a supported post-removal target. Test jobs on the actual runner labels used in production because a GitHub-hosted matrix cannot validate your self-hosted image, proxy, certificates, filesystem, or service-account policy.
Runner software also needs a normal update path. Keep self-hosted runners current according to GitHub’s supported runner policy, and verify that ephemeral images pull the intended runner release before each job. A workflow-level force flag cannot compensate for an obsolete runner binary or operating system.
Use the opt-out only as a bounded rollback
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true temporarily allows Node.js 20 after Node.js 24 becomes the default. GitHub states that the opt-out is available only until Node.js 20 is removed. It therefore cannot be the steady-state fix for a workflow that still depends on Node.js 20 on September 23.
If a critical workflow breaks during rollout, use the opt-out only with an owner, an expiry before September 23, and a link to the failing action upgrade. Apply it to the narrowest workflow or job that needs the rollback:
env:
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: "true" # temporary, remove before 2026-09-23
Do not set the escape hatch as an organization-wide default. That suppresses useful migration pressure and makes it difficult to discover which workflow still depends on the old runtime. After the compatible action version is verified, remove the variable and repeat the forced-Node.js-24 run.
Avoid three common false positives
First, actions/setup-node controls the Node.js version available to your application steps; it does not choose the runtime embedded in another JavaScript action. A workflow can test an application on Node.js 20 while every action runs on Node.js 24, or test an application on Node.js 24 while an old action still requests Node.js 20.
Second, a workflow file with no literal node20 can still be affected. External JavaScript actions declare their runtime in their own metadata, not in the caller’s YAML. Reusable workflows and composite actions add another level of indirection.
Third, a successful Linux run is not enough for a multi-platform matrix. Native modules, path handling, process spawning, certificates, and filesystem assumptions can differ across Windows, macOS, and self-hosted images. Preserve all required runner labels in the migration test.
Verification checklist
- Confirm the deadline against GitHub’s changelog and current runner constants.
- Inventory direct actions, local actions, composite actions, and reusable workflows.
- Identify the Node.js runtime declared by every local JavaScript action.
- Upgrade external actions to maintainer-supported Node.js 24 releases.
- Preserve full-SHA pinning and review every new commit.
- Rebuild checked-in local action bundles with a locked dependency graph.
- Test each important workflow event with
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true. - Exercise every production runner label, including self-hosted hosts.
- Replace macOS 13.4-or-earlier and ARM32 self-hosted runners.
- Capture run ID, commit SHA, runner labels, conclusion, and warning scan.
- Remove
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSIONbefore September 23. - Rerun the action-reference inventory after the final merge.
The migration is complete only when the repository has no known Node.js 20 action dependency and the real workflow graph succeeds on its production runner fleet. That evidence is stronger than assuming the June default already covered every action path.
References
- GitHub Changelog — Deprecation of Node 20 on GitHub Actions runners
- actions/runner #4685 — Update Node.js 20 removal date to September 23, 2026
- actions/runner Constants.cs
- GitHub Actions metadata syntax
- GitHub Actions workflow syntax
- GitHub Actions secure use reference
Found this useful? Support more practical developer content.