close
Skip to content

Node 20 is EOL (2026-04-30) and pinned in 11 CI jobs, the installer, and the desktop runtime #1070

Description

@dwu96

Problem

Every Node.js pin in this repo targets Node 20, which reached end-of-life on 2026-04-30 — three months ago. Node 20 no longer receives security patches of any kind. The pin is present in 11 CI jobs, the local bootstrap script, the installer, the EC2 cloud template, and (transitively, via Electron 33) the runtime shipped inside the desktop app.

Separately, the repo declares six mutually incompatible Node floors (16, 18, 20, 22, 24, 25), four of which are EOL. kirocrew doctor currently reports a green checkmark for a Node version that kirocrew chat refuses to start on.

Evidence — every Node declaration on main (2108dec)

CI (hard pins)

File:line Value
.github/workflows/ci.yml:397 node-version: 20
.github/workflows/ci.yml:532 node-version: 20
.github/workflows/ci.yml:570 node-version: 20
.github/workflows/build.yml:30 node-version: 20
.github/workflows/build.yml:74 node-version: 20
.github/workflows/build-desktop.yml:119 node-version: 20
.github/workflows/build-wheel.yml:36 node-version: 20
.github/workflows/docker-smoke.yml:59 node-version: 20
.github/workflows/docker-smoke.yml:191 node-version: 20
.github/workflows/pages.yml:34 node-version: 20
.github/workflows/dependency-vulnerability.yml:24 node-version: "20.19.4" (exact patch)
.github/workflows/ota-test.yml:68 node-version: "22" — the only outlier, and load-bearing (see below)

Bootstrap, install, deploy

File:line Value
ensure-node.sh:13 MIN_VERSION=18
ensure-node.sh:14 TARGET_VERSION=20
ensure-node.sh:22 AL2_NODE_VERSION=16 (also applied at :90-91, :142-143)
install.sh:44 NODE_VERSION="20"
src/kiro_crew/cloud/templates/kirocrew-ec2.yaml:380 NodeSource pub_20.x repo
website/package.json:10 "engines": { "node": "20 || >=22" }
website/electron/package.json:19 "electron": "^33.0.0" → embeds Node 20.18.0

There is no .nvmrc, .node-version, .tool-values, mise.toml, or volta field anywhere, so nothing pins Node for local development and no .npmrc sets engine-strict, making the engines field advisory only.

Python-side gates (contradictory)

File:line Value
src/kiro_crew/cli.py:262 _MIN_NODE_VERSION = 16
src/kiro_crew/cli_doctor.py:59 _MIN_NODE_VERSION = 16 (duplicated constant)
src/kiro_crew/cli_chat.py:67 process.exit(... < 20 ? 1 : 0)hard sys.exit(1) below 20
src/kiro_crew/cli_chat.py:73 "Node.js >= 20 required. Current version is too old."

Documentation floors (four different answers)

File:line Claim
docs/getting-started.md:10 Node.js 16+
docs/app-kit/getting-started.md:8 Node.js 18+
src/kiro_crew/docs/getting-started.md:13 Node.js 18+
CONTRIBUTING.md:10 Node.js ≥ 18
docs/remote-desktop-setup.md:10,23,35 Node 20+
docs/WINDOWS_CHANGES.md:9 Node 24 (tested config)

Support status (primary source)

From nodejs/Release/schedule.json, evaluated 2026-08-01:

Major EOL Status today
16 (Gallium) 2023-09-11 EOL — 2y 11m ago
18 (Hydrogen) 2025-04-30 EOL — 15m ago
20 (Iron) 2026-04-30 EOL — 3m ago
22 (Jod) 2027-04-30 Maintenance LTS (since 2025-10-21)
24 (Krypton) 2028-04-30 Active LTS until 2026-10-20
25 2026-06-01 EOL (odd-numbered)
26 2029-04-30 Current; becomes LTS 2026-10-28

Why it matters

  1. No security patches. CI, release artifacts, and the desktop runtime all execute an unpatched Node. Ironically dependency-vulnerability.yml:24 — the vulnerability scan itself — runs on the EOL runtime.
  2. The desktop app is the worst case. ^33.0.0 cannot resolve past Node 20.x (stable Electron lines only take minor/patch Node bumps), so the >=22 branch of the engines field is unreachable for the Electron process. Electron's support policy is the latest three stable majors; Electron 33 (Chromium 130) is roughly ten majors behind, so it also receives no Chromium security fixes. This is a shipped-to-users concern, not just a CI one.
  3. doctor and chat disagree. cli_doctor.py:59 prints ✅ for any major ≥ 16; cli_chat.py:67 exits 1 below 20. A user on Node 18 gets a clean doctor report and an immediate launch failure.
  4. ensure-node.sh cannot satisfy the engines field it exists to serve. It accepts an existing Node ≥ 18 (:39) and returns without installing, while website/package.json:10 requires 20 || >=22.
  5. It is already blocking other work. Two places are held back by the old floor, not by their own constraints:
    • .vulnerability-exceptions.json:8,16 — two live accepted vulnerabilities whose stated removal condition is the Node 22 migration: "the 8.3.0 fix requires Node 22 and React 19. Remove this exception during that migration."
    • src/kiro_crew/apps/builtins/design_critique/.../scripts/ensure-playwright.mjs:23PW_VERSION = '1.47.2' // last line that supports Node 18. A shipped runtime path is pinned to a two-year-old Playwright purely to stay compatible with a Node line that is now 15 months dead.
  6. The ecosystem is already moving. Several installed dependencies now express ranges of the shape 20 || >=22 (glob@13, minimatch@10, test-exclude@8) or ^20.17.0 || >=22.9.0 (mute-stream@3) — Node 20 is in their legacy branch. The next routine dependency bump is what turns this from latent into blocking.

Why the Amazon Linux 2 escape hatch is no longer a blocker

#49 added a deliberate Node 16 path for AL2 because AL2 ships glibc 2.26 while official Node ≥ 18 binaries are linked against glibc ≥ 2.28. That constraint is real and unchanged — nodejs/node/BUILDING.md lists Tier 1 GNU/Linux as glibc >= 2.28. But it does not block this upgrade, for two reasons:

  • AL2 is already on a separate branch of ensure-node.sh (:90-91). Moving TARGET_VERSION from 20 to 24 leaves the AL2 path untouched, so this bump cannot make AL2 worse.
  • AL2 itself reached end of support on 2026-06-30 (AWS AL2 FAQ: "Amazon Linux 2 reached its end of support on 2026-06-30... is no longer receiving standard security updates"). AL2023 ships glibc 2.34, clearing the Node 2.28 floor comfortably.

So the AL2 fallback should be reframed as a deprecated compatibility path, not a constraint on the primary target.

Correction to a claim I made earlier in this issue: AL2 is not necessarily stuck on Node 16. nodejs/unofficial-builds publishes a glibc-2.17 x64 build, and its authoritative gate script — recipes/x64-glibc-217/should-build.sh — carries isNodeVersionLT 'v27.0' with the comment "v24 and v26 verified on glibc 2.17". Verified live: node-v24.18.1-linux-x64-glibc-217.tar.{gz,xz} exists at unofficial-builds.nodejs.org/download/release/v24.18.1/. (The project's README still claims glibc-217 stops at v23 — the README is stale, the gate script is authoritative.)

Two caveats before anyone relies on it: the project self-describes as experimental with "minimal or no testing" and no guarantees, and there is no arm64 glibc-217 recipe — the recipe list is arm64-musl, armv6l, centos7-toolchain, headers, loong64, musl, riscv64*, x64-debug, x64-glibc-217, x64-pointer-compression. Since ensure-node.sh:18-19 specifically notes Node 16 "runs on AL2 for both x86_64 and aarch64", an AL2 aarch64 host has no glibc-217 path at all — only musl. So this is a viable escape hatch for AL2 x64 only, and it does not change the recommendation for the primary target.

Also worth stating, because it is a plausible objection: Node 24 does not raise the glibc floor. The Tier 1 row is byte-identical (glibc >= 2.28) across the v20.x, v22.x, v24.x and main BUILDING.md files. Node 24's semver-major platform commits raised only the toolchain floor — macOS 13.5, Xcode 16.1 — per the v24.0.0 release notes. The one real per-major platform regression is armv7: Tier 1 with an official linux-armv7l binary on 20 and 22, downgraded to Experimental on 24 with linux-armv7l removed from the official binary table. arm64 stays Tier 1, so docker-smoke.yml:191's arm64 cross-build is unaffected — but anyone targeting 32-bit ARM should confirm before this lands.

Upgrade risk assessment (measured, not assumed)

Scanned website/node_modules (841 installed packages) and grepped the source tree:

  • No dependency has a hard floor above 20 — but the real floor is 20.19, not 20. The highest constraint in the repo is eslint-visitor-keys (nested under @typescript-eslint/visitor-keys) at ^20.19.0 || ^22.13.0 || >=24, with the five @inquirer/* packages close behind at >=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0. Bare node-version: 20 resolves to the newest 20.x (20.20.2) so this is satisfied today, and dependency-vulnerability.yml:24's exact 20.19.4 clears ^20.19.0 by four patch releases — but only just. The actionable consequence: if the target were 22, the floor is 22.13.0, not 22. Node 24 is unconstrained by every range in the tree.
  • No native-addon ABI risk, across all three projects. In website/ only two packages have install scripts — esbuild@0.21.5 (downloads a prebuilt binary) and msw@2.12.14. site/ has zero install/postinstall scripts despite carrying 25 @tailwindcss/oxide* entries (4.3.2, engines.node: ">= 20"): those are prebuilt per-platform Rust/napi binaries delivered as optionalDependencies, the same model as esbuild and rollup, and N-API is ABI-stable across Node majors. No node-gyp anywhere. Electron's ABI is separate and handled by electron-builder.
  • Worth stating explicitly because it is easy to miss: site/ is a third project with its own Node exposure, built by pages.yml:34 on Node 20 and path-scoped to site/**. It has no engines field, no @types/node constraint, and its own lockfile — so it needs to be in scope for every step below, not just website/.
  • Zero hits for the classic Node 22/24 removals across website/src, website/scripts, website/electron, site/src, src/kiro_crew, and all *.mjs: punycode, url.parse, util._extend, createCipher/createDecipher, fs.rmdir recursive, process.binding, new Buffer(, tls.createSecurePair, and assert { type: 'json' } import attributes.
  • Toolchain headroom is fine: vite@5.3.6 (^18 || >=20), vitest@3.2.4 (^18 || ^20 || >=22), @playwright/test@1.58.2 (>=18), happy-dom@20 (>=20).
  • tsconfig is orthogonal: types is absent from all four tsconfigs, moduleResolution is "bundler" (models the bundler, not Node), and noEmit: true — so no tsconfig edit is required.

But three things DO break on the way up

The scan above answers "does anything block 22/24"; it does not answer "does anything assume 20". Three sites assert the absence of post-20 APIs and fail when the runtime moves up:

Site Assumption
website/src/i18n/format.test.ts:147 asserts Intl.DurationFormat is undefined on the Node 20 baseline
website/src/i18n/englishIdentity.test.ts:124-140 avoids fs.globSync with a hand-rolled walk because it is undefined on Node 20
website/integration/setup.ts:76-77 localStorage polyfill branches on Node 22+ shipping a native one, and differs again on 25

Plus one CI-gate coupling: test/test_dependency_vulnerability_gate.py:133 asserts node-version == "20.19.4" verbatim, so editing dependency-vulnerability.yml:24 alone turns the build red.

And website/electron/package.json:8 runs node --test test/*.test.js on the host Node (not Electron's bundled one). The built-in test runner's semantics — default concurrency, per-file isolation, glob handling, reporter output — changed materially across 20 → 22 → 24. This is the one genuinely version-sensitive script in the repo and needs to be run, not reasoned about.

ota-test.yml's Node 22 is a real floor, not drift

website/electron/scripts/ota-drive.js:73 constructs a bare global WebSocket — there is no ws dependency in website/electron/package.json, so it must come from the runtime. Per Node's globals docs the global was added in v20.10.0 behind --experimental-websocket and only unflagged in v22.0.0; ota-test.yml:150 invokes the script bare. On Node 20 it dies with ReferenceError: WebSocket is not defined. It is unavoidable by design — CDP is a WebSocket protocol. The pin is correct and must never be lowered; it is also completely undocumented (a 35-line header comment that explains everything else, and one dependabot commit that only touched the action version).

The @types/node situation is worse than one stale pin

Three lockfiles, three different majors, three different causes:

Lockfile Resolved Cause
website/package-lock.json:3964 25.5.0 the overrides pin at website/package.json:126
site/package-lock.json:1900 26.1.1 no constraint at all — every requester asks "*", so it floats
website/electron/package-lock.json:741 20.19.37 electron@33.4.11 hard-depends on @types/node: "^20.9.0"

So site/ typechecks against Node 26 types on a Node 20 runtime, and website/ against Node 25 types — and Node 25 is itself EOL (2026-06-01). This is invisible because skipLibCheck: true is set in all three real projects and no tsconfig declares types, which converts a class of build-time errors into runtime errors. DefinitelyTyped no longer ships a types/node/v20 folder at all, so Node 20 types are unmaintained upstream. .github/dependabot.yml has only a github-actions entry and no npm ecosystem, so nothing will ever propose a fix.

vite@5.3.6 is the weakest link, independently of this bump

Pinned exact at website/package.json:110. Per vite's own release policy only the latest minor, previous major's latest minor, and previous minor are supported; with latest at 8.x that puts 5.3.6 two majors out of support — no fixes, no security patches. Its engines (^18.0.0 || >=20.0.0) nominally admits 24, but vite only started CI-testing on Node 24 in vite 7.0.0, and the bundled esbuild@0.21.5 (2024-06-09) predates Node 24's release by ~11 months and was never tested against it upstream. Note vitest 3.2.4 caps its vite dep at ^7, so vite 7 is reachable without touching vitest; vite 8 would force vitest and @vitest/coverage-v8 to move too.

Verdict: target Node 24 (Active LTS, EOL 2028-04-30). Node 22 is already in maintenance with only ~9 months of headroom. Node 26 is premature (LTS on 2026-10-28) and from Node 25 onward official Linux binaries additionally require the libatomic runtime at execution time (per BUILDING.md), which adds a deployment prerequisite.

Proposed fix

Sequenced so each step is independently reviewable:

  1. Pin the toolchain. Add .nvmrc (24) and a mise.toml entry so local dev and CI agree; set engines.node to >=22 in website/package.json (allow 22 for contributors, target 24 in CI).
  2. Bump CI. Replace node-version: 20 with 24 in the 10 floating sites; update dependency-vulnerability.yml:24 to a 24.x patch and test/test_dependency_vulnerability_gate.py:133 in the same commit; leave ota-test.yml:68's 22 alone or raise it to 24, never lower; separately, align ota-test.yml:66 onto the same SHA-pinned actions/setup-node the other 11 jobs use (it is the only floating tag in the repo).
  3. Unify the floors. Raise TARGET_VERSION to 24 in ensure-node.sh:14 and NODE_VERSION in install.sh:44; move MIN_VERSION to 22; switch kirocrew-ec2.yaml:380 off the pub_20.x NodeSource repo. Note that install.sh:246-251 (apt-get install -y nodejs npm / dnf install -y nodejs) and install.sh:254 (brew install node) ignore NODE_VERSION entirely and then only report what landed — Debian/Ubuntu can install a Node far below any declared floor. Those paths need a post-install version assertion, not just a new constant.
  4. Collapse the Python constants. Single shared _MIN_NODE_VERSION (22) imported by cli.py, cli_doctor.py, and cli_chat.py so doctor and chat can never disagree again; update the six user-facing message strings.
  5. Reconcile @types/node. Re-pin website/package.json:126 from 25.5.0 to 24.x so types match the runtime 1:1; give site/ an explicit pin so it stops floating (it is at 26.1.1 today); add an npm ecosystem entry to .github/dependabot.yml so this cannot silently diverge again.
  6. Fix the docs — one floor, stated once. The list is larger than the six above: also Makefile:33, minimal_install.sh:14,55,56, setup.sh:65, install.sh:339, docs/windows-install.md:35, docs/system-specs/modules/security.md:194 (hardcodes "CI pins Node 20.19.4"), docs/system-specs/modules/acp-client.md:19, and site/src/SectionsBottom.tsx:74 — the public marketing page says "Node.js 18+".
  7. Add a regression gate so this cannot silently rot again: a test asserting every node-version: in .github/workflows/** matches .nvmrc, plus a check that the pinned major is not past its EOL date in nodejs/Release/schedule.json.
  8. Electron upgrade — separate issue. Moving ^33.0.0 onto a supported line (latest three majors) is a materially larger change (Chromium/V8 breaking changes, electron-builder@25 may also need bumping, notarization and OTA update paths need re-testing). Tracking it here only as a dependency, not doing it in the same PR.

Out of scope

  • docker/Dockerfile contains no Node at all (FROM python:3.12-slim-trixie), yet frontend.py:202,261 and apps/backend.py:330,338 call shutil.which("npm")/which("node") at runtime. That is a separate gap worth its own issue.
  • Unpinned indirect Node consumers, invisible to any node-version grep: codex-review.yml:98 runs npm install -g @openai/codex with no setup-node step at all, so the GPT review gate executes on whatever Node the runner ships; four anthropics/claude-code-action call sites (claude-review.yml:132, design-review.yml:69, longterm-arbiter.yml:244, ux-review.yml:94) likewise. Separately, code-review.yml:218, nightly.yml:110, and release.yml:70 inherit pinned Node through reusable-workflow calls — grepping those three files reports "no Node" and is wrong.
  • Nine call sites shell out to node/npm/npx with a presence check but no version check (record_browser.py:51, apps/registry.py:1751, mcp_playwright_proxy.py:640, scripts/check_npm_audit.py:173, and others). Worth its own issue because the failure mode is not just unversioned, it is silent: apps/backend.py:767-774 logs and returns None when no node is found, and routes.py:952 still answers the enable request with a success-shaped HTTP 200 — the problem only surfaces later as a generic 502 "app '<name>' has no reachable backend" that names neither node nor the search order. The design-critique app degrades the same way, into a user-facing "couldn't see this screen" list rather than a diagnosable error.

Metadata

Metadata

Assignees

Labels

area: ciGitHub Actions, AI review gates, readiness checksarea: coreCore runtime utilities, config/paths, process singletonsarea: packagingInstaller, uninstaller, Electron shell, updaterclaimedAn automated fix is in progresssecurityHardening, trust boundary, or sandbox escape

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions