
Updating action.yml from runs.using: node20 to node24 Without Breaking Your Action
Why updating action.yml from node20 to node24 is more than a one-line change
Node 20 is gone from GitHub Actions hosted runners. The GitHub blog post "Node 20 is no longer available in GitHub Actions," published 2026-09-23, says it plainly: any action whose action.yml still declares runs.using: node20 stops working on hosted runners, and it stops before your first line of JavaScript executes. Your code is fine — the metadata GitHub reads to choose an interpreter is not. This guide walks through the full migration: updating action.yml from runs.using: node20 to node24, rebuilding the committed dist/ bundle under a matching toolchain, refreshing dependencies, and re-verifying inputs and outputs so existing workflows stay green.
Provenance note before anything else: I read the announcement, I did not reproduce it. I can't pull Node 20 out of GitHub's fleet to confirm the removal. The rebuild, bundle-verification, and local harness commands below I ran against a small Node action on a Node 24 toolchain, and the outputs shown come from those runs unless I say otherwise.
The action.yml edit is a thirty-second change, and it's also the least interesting part of this. If you ship an action with a committed dist/, the real work is rebuilding that bundle under a toolchain targeting the new runtime, then re-verifying the input/output contract on an actual runner. Skip the rebuild and you ship a "fixed" release that fails in a different way.
What GitHub removed, and when node20 support ended
Per the post dated 2026-09-23, Node 20 is no longer available in GitHub Actions. Node 22 and Node 24 remain. The announcement frames this as a migration window closing, not a surprise deprecation.
That sentence hides two separate changes, and conflating them leads to bad fixes:
- Runner image change. Hosted runner images no longer carry a Node 20 installation.
- Action runtime change. The runner service no longer honors
runs.using: node20as a runtime selector for JavaScript actions.
For a JavaScript action, the second one breaks you. The first one breaks any workflow step that assumed node on PATH was version 20.
A few things the source material does not establish, and which I won't pretend it does: the literal error string GitHub emits, the exact runner image build that dropped Node 20, and whether every runner label rolled out at once. Treat those as unverified until you see them in your own job logs or find a second primary source. Self-hosted runners are a separate story — they get runtimes from the runner software and tool cache rather than the hosted image, so a stale runner binary is the likely blocker there. That last part is inference, not something I tested.
Two Node versions inside one workflow: action runtime vs. job runtime
Most broken "fixes" originate here. A single workflow routinely carries two independent Node version decisions, and they never talk to each other.
Action runtime vs. job runtime
runs.using in action.yml picks the interpreter that runs the action's own entry point:
runs:
using: node20
main: dist/index.js
actions/setup-node with node-version picks the interpreter for your workflow's build, test, and script steps:
- uses: actions/setup-node@v4
with:
node-version: 20
Bumping node-version to 24 does nothing for a failing third-party action. Editing runs.using in your own action does nothing for your npm test step. If you already "fixed" a broken action by editing setup-node and the job still fails, that's why.
Composite and Docker actions
Composite actions declare runs.using: composite and run shell steps in the job's Node, so the runtime removal doesn't touch them — though their scripts still break if they rely on Node 20 behavior or features that changed. Docker actions declare runs.using: docker and bring their own base image, so there is no runs.using value to change at all. If you're in either bucket, skip the migration section; your work is dependency and script hygiene.
Identify your failure mode before editing action.yml
Three cases, three different jobs:
| Case | Situation | What to change |
|---|---|---|
| 1 | You consume a third-party action that hasn't shipped a fix | Nothing you can edit; pin a fork, vendor it, or replace it |
| 2 | You author an action with a committed dist/ | action.yml and the bundle |
| 3 | You author an action published unbundled from a registry | action.yml, plus dependencies in metadata |
Case 2 is the one people misread as a missing input. The tell is when it fails. A runtime failure happens during action resolution or startup — before your getInput() calls, before any console.log, before a try block can catch it. A missing input fails inside your code, usually with your own error message.
What you're scanning the log for is a startup failure that names the runtime, roughly:
Run my-org/my-action@v3
Error: node20 <runtime unavailable / unsupported>
I've deliberately left that a sketch. The announcement text I had does not include a precise string and I won't invent one. The diagnostic rule holds regardless of wording: fails at startup naming the runtime means metadata; fails after your banner log means your code.
The action.yml metadata change and the rebuild people skip
Changing runs.using from node20 to node24
runs:
using: node24
main: dist/index.js
Scope the edit to that one field. Don't fold main, inputs, outputs, or branding into the same commit — you want a diff you can point at when a reviewer asks what changed.
Node 22 is a valid target too. Pick Node 24 if you want the longer window and your bundle plus native dependencies support it. Pick Node 22 if you depend on a native module with no Node 24 prebuilds yet, or if a downstream consumer pins an older runner. Per the Node.js release schedule, Node 20 reached end of life in April 2026, so staying on it was never a long-term plan.
Why your committed dist/ must be rebuilt
The runner's runtime executes your bundle, not your build machine's. That doesn't make the artifact portable for free. Two concrete problems:
- Toolchain pins. If
npm ci && npm run buildruns under Node 20 on your CI or your laptop, the compiler and dependency graph producing that output were validated against Node 20 — not against whatever ends up running it. - Bundler target.
esbuildin particular will happily emit syntax for whichever version you name. A stale target means you ship artificially downlevelled code, or a mismatch you didn't intend.
The rebuild itself is unremarkable:
#!/usr/bin/env bash
set -euo pipefail
node -v # must be 24.x
rm -rf dist
## ncc
npx ncc build src/index.js -o dist
## or esbuild
## npx esbuild src/index.js --bundle --platform=node --target=node24 ## --outfile=dist/index.js
git diff --stat dist/The diff will not be empty:
$ git diff --stat dist/
dist/index.js | 412 ++++++++++++++++++-----------
1 file changed, 268 insertions(+), 144 deletions(-)
Exact counts vary by bundle and toolchain; the shape is what matters. A non-zero diff is expected, not a red flag. A zero diff means either you rebuilt nothing or your toolchain was already targeting the new runtime — work out which before shipping.
Verify the bundle actually changed
Then stop guessing and assert on facts:
$ grep -rn "node20" dist/ || echo "no node20 references"
no node20 references
$ node --check dist/index.js && echo "parses under $(node -v)"
parses under v24.4.0
node --check is a syntax gate, not a compatibility proof. It won't catch an API that exists in one runtime and not the other. It does catch the embarrassing case where the bundle doesn't parse at all — which is exactly what happens when the build ran under the wrong Node.
Refreshing dependencies for the new runtime
engines, lockfile, and packages that dropped Node 20
Keep engines.node honest. If your action needs Node 24, say so:
{
"engines": {
"node": ">=24"
}
}
npm only warns on engine mismatches unless engine-strict=true is set, so this documents intent for consumers rather than enforcing it. Still worth doing: a clear install-time message beats a runtime crash.
Expect major bumps in three dependency classes: HTTP stacks (undici-level fetch implementations and their wrappers), native bindings, and test runners. Those are the packages where Node 20 support gets dropped rather than deprecated, so the lockfile refresh is where breakage actually surfaces.
Bundler target and native modules
For esbuild, --target=node24 is explicit, and I verified that behavior. For ncc, the emitted target follows the Node running the build and/or your engines field depending on version — I did not verify ncc's target resolution myself, so check your installed version's docs before assuming it picked up Node 24.
Native modules with prebuilt binaries are the most likely to need a version pin or a prebuild rebuild. Anything in this subsection I haven't personally exercised is marked untested on purpose.
Do not run the action's dist/ build under Node 20 after you have raised the target to node24. A mismatched bundler target produces bundles that parse locally and throw on the runner — green locally, red in production, no obvious cause.
Retesting the input and output contract: the migration is not done when the process starts
An action that starts is not an action that works. Runtime migrations move the floor; they do not validate behavior.
A minimal local harness
Skip the runner for the first pass. Invoke the bundle directly and feed it what the runner would:
$ rm -f /tmp/gh-output
$ INPUT_PATH=./fixtures/sample.txt GITHUB_OUTPUT=/tmp/gh-output node dist/index.js
processed ./fixtures/sample.txt
$ cat /tmp/gh-output
lines=3
$ echo "exit=$?"
exit=0
The INPUT_* convention (uppercased, spaces to underscores) and the GITHUB_OUTPUT file are exactly what the runner provides. Two seconds of setup, and it catches most contract regressions.
What to assert before publishing
- Inputs with defaults: unset variable produces the documented default, not
undefined. - Inputs with empty strings:
INPUT_PATH=""behaves the same as unset, or fails loudly. GITHUB_OUTPUTwrites go to the file, not the deprecated stdout::set-outputcommand.- Failure path: a bad input exits non-zero, and the message names the input.
- Error message format: unchanged from the previous major, or documented as changed.
One real CI run
Then do it for real. A workflow in the same repo, using the working tree, exercises the runtime on an actual runner before you tag anything:
name: test-action
on: [push, pull_request]
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- id: a
uses: ./
with:
path: ./fixtures/sample.txt
- run: echo "got ${{ steps.a.outputs.lines }} lines"
Observed on my run:
test-action (push) #12
✓ success 34s
run: got 3 lines
The npm ci && npm run build step matters: uses: ./ executes whatever main points at in the working tree, so a stale committed dist/ passes locally and fails here.
Releasing without breaking pinned consumers
Moving a major version tag means every workflow pinned to @v3 silently starts running different code, with no PR to review. Your options:
- New major (
v4) with a migration note. Clean, reviewable, explicit. - Floating tag with a deprecation warning. Move
v3forward, emit a warning, break the pinning contract. - Dual-tag window. Cut
v4and also repointv3at the same commit for a defined period.
I'd pick 3 — but only because v3 is already broken. Under normal circumstances option 1 is correct; repointing a major tag violates the expectation that a pinned major does not change behavior. Here the behavior already changed underneath your consumers, through no fault of theirs. Leaving v3 failing means every dependent workflow is red and every maintainer is reading release notes under pressure. Repoint it, warn loudly, and write down the window before v3 stops tracking.
If the action's inputs or outputs changed at all during the migration, option 1 is the only defensible answer. Don't use a floating tag to smuggle a contract change.
What is confirmed, and what still needs checking
Sourced from the announcement (github.blog, 2026-09-23): Node 20 is no longer available in GitHub Actions; Node 22 and Node 24 remain.
Reproduced locally with commands shown above: the rebuild produces a non-empty dist/ diff; node --check passes under v24.4.0; the local harness writes GITHUB_OUTPUT correctly and exits 0; uses: ./ runs green on a hosted runner.
Inference, needs a second source: the exact error string for a node20 action; the runner-image rollout schedule; self-hosted runner behavior with older runner software; whether ncc picks up the target from engines in current versions.
Conclusion
The ordering matters more than any individual command:
- Confirm the failure is the runtime — it fails at startup, before your code.
- Edit exactly one field:
runs.using: node20→node24. - Rebuild the committed
dist/under a Node 24 toolchain and verify the diff. - Refresh dependencies for the new runtime, especially native and HTTP layers.
- Re-verify inputs and outputs locally, then on a real runner via
uses: ./. - Release with a deliberate tag strategy, and write down the window.
Steps 1 and 2 take ten minutes. Steps 3 and 5 are where the actual bugs live.
Further Reading
- GitHub Blog — publisher of "Node 20 is no longer available in GitHub Actions" (2026-09-23). I'm linking the blog index rather than guessing the canonical post path, since the feed entry I had was a Google News redirect.
- Metadata syntax for GitHub Actions — official reference for
runs.using,main, and action metadata fields. - Node.js Release schedule — the authoritative support windows for Node 20, 22, and 24.
- Node.js previous releases — version status and EOL dates in a readable table.


