diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 80572b13..d3fbddf6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -195,8 +195,10 @@ jobs: # install.py stays on Python 3.8: the suite runs it end to end under a # real 3.8 interpreter, which import-only and grammar checks miss. 3.9 - # is the floor pipeline.py states for the build scripts, compiled under - # a real 3.9. The last version listed is the default python. + # is the floor pipeline.py states for the build scripts: a real 3.9 + # imports every module under scripts/, so it needs their dependencies. + # jsonschema 4.26 requires 3.10, 4.25.1 is the last release for 3.9. + # The last version listed is the default python. - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: | @@ -204,6 +206,9 @@ jobs: 3.9 3.12 + - name: Install the build scripts' dependencies for Python 3.9 + run: python3.9 -m pip install pyyaml "jsonschema[format-nongpl]==4.25.1" + # markdown: the site validation test renders a wiki line to prove the # heading guard's premise, and skipped on every run without it. The docs # group builds the site as deploy-site.yml does, with the same bounds. diff --git a/scripts/check_release_assets.py b/scripts/check_release_assets.py index 84ef4463..069bf1ef 100644 --- a/scripts/check_release_assets.py +++ b/scripts/check_release_assets.py @@ -22,6 +22,7 @@ import re import subprocess import sys from collections.abc import Iterable +from typing import NamedTuple sys.path.insert(0, os.path.dirname(__file__)) from common import composition_tier, load_database @@ -32,7 +33,14 @@ from largefiles import ( registered_paths, ) -Finding = tuple[str, str, int, int | None] + +class AssetFinding(NamedTuple): + """A difference between the release and the collection.""" + + kind: str + path: str + size: int + published: int | None def expected_assets(db: dict, gitignore_text: str) -> dict[str, int]: @@ -61,7 +69,7 @@ def compare( registered: Iterable[str] | None = None, expected_digests: dict[str, str] | None = None, asset_digests: dict[str, str] | None = None, -) -> list[Finding]: +) -> list[AssetFinding]: """Files the release lacks or serves at another size, sorted by kind. At the same size, the sha256 the release lists for an asset is held @@ -74,9 +82,9 @@ def compare( matches what render_notes() would write is a finding of its own: the page is what a reader checks a download against. """ - findings: list[Finding] = [] + findings: list[AssetFinding] = [] if not notes_current: - findings.append(("notes", "release description", 0, None)) + findings.append(AssetFinding("notes", "release description", 0, None)) names = asset_names([*(registered or ()), *expected]) for path, size in sorted(expected.items()): served = next( @@ -84,14 +92,14 @@ def compare( ) published = assets[served] if served else None if published is None: - findings.append(("missing", path, size, None)) + findings.append(AssetFinding("missing", path, size, None)) elif published != size: - findings.append(("size", path, size, published)) + findings.append(AssetFinding("size", path, size, published)) else: ours = (expected_digests or {}).get(path) theirs = (asset_digests or {}).get(served) if ours and theirs and ours != theirs: - findings.append(("content", path, size, published)) + findings.append(AssetFinding("content", path, size, published)) return sorted(findings) diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index d12efd68..30635ba9 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -110,11 +110,12 @@ class ReadmeRegressions(unittest.TestCase): self.assertEqual(proc.returncode, 1) self.assertIn("needs Python 3.9", proc.stderr) - def test_the_floor_interpreter_compiles_every_script(self): - """The floor said 3.12 for a reason the code no longer carried: every - script compiled under 3.9, and only the guard refused it. Checked - with a real interpreter of the floor version, as the installer's 3.8 - floor is.""" + def test_the_floor_interpreter_imports_every_script(self): + """Every module under scripts/ is loaded by a real interpreter of the + floor version, as the installer's 3.8 floor is proven by running it. + Compiling only checks the grammar: a module-level alias such as + `tuple[int, int | None]` compiles under 3.9 and raises TypeError + when the module loads.""" import shutil # noqa: PLC0415 floor = re.search( @@ -128,21 +129,41 @@ class ReadmeRegressions(unittest.TestCase): self.fail(f"CI must provide {name}") self.skipTest(f"no {name} on PATH") check = ( - "import pathlib, sys\n" - "bad = []\n" + "import importlib, json, pathlib, sys\n" + "sys.path.insert(0, '.')\n" + "failed = {}\n" "for p in sorted(pathlib.Path('scripts').rglob('*.py')):\n" + " if '__pycache__' in p.parts:\n" + " continue\n" + " parts = p.with_suffix('').parts\n" + " if parts[-1] == '__init__':\n" + " parts = parts[:-1]\n" " try:\n" - " compile(p.read_text(encoding='utf-8'), str(p), 'exec')\n" - " except SyntaxError as e:\n" - " bad.append(f'{p}:{e.lineno}')\n" - "print(bad)\n" - "sys.exit(1 if bad else 0)\n" + " importlib.import_module('.'.join(parts))\n" + " except BaseException as e:\n" + " failed[str(p)] = [type(e).__name__, getattr(e, 'name', None), str(e)]\n" + "print(json.dumps(failed))\n" ) proc = subprocess.run( [interpreter, "-I", "-c", check], cwd=ROOT, capture_output=True, - text=True, timeout=300, check=False, + text=True, timeout=600, check=False, + env={**os.environ, "RETROBIOS_OFFLINE": "1"}, ) self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + failed = json.loads(proc.stdout.strip().splitlines()[-1]) + # pyyaml for every build script, jsonschema for validate_schemas: + # without them the floor interpreter cannot load the modules, which + # proves nothing either way. + absent = sorted({ + err[1] for err in failed.values() + if err[0] == "ModuleNotFoundError" and err[1] in ("yaml", "jsonschema") + }) + if absent: + message = f"{name} lacks {', '.join(absent)}" + if os.environ.get("CI"): + self.fail(message) + self.skipTest(message) + self.assertEqual(failed, {}) class ReleaseSigningRegressions(unittest.TestCase):