From ab1866fd661a0c378578cb6c1f1f7519bbc3c9fb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:17:31 +0100 Subject: [PATCH] feat: compare the site npm pins with the registry The ci area of check_freshness read the PyPI and action pins only. The exact versions website/package.json pins (astro, starlight) now get a row each, STALE once npm publishes a newer release. --- scripts/check_freshness.py | 53 ++++++++++++++++++++++++++++++++++- tests/test_check_freshness.py | 38 +++++++++++++++++++++++++ wiki/tools.md | 5 ++-- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/scripts/check_freshness.py b/scripts/check_freshness.py index fac97bc71..425bd6ad3 100644 --- a/scripts/check_freshness.py +++ b/scripts/check_freshness.py @@ -79,6 +79,9 @@ TOSEC_DOWNLOADS = "https://www.tosecdev.org/downloads" MAME_REPO = "https://github.com/mamedev/mame" FBNEO_REPO = "https://github.com/libretro/FBNeo" PYPI_URL = "https://pypi.org/pypi/{name}/json" +NPM_URL = "https://registry.npmjs.org/{name}/latest" +# The site's npm dependencies, pinned to exact versions and built by Deploy Site. +SITE_PACKAGE = REPO_ROOT / "website" / "package.json" INSTALLERS = ("install.sh", "install.ps1") SHOWN_ITEMS = 4 # items named in a diff summary before ", ..." @@ -1355,6 +1358,53 @@ def pypi_latest(name: str) -> str | None: return str(info.get("version") or "") or None +def parse_npm_pins(text: str) -> dict[str, str]: + """Package -> the version a package.json names, dependencies of both kinds.""" + data = json.loads(text) + pins: dict[str, str] = {} + for key in ("dependencies", "devDependencies"): + pins.update({str(k): str(v) for k, v in (data.get(key) or {}).items()}) + return pins + + +def npm_latest(name: str) -> str | None: + payload = upstream._http_json(NPM_URL.format(name=name)) # noqa: SLF001 + if not isinstance(payload, dict): + return None + return str(payload.get("version") or "") or None + + +def _npm_findings() -> list[Finding]: + """One row per site dependency: an exact pin is stale once npm's latest + release is newer, since nothing else asks npm.""" + findings = [] + for name, pinned in sorted(parse_npm_pins(SITE_PACKAGE.read_text(encoding="utf-8")).items()): + subject = f"npm:{name}" + if not re.fullmatch(r"\d+(\.\d+)*", pinned): + findings.append(Finding("ci", subject, UNKNOWN, local=pinned, detail="a range, not an exact pin")) + continue + try: + latest = npm_latest(name) + except upstream.UpstreamError as exc: + findings.append(Finding("ci", subject, UNKNOWN, local=pinned, detail=str(exc))) + continue + if latest is None: + findings.append(Finding("ci", subject, UNKNOWN, local=pinned, detail="not on npm")) + continue + newer = _version_tuple(latest) > _version_tuple(pinned) + findings.append( + Finding( + "ci", + subject, + STALE if newer else OK, + local=pinned, + remote=latest, + detail="a newer release is out" if newer else "", + ) + ) + return findings + + def check_ci(cache_dir: str, offline: bool) -> list[Finding]: findings = [_check_installer_pins()] workflows = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")) @@ -1366,8 +1416,9 @@ def check_ci(cache_dir: str, offline: bool) -> list[Finding]: pins.setdefault(name, spec) actions.update(parse_action_pins(text)) if offline: - findings.append(Finding("ci", "workflow pins", SKIPPED, detail="offline")) + findings.append(Finding("ci", "workflow and site pins", SKIPPED, detail="offline")) return findings + findings.extend(_npm_findings()) for name, spec in sorted(pins.items()): try: latest = pypi_latest(name) diff --git a/tests/test_check_freshness.py b/tests/test_check_freshness.py index ca72aefc9..72846872f 100644 --- a/tests/test_check_freshness.py +++ b/tests/test_check_freshness.py @@ -8,6 +8,7 @@ report is folded, since each of those decides whether a row says STALE. from __future__ import annotations +import json import os import sys import tempfile @@ -353,6 +354,43 @@ class PinParsingTests(unittest.TestCase): self.assertTrue(cf.specifier_allows(">=10.14", "12.0.1")) +class SiteNpmPins(unittest.TestCase): + """The site's npm dependencies are pinned to exact versions in + website/package.json, and the ci area compared only the PyPI and action + pins: astro 7.4.2 stayed behind 7.4.7 with no STALE row.""" + + def test_both_dependency_kinds_are_read(self): + pins = cf.parse_npm_pins( + '{"dependencies": {"astro": "7.4.2"}, "devDependencies": {"x": "^1.0.0"}}' + ) + self.assertEqual(pins, {"astro": "7.4.2", "x": "^1.0.0"}) + + def test_a_newer_release_is_stale(self): + with tempfile.TemporaryDirectory() as tmp: + package = Path(tmp) / "package.json" + package.write_text(json.dumps({"dependencies": { + "astro": "7.4.2", "@astrojs/starlight": "0.42.9", "y": "^2.0.0", + }})) + latest = {"astro": "7.4.7", "@astrojs/starlight": "0.42.9"} + def answer(url): + name = url.split("registry.npmjs.org/", 1)[1].rsplit("/latest", 1)[0] + return {"version": latest[name]} + with mock.patch.object(cf, "SITE_PACKAGE", package), \ + mock.patch.object(cf.upstream, "_http_json", side_effect=answer): + rows = {f.subject: f for f in cf._npm_findings()} + self.assertEqual(rows["npm:astro"].status, cf.STALE) + self.assertEqual(rows["npm:astro"].remote, "7.4.7") + self.assertEqual(rows["npm:@astrojs/starlight"].status, cf.OK) + self.assertEqual(rows["npm:y"].status, cf.UNKNOWN) + + def test_the_ci_area_reads_the_site_pins(self): + with mock.patch.object(cf, "_npm_findings", return_value=[]) as npm, \ + mock.patch.object(cf, "parse_pip_pins", return_value={}), \ + mock.patch.object(cf, "parse_action_pins", return_value={}): + cf.check_ci("unused", offline=False) + npm.assert_called_once() + + class RomFolderPins(unittest.TestCase): """The rom folders of a setup were pinned by hand and nothing compared the pins with the platform file or the project's releases.""" diff --git a/wiki/tools.md b/wiki/tools.md index 31086a61e..9d9c2d08a 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -688,8 +688,9 @@ directories are checked against their upstream, dump catalogs and romset snapshots against the catalog they were read from (Redump DAT versions, the No-Intro daily rebuild, the newest TOSEC pack, the newest MAME release, the git blobs of the FBNeo DATs), the game catalogs of `games/catalogs/` against -the No-Intro pack and the Redump datfiles they were read from, and the CI -toolchain against PyPI and the actions' latest releases. +the No-Intro pack and the Redump datfiles they were read from, the CI +toolchain against PyPI and the actions' latest releases, and the site's exact +npm pins in `website/package.json` against the npm registry. The `native` area needs no network. It reads the cache `export_native.py` patches from and reports an original that is absent, that came from another