DEV Community

Cover image for GitHub Actions Node 20 Deprecation: What Broke on 23 September (and Why node20 Isn't the Problem)
Marco Orta
Marco Orta

Posted on Originally published at ortamarco.me

GitHub Actions Node 20 Deprecation: What Broke on 23 September (and Why node20 Isn't the Problem)

GitHub removed Node 20 from the Actions runners on 23 September 2026, and an action that declares using: node20 still doesn't fail for that: since 16 June the runner rewrites node20 to node24 before it executes anything, and it does the same with node12 and node16. What went away on the 23rd was the escape hatch: the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION variable no longer brings Node 20 back. The next date with real consequences is 2 November, when macos-14 is retired.

That inverts who is at risk. If your CI has been green since June without that variable, the 23rd changed nothing for you on GitHub-hosted runners. The ones that break are precisely the teams that already hit an action that can't handle Node 24, set the variable to keep moving, and filed the real fix under later. And around that date there are four more with real consequences: the self-hosted runner minimum version (25 September), retention for checks and runs (1 October), the macos-14 brownouts (October) and its retirement (2 November).

I checked this against the runner's source code and against this site's own CI, which uses two node20 actions. Everything below comes with its source.

The calendar, by date

Date What changes What breaks How to detect it
14, 16 and 18 Sep (11:00-15:00 ET) Brownouts ahead of the self-hosted runner minimum version Outdated runners cannot register or run jobs during the window Runner deprecations API (below)
17 Sep ubuntu-22.04 and ubuntu-22.04-arm enter deprecation Nothing fails yet; queues may be longer grep for the label
21-30 Sep windows-11-arm moves to the Visual Studio 2026 image The toolchain underneath changes grep windows-11-arm
23 Sep Node 20 leaves the runners ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION is ignored; Linux ARM32 runners lose support grep for the variable
25 Sep Self-hosted runner minimum version is enforced Registering requires ≥ 2.329.0; a runner that doesn't install each release within 30 days stops receiving jobs Deprecations API
1 Oct Actions retention now covers checks, workflow runs and statuses Anything past your retention period (90 days by default) is deleted Repo/org retention setting
5-31 Oct Eight macos-14 brownouts Jobs scheduled inside the window fail grep macos-14
2 Nov macos-14 retired Jobs with that label terminate with an error grep macos-14
17 Apr 2027 ubuntu-22.04 retired (brownouts before, in March and April) Jobs terminate with an error grep for the label

1. Node 20: what actually happens on 23 September

What the notice says

GitHub's changelog post from 19 September 2025, with an editor's note dated 25 August 2026, sets two dates: from 16 June 2026 runners use Node 24 by default, and the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true opt-out back to Node 20 "will only work until we upgrade the runner and remove Node20 on September 23rd, 2026." It adds two platform limits: Node 24 is incompatible with macOS 13.4 and lower, and it has no official ARM32 support.

What the notice does not explain is what happens to an action whose action.yml still says using: node20. That lives in the code.

What the runner code says

In HandlerFactory.cs (identical at tag v2.337.0, the current release), the runner does two things before launching a JavaScript action:

  1. If the action declares node12 or node16, it bumps it to node20.
  2. If it ends up on node20, it asks NodeUtil.DetermineActionsNodeVersion which version to use.

That function has three phases, driven by feature flags on GitHub's side:

// Phase 3: Always use Node 24 regardless of environment variables
if (requireNode24)
{
    return (Constants.Runner.NodeMigration.Node24, null);
}
// ...
// Phase 2: Node 24 is the default
if (useNode24ByDefault)
{
    if (allowUnsecureNode)
    {
        return (Constants.Runner.NodeMigration.Node20, null);
    }
    return (Constants.Runner.NodeMigration.Node24, null);
}
Enter fullscreen mode Exit fullscreen mode

PR #3948, which introduced this, is blunt about it: in phase 3, "All actions use Node 24" and "No opt-out options available." No branch returns an error for declaring node20. The action runs on a different Node, which is very different from not running.

Note the order too: node12 → node20 → node24. An old action with using: node16 (for example actions/checkout@v3) ends up on Node 24 as well.

The proof: this site's CI

This blog's CI workflow uses actions/checkout@v4 and actions/setup-node@v4. The official action.yml files at those tags declare using: node20; from v5 onward they declare node24 (I checked tags v4 through v7 of both). The run on 11 September, on runner 2.337.0 with the ubuntu-24.04 image, finished green with this annotation:

Node.js 20 is deprecated. The following actions target Node.js 20 but are being
forced to run on Node.js 24: actions/checkout@v4, actions/setup-node@v4.
Enter fullscreen mode Exit fullscreen mode

And in the log of those two actions' steps, this line:

Node 20 is being deprecated. This workflow is running with Node 24 by default.
If you need to temporarily use Node 20, you can set the
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable.
Enter fullscreen mode Exit fullscreen mode

The same annotation appears on the very first run of that workflow, on 23 August. In other words, my two node20 actions have never run on Node 20 in this CI, and I didn't touch anything to make that happen. 23 September doesn't change their runtime, because that change already happened in June. Bumping them to v5 or later clears the annotation, and it's worth doing, but there's no clock on it.

What it looks like when it does break

There are three real ways to fail, and none of them is a GitHub message saying "Node 20 no longer exists."

You had the variable set. Some action of yours tripped on Node 24 after 16 June, you set ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true, and it stayed on Node 20. In phase 3 the runner ignores the variable without telling you: the requireNode24 branch returns null as its message. The action goes back to Node 24, and the error you see is its own code's error, the same one you saw the first time. One hint that phase 3 is live: with the current runner code, the "If you need to temporarily use Node 20…" line disappears from the log, because it is only printed while Node 24 is the default but not yet mandatory.

The action uses something Node 24 removed. Per the Node 24 deprecations table and the 24.0.0 release notes, these reached end-of-life in that release:

API Status in Node 24 How it fails
tls.createSecurePair() Removed (DEP0064) TypeError: ... is not a function when called
fs.truncate() with a file descriptor Removed (DEP0081) Throws ERR_INVALID_ARG_TYPE: it expects a path. Use fs.ftruncate()
dirent.path Removed (DEP0178) Doesn't throw on its own: the property is gone and you read undefined. Use dirent.parentPath
OutgoingMessage.prototype._headers / _headerNames Removed (DEP0066) You read undefined. Use getHeaders()

dirent.path is the sneakiest one on the list, because reading it throws nothing: the failure shows up later, wherever that path gets used, or never, if it's only concatenated into a string and comes out as undefined/file. If you maintain an action that walks directories, search for that property before you trust the green check.

Your self-hosted runner can't run Node 24. On Linux ARM32, the same NodeUtil.cs has a kill switch that fails the step with:

Linux ARM32 runners are no longer supported. Please migrate to a supported platform.
Enter fullscreen mode Exit fullscreen mode

Before it's flipped, the runner keeps those actions on Node 20 with a warning whose default date is the Node 20 removal date: "Linux ARM32 runners are deprecated and will no longer be supported after September 23rd, 2026." GitHub hasn't published the exact day the switch flips; the changelog only says ARM32 loses support after the Node 20 deprecation. On macOS, Node 24's BUILDING.md requires macOS 13.5 or later.

2. The audit: what to look for and where

In one repository

Run this from the root:

# 1. The switch that stops working on 23 September
grep -rn 'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION' .github/

# 2. Image labels with a date. No "runs-on:" on purpose,
#    so matrices show up too (os: [ubuntu-22.04, ...])
grep -rnE 'ubuntu-22\.04|macos-14|windows-11-arm' .github/

# 3. Your own or local actions that declare an old Node
grep -rnE --include=action.yml --include=action.yaml \
  "using:[[:space:]]*['\"]?node(12|16|20)" .
Enter fullscreen mode Exit fullscreen mode

Search 2 has a blind spot: runs-on: ${{ vars.RUNNER }} or a label passed as an input to a reusable workflow won't show up in any grep. For those, the log is the source of truth. The "Set up job" step prints the real image:

gh run view <run-id> --log | grep -m1 'Image:'
# Image: ubuntu-24.04
Enter fullscreen mode Exit fullscreen mode

If the matrix is long or nested, converting it to JSON and filtering it with jq is usually faster than reading the YAML by eye:

Free tool: JSON and YAML Converter — Convert JSON to YAML and YAML to JSON instantly, with configurable indentation, download and copy. All in your browser, nothing uploaded.

To find out which runtime each third-party action you use declares, including SHA-pinned ones, you have to read its action.yml at that exact ref. This script does it with gh:

grep -rhoE "uses:[[:space:]]*['\"]?[^[:space:]'\"#]+@[^[:space:]'\"#]+" .github/ \
  | sed -E "s/uses:[[:space:]]*['\"]?//" | sort -u \
  | while read -r ref; do
      spec=${ref%@*}; ver=${ref#*@}
      case "$spec" in ./*|docker://*) continue ;; esac
      repo=$(cut -d/ -f1-2 <<<"$spec"); sub=$(cut -d/ -f3- <<<"$spec")
      for f in action.yml action.yaml; do
        rt=$(gh api "repos/$repo/contents/${sub:+$sub/}$f?ref=$ver" --jq .content 2>/dev/null \
             | base64 -d | grep -E '^[[:space:]]*using:' | tr -d " '\"" | cut -d: -f2)
        [ -n "$rt" ] && { echo "$rt  $ref"; break; }
      done
    done
Enter fullscreen mode Exit fullscreen mode

On this repo it prints:

node20  actions/checkout@v4
node20  actions/setup-node@v4
Enter fullscreen mode Exit fullscreen mode

It handles subpaths (github/codeql-action/init@v3) and SHAs. A SHA pointing at a node20 release doesn't fail on the 23rd, for the same reason @v4 doesn't: the runner forces it onto Node 24. What's different with a SHA is how it gets updated: nothing moves on its own, so Dependabot or a human has to bump it.

One warning before bumping a major: read the release notes. actions/setup-node v5 turned on automatic caching when package.json has a packageManager field, and v6 limited it to npm. If you already pass cache: npm explicitly, as my CI does, it doesn't affect you.

Across an organization

GitHub code search reaches every repo at once:

gh search code --owner YOUR_ORG 'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION'
gh search code --owner YOUR_ORG 'ubuntu-22.04 path:.github/workflows'
gh search code --owner YOUR_ORG 'macos-14 path:.github/workflows'
gh search code --owner YOUR_ORG node20 --filename action.yml
Enter fullscreen mode Exit fullscreen mode

Two limits of the code search API that will bite you: it only searches the default branch (a workflow on a release branch won't show up) and it allows 10 requests per minute. While preparing this post I hit HTTP 403: API rate limit exceeded as soon as I chained a handful of searches; add a sleep between them if you automate it.

3. Self-hosted runners: two dates in three days

If you only use GitHub-hosted runners, skip this section. If you run your own, 25 September matters more than the 23rd.

The 12 June changelog post resumes enforcement of two rules on github.com:

  • To register (or re-register) a runner you need version 2.329.0 or later.
  • To keep running jobs, the runner must install each new release within 30 days of its publication. "A runner pinned to 2.329.0 that never updates again will not pick up jobs."

Full enforcement begins on 25 September 2026 (the notice's table labels that date as GitHub Enterprise Cloud, and the same notice states the change applies to github.com), with brownouts first: on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern, out-of-date runners can neither register nor run jobs. What you see, per the notice, is that "workflows targeting unsupported runners may remain queued or fail."

The way to find out when your version expires is an endpoint GitHub added on 3 September. I queried it today:

gh api repos/OWNER/REPO/actions/runners/deprecations/2.335.1
# {"runner_version":"2.335.1","runtime_deprecates_at":"2026-09-24T15:30:55Z"}

gh api repos/OWNER/REPO/actions/runners/deprecations/2.337.0
# {"runner_version":"2.337.0","runtime_deprecates_at":null}
Enter fullscreen mode Exit fullscreen mode

A runner on 2.335.1 falls out of support on 24 September, so by the time full enforcement starts on the 25th it no longer gets jobs. You get the installed version on the machine itself with ./config.sh --version.

The self-hosted checklist, in order:

  1. Update the runner to the current release and leave auto-update on. If it's off, you need a manual cadence shorter than 30 days.
  2. Rebuild the images, VM templates and containers you create runners from. The changelog asks you to recreate runners built from older cached images or templates: if the image ships a runner older than 2.329.0, it won't register.
  3. Linux ARM32: move to arm64. Node 24 has no official support there, the runner's linux-arm package only bundles the Node 20 binary (externals.sh), and the code that fails the step is already in the runner.
  4. macOS 13.4 or earlier: upgrade the OS. GitHub lists Node 24 as incompatible with those versions.
  5. Actions that already declare node24 (such as actions/checkout@v5) require runner v2.327.1 at minimum. If you've done step 1, you already have it.

4. Retention, 1 October: history gets deleted

This change breaks no build, but it deletes data. According to the 27 August changelog post, starting 1 October 2026 checks, workflow runs and statuses follow the same retention setting that already governed artifacts and logs, 90 days by default. Until now they were kept for "400+ days" regardless of your configuration.

  • For public repositories the maximum is 90 days.
  • The change is not retroactive: raising retention later won't restore anything.
  • Check and workflow run metadata isn't billed as storage; the associated artifacts and logs are.

What breaks is anything that reads that history beyond your period: deployment metrics computed from gh run list, audits that link to a specific run, quarterly reports. Export what you need before 1 October.

What the changelog does not say: what happens to a pull request open for more than 90 days whose required check expires. I couldn't find it documented. If you have long-lived PRs under branch protection, look at them after 1 October before assuming anything.

5. Images: macos-14 has a date, ubuntu-22.04 doesn't yet

macos-14 (actions/runner-images#13518): deprecation started on 6 July and support ends on 2 November 2026. Before that there are eight brownouts during which jobs fail: 5, 12, 16, 19, 23, 26, 29 and 30 October, each from 14:00 UTC to 00:00 UTC the next day. It covers macos-14, macos-14-large and macos-14-xlarge. The replacement is macos-15, macos-26 or macos-latest, which started pointing to macOS 26 with the migration that began on 15 June.

ubuntu-22.04 (actions/runner-images#14254): on 17 September deprecation only begins. The issue warns about longer queue times at peak hours, not failures. Retirement is on 17 April 2027, with brownouts that the issue schedules for March and April (no year given, but they fall before the retirement). It also covers ubuntu-22.04-arm. The replacement is ubuntu-24.04, ubuntu-26.04 or ubuntu-latest.

If someone tells you ubuntu-22.04 breaks this week, it doesn't. Migrate calmly, but don't leave it for March.

What's in the changelog and breaks nothing

  • cache-mode (10 September): a new key to limit cache access per workflow or job. It's opt-in: workflows that don't set it "continue to use the existing secure defaults."
  • The separate Code Quality path (20 August): only affects you if you have reports filtering on dynamic/github-code-scanning/codeql or on the github-advanced-security actor.
  • actions/checkout and pull_request_target (18 June): this one does break, but it already happened. Since 20 July the floating tags, @v4 included, refuse to check out fork code in pull_request_target. If your workflow depended on that, it's already red. It's the same family of risk I covered in the worm that got in through npm trusted publishing.

What I'm doing with my CI

Nothing urgent. actions/checkout@v4 and actions/setup-node@v4 have been running on Node 24 since June, the workflow doesn't use the opt-out variable, it runs on ubuntu-latest (currently ubuntu-24.04) and on GitHub-hosted runners. 23 September changes nothing for it.

I am going to bump both actions to their current major, for two reasons unrelated to the date: a permanent yellow annotation trains you to ignore annotations, and an action its authors no longer test on the runtime it actually runs on is debt. If you're also thinking about your own project's Node rather than the actions', I covered that in what breaks when upgrading to Node 26.

Frequently asked questions

What happens to GitHub Actions on 23 September 2026?

GitHub removes Node 20 from the runners. Since 16 June 2026 runners had already been running actions on Node 24 by default, and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true let you go back to Node 20. From 23 September that variable stops working and every JavaScript action runs on Node 24. Self-hosted runners on Linux ARM32 lose support, because Node 24 has no official support for that platform.

Does an action that declares using: node20 fail after 23 September?

Not for declaring it. The runner code (HandlerFactory.cs and NodeUtil.cs in actions/runner) rewrites node12 and node16 to node20, and node20 to node24; in the final phase it ignores the environment variables and always picks Node 24, without returning an error. The action fails if its code uses something Node 24 removed, such as tls.createSecurePair or fs.truncate with a file descriptor, or if it runs on a self-hosted Linux ARM32 runner or on macOS 13.4 or earlier. The logs show the annotation: The following actions target Node.js 20 but are being forced to run on Node.js 24.

Do I have to update actions/checkout@v4 and actions/setup-node@v4?

It's a good idea, but no date forces it. The v4 tags of both declare node20 and v5 onward declare node24. On GitHub-hosted runners, the v4 tags have already been running on Node 24 since June and keep working. When bumping setup-node, read the release notes: v5 turned on automatic caching when package.json has packageManager, and v6 limited it to npm.

How do I find affected workflows across my organization?

With GitHub code search: gh search code --owner YOUR_ORG with ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, with ubuntu-22.04 path:.github/workflows, with macos-14 path:.github/workflows, and with node20 --filename action.yml for your own actions. The API only searches the default branch and allows 10 requests per minute. Labels that come from variables or expressions don't show up in any search: for those, check the Image: line of the Set up job step in the log.

Does ubuntu-22.04 break on 17 September 2026?

No. On 17 September deprecation only begins, and the notice talks about longer queue times at peak hours. Jobs start failing during the brownouts that issue 14254 in actions/runner-images schedules for March and April, before the image is retired on 17 April 2027. The one with a date this fall is macos-14: brownouts in October and retirement on 2 November 2026.

What do self-hosted runners need to do before 25 September?

Update the runner. From 25 September 2026 GitHub requires version 2.329.0 to register a runner, and each new release must be installed within 30 days to keep receiving jobs. There are brownouts first on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern. The endpoint GET /repos/OWNER/REPO/actions/runners/deprecations/VERSION returns when your version stops running jobs. Also rebuild the images you create runners from.

What changes with GitHub Actions retention on 1 October 2026?

Checks, workflow runs and statuses start following the artifact and log retention setting, which defaults to 90 days; before, they were kept for more than 400 days. For public repositories the maximum is 90 days, and the change is not retroactive. If you have metrics or audits that read old runs, export the data before that date.


Originally published on ortamarco.me.

Top comments (0)