Merge pull request #1108 from infinito-nexus/fix/install/node-lts-runtime/1088

fix(install): replace an end-of-life distro Node.js with the current LTS
This commit is contained in:
kevinveenbirkenbach authored and GitHub committed 2026-10-21 17:01:45 +02:00
commit 9e62a63ca0
6 files changed
+354 -7

No files matched your search

+137 -2
View File
@@ -2,17 +2,152 @@
from __future__ import annotations
import datetime as dt
import hashlib
import tempfile
import unittest
import unittest.mock as mock
from pathlib import Path
from utils.install import node as node_mod
SCHEDULE = {
"v20": {"end": "2026-04-30"},
"v22": {"end": "2027-04-30"},
"v24": {"end": "2028-04-30"},
}
TODAY = dt.date(2026, 10, 21)
class TestLineIsSupported(unittest.TestCase):
def test_line_before_its_end_is_supported(self) -> None:
self.assertTrue(node_mod.line_is_supported("v22", SCHEDULE, TODAY))
def test_line_on_its_end_date_is_supported(self) -> None:
self.assertTrue(
node_mod.line_is_supported("v20", SCHEDULE, dt.date(2026, 4, 30))
)
def test_line_past_its_end_is_not_supported(self) -> None:
self.assertFalse(node_mod.line_is_supported("v20", SCHEDULE, TODAY))
def test_unknown_line_is_not_supported(self) -> None:
self.assertFalse(node_mod.line_is_supported("v18", SCHEDULE, TODAY))
class TestNewestLts(unittest.TestCase):
def test_skips_current_releases(self) -> None:
index = [
{"version": "v26.1.0", "lts": False},
{"version": "v24.22.1", "lts": "Krypton"},
{"version": "v22.30.0", "lts": "Jod"},
]
self.assertEqual(node_mod.newest_lts(index), "v24.22.1")
def test_raises_without_lts(self) -> None:
with self.assertRaises(RuntimeError):
node_mod.newest_lts([{"version": "v26.1.0", "lts": False}])
class TestTarballName(unittest.TestCase):
def test_maps_x86_64(self) -> None:
self.assertEqual(
node_mod.tarball_name("v24.22.1", "Linux", "x86_64"),
"node-v24.22.1-linux-x64.tar.gz",
)
def test_maps_aarch64(self) -> None:
self.assertEqual(
node_mod.tarball_name("v24.22.1", "Linux", "aarch64"),
"node-v24.22.1-linux-arm64.tar.gz",
)
def test_raises_for_unknown_machine(self) -> None:
with self.assertRaises(RuntimeError):
node_mod.tarball_name("v24.22.1", "Linux", "riscv64")
def test_refuses_a_host_that_is_not_linux(self) -> None:
with self.assertRaises(RuntimeError):
node_mod.tarball_name("v24.22.1", "Darwin", "x86_64")
class TestExpectedSha256(unittest.TestCase):
def test_finds_the_listed_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
shasums = Path(tmp) / "SHASUMS256.txt"
shasums.write_text(
"aaa node-v24.22.1-linux-arm64.tar.gz\n"
"bbb node-v24.22.1-linux-x64.tar.gz\n",
encoding="utf-8",
)
self.assertEqual(
node_mod.expected_sha256(shasums, "node-v24.22.1-linux-x64.tar.gz"),
"bbb",
)
def test_raises_for_an_unlisted_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
shasums = Path(tmp) / "SHASUMS256.txt"
shasums.write_text("aaa other.tar.gz\n", encoding="utf-8")
with self.assertRaises(RuntimeError):
node_mod.expected_sha256(shasums, "node-v24.22.1-linux-x64.tar.gz")
class TestInstallUpstreamLts(unittest.TestCase):
def test_refuses_a_tarball_whose_checksum_differs(self) -> None:
name = "node-v24.22.1-linux-x64.tar.gz"
def fake_download(url: str, output: str, **_kwargs: object) -> None:
path = Path(output)
if url.endswith("index.json"):
path.write_text(
'[{"version": "v24.22.1", "lts": "Krypton"}]', encoding="utf-8"
)
elif url.endswith("SHASUMS256.txt"):
listed = hashlib.sha256(b"published").hexdigest()
path.write_text(f"{listed} {name}\n", encoding="utf-8")
else:
path.write_bytes(b"tampered")
with (
mock.patch.object(node_mod, "download_file", side_effect=fake_download),
mock.patch.object(node_mod, "_verify_signature"),
mock.patch.object(node_mod.platform, "system", return_value="Linux"),
mock.patch.object(node_mod.platform, "machine", return_value="x86_64"),
mock.patch.object(node_mod, "run_privileged") as extract,
self.assertRaises(RuntimeError),
):
node_mod.install_upstream_lts()
extract.assert_not_called()
class TestEnsureNodePresent(unittest.TestCase):
def test_delegates_to_the_shared_provisioner(self) -> None:
with mock.patch.object(node_mod, "ensure_command_present") as ensure:
def setUp(self) -> None:
node_mod.ensure_node_present.cache_clear()
self.addCleanup(node_mod.ensure_node_present.cache_clear)
def _run(self, line: str) -> mock.MagicMock:
def fake_download(_url: str, output: str, **_kwargs: object) -> None:
Path(output).write_text(
'{"v20": {"end": "2026-04-30"}, "v22": {"end": "2099-04-30"}}',
encoding="utf-8",
)
with (
mock.patch.object(node_mod, "ensure_command_present") as ensure,
mock.patch.object(node_mod, "installed_line", return_value=line),
mock.patch.object(node_mod, "download_file", side_effect=fake_download),
mock.patch.object(node_mod, "install_upstream_lts") as upstream,
):
node_mod.ensure_node_present()
ensure.assert_called_once_with("node")
return upstream
def test_keeps_a_supported_distro_node(self) -> None:
self._run("v22").assert_not_called()
def test_replaces_an_end_of_life_distro_node(self) -> None:
self._run("v20").assert_called_once_with()
def test_propagates_the_hard_failure(self) -> None:
with (
@@ -10,6 +10,19 @@ from utils.install import npm as npm_mod
class TestEnsureNpmPresent(unittest.TestCase):
def setUp(self) -> None:
patcher = mock.patch("utils.install.node.ensure_node_present")
self.ensure_node = patcher.start()
self.addCleanup(patcher.stop)
def test_provides_a_supported_node_first(self) -> None:
with (
mock.patch.object(npm_mod.shutil, "which", return_value="/usr/bin/npm"),
mock.patch("utils.install.system_pkg.install_command_via_pkg"),
):
npm_mod.ensure_npm_present()
self.ensure_node.assert_called_once_with()
def test_present_noop(self) -> None:
with (
mock.patch.object(npm_mod.shutil, "which", return_value="/usr/bin/npm"),
+3 -1
View File
@@ -15,6 +15,7 @@ Modules at this level are channel-agnostic primitives; per-tool policy lives one
| `primitives.py` | Logging, sudo wrapping, PATH mutation, and stdlib-based URL download. |
| `pip.py` | Venv-aware pip installer with `--break-system-packages` and `--user` fallback. |
| `npm.py` | Global / local npm install with per-user `--prefix` fallback. |
| `node.py` | Node.js runtime that is still supported upstream: the distro package, or the newest LTS from nodejs.org (signature- and checksum-verified) when the distro line has reached its end of life. |
| `github_release.py` | Latest-tag resolver and asset downloader for GitHub releases. |
| `system_pkg.py` | Per-manager dispatch for `pacman` / `apt-get` / `dnf` / `yum` / `brew`. |
| `lint/` | One module per tool, each exposing `ensure()`. See [lint README](lint/README.md). |
@@ -33,5 +34,6 @@ The matching dispatcher [wrapper.sh](../../scripts/install/wrapper.sh) routes `m
- `primitives.py` MUST NOT import from any other module in this package.
- `pip.py`, `npm.py`, `github_release.py`, and `system_pkg.py` MAY import from `primitives.py`.
- `npm.py` MAY import from `system_pkg.py` to bootstrap npm itself; no other cross-imports between these four are allowed.
- `npm.py` MAY import from `system_pkg.py` and `node.py` to bootstrap npm itself; no other cross-imports between these four are allowed.
- `node.py` MAY import from `primitives.py` and `system_pkg.py`.
- Per-tool modules under `lint/` MUST NOT import each other (see [lint README](lint/README.md)).
+191 -4
View File
@@ -1,17 +1,204 @@
"""Node.js runtime provisioning for the JavaScript unit suite."""
"""Node.js runtime provisioning for the lint toolchain and the JavaScript unit suite."""
from __future__ import annotations
import datetime as dt
import functools
import hashlib
import json
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
from utils.install.primitives import download_file, log, run_privileged
from utils.install.system_pkg import ensure_command_present
DIST_URL = "https://nodejs.org/dist"
SCHEDULE_URL = "https://raw.githubusercontent.com/nodejs/Release/main/schedule.json"
KEYRING_URL = "https://raw.githubusercontent.com/nodejs/release-keys/main/gpg-only-active-keys/pubring.kbx"
PREFIX = Path("/usr/local")
ARCHITECTURES = {"x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}
def ensure_node_present() -> None:
"""Install the Node.js runtime through the system package manager.
def _load_json(path: Path) -> object:
with path.open(encoding="utf-8") as handle:
return json.load(handle)
def installed_line() -> str:
"""Return the release line of the ``node`` on PATH, such as ``v20``.
Returns:
The major version prefixed with ``v``, as the release schedule keys it.
"""
version = subprocess.run(
["node", "--version"], check=True, capture_output=True, text=True
).stdout.strip()
return version.split(".", 1)[0]
def line_is_supported(line: str, schedule: dict, today: dt.date) -> bool:
"""Tell whether a release line still receives upstream fixes.
Args:
line: release line such as ``v20``.
schedule: the parsed nodejs/Release ``schedule.json``.
today: the date the end of life is compared against.
Returns:
False for an unknown line and for a line past its ``end`` date.
"""
entry = schedule.get(line)
if entry is None:
return False
return dt.date.fromisoformat(entry["end"]) >= today
def newest_lts(index: list) -> str:
"""Return the version of the newest LTS release in ``index.json``.
Args:
index: the parsed ``https://nodejs.org/dist/index.json``, newest first.
Raises:
RuntimeError: node is still absent after the install attempt.
RuntimeError: the index lists no LTS release.
"""
for release in index:
if release["lts"]:
return release["version"]
raise RuntimeError(f"{DIST_URL}/index.json lists no LTS release")
def tarball_name(version: str, system: str, machine: str) -> str:
"""Return the Linux tarball name of a release for this host.
Args:
version: release version such as ``v24.22.1``.
system: ``platform.system()`` of the host.
machine: ``platform.machine()`` of the host.
Raises:
RuntimeError: the host is not Linux, or Node.js publishes no Linux
build for the machine.
"""
if system != "Linux":
raise RuntimeError(
f"The upstream Node.js install supports Linux only; install a "
f"supported Node.js through the package manager on {system}."
)
arch = ARCHITECTURES.get(machine)
if arch is None:
raise RuntimeError(f"No Node.js Linux build for machine {machine!r}")
return f"node-{version}-linux-{arch}.tar.gz"
def expected_sha256(shasums: Path, name: str) -> str:
"""Return the checksum ``SHASUMS256.txt`` records for a file.
Args:
shasums: the downloaded ``SHASUMS256.txt``.
name: file name to look up.
Raises:
RuntimeError: the file is not listed.
"""
with shasums.open(encoding="utf-8") as handle:
for line in handle:
digest, _, listed = line.strip().partition(" ")
if listed == name:
return digest
raise RuntimeError(f"{name} is not listed in {shasums.name}")
def _verify_signature(workdir: Path, shasums: Path, signature: Path) -> None:
ensure_command_present("gpgv")
keyring = workdir / "pubring.kbx"
download_file(KEYRING_URL, str(keyring))
subprocess.run(
["gpgv", "--keyring", str(keyring), str(signature), str(shasums)],
check=True,
)
def _sha256(path: Path) -> str:
with path.open("rb") as handle:
return hashlib.file_digest(handle, "sha256").hexdigest()
def install_upstream_lts() -> str:
"""Install the newest Node.js LTS from nodejs.org under ``PREFIX``.
Returns:
The installed version.
Raises:
RuntimeError: the checksum does not match, or ``node`` on PATH is not
the installed release afterwards.
subprocess.CalledProcessError: the signature does not verify.
"""
with tempfile.TemporaryDirectory() as tmp:
workdir = Path(tmp)
index = workdir / "index.json"
download_file(f"{DIST_URL}/index.json", str(index))
version = newest_lts(_load_json(index))
name = tarball_name(version, platform.system(), platform.machine())
shasums = workdir / "SHASUMS256.txt"
signature = workdir / "SHASUMS256.txt.sig"
download_file(f"{DIST_URL}/{version}/SHASUMS256.txt", str(shasums))
download_file(f"{DIST_URL}/{version}/SHASUMS256.txt.sig", str(signature))
_verify_signature(workdir, shasums, signature)
tarball = workdir / name
log(f"Installing Node.js {version} from {DIST_URL} into {PREFIX}")
download_file(f"{DIST_URL}/{version}/{name}", str(tarball), timeout=300.0)
if _sha256(tarball) != expected_sha256(shasums, name):
raise RuntimeError(f"{name} does not match its SHASUMS256.txt entry")
top = name.removesuffix(".tar.gz")
run_privileged(
[
"tar",
"-xzf",
str(tarball),
"-C",
str(PREFIX),
"--strip-components=1",
f"{top}/bin",
f"{top}/include",
f"{top}/lib",
f"{top}/share",
]
)
resolved = shutil.which("node")
line = installed_line()
if resolved != str(PREFIX / "bin" / "node") or line != version.split(".", 1)[0]:
raise RuntimeError(
f"node on PATH is {resolved!r} ({line}) after installing {version} "
f"into {PREFIX}"
)
return version
@functools.cache
def ensure_node_present() -> None:
"""Provide a ``node`` whose release line is still supported upstream.
Raises:
RuntimeError: node is still absent after the install attempt, or the
upstream install failed its checks.
"""
ensure_command_present("node")
line = installed_line()
with tempfile.TemporaryDirectory() as tmp:
schedule = Path(tmp) / "schedule.json"
download_file(SCHEDULE_URL, str(schedule))
supported = line_is_supported(
line, _load_json(schedule), dt.datetime.now(dt.UTC).date()
)
if supported:
return
log(f"Node.js {line} on PATH has reached its end of life upstream.")
install_upstream_lts()
if __name__ == "__main__":
+3
View File
@@ -10,6 +10,9 @@ from utils.install.primitives import ensure_dir_on_path, install_with_optional_s
def ensure_npm_present() -> None:
from utils.install.node import ensure_node_present
ensure_node_present()
if shutil.which("npm") is not None:
return
+7
View File
@@ -143,6 +143,13 @@ _COMMAND_PACKAGES: dict[str, dict[str, list[str]]] = {
"shfmt": {m: ["shfmt"] for m in _SUPPORTED},
"shellcheck": {m: ["shellcheck"] for m in _SUPPORTED},
"unzip": {m: ["unzip"] for m in _SUPPORTED},
"gpgv": {
"pacman": ["gnupg"],
"apt-get": ["gpgv"],
"dnf": ["gnupg2"],
"yum": ["gnupg2"],
"brew": ["gnupg"],
},
"php": {
"pacman": ["php"],
"apt-get": ["php-cli", "php"],