From 6e98c77f3ae91cd214b56fcd9ebfbe3a9553ba65 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 10 Aug 2026 14:25:27 +0200 Subject: [PATCH] ci(release): publish simple_module_site_lock to PyPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit site_lock shipped in #240 and is offered by the CLI catalog, but was never added to the publish-pypi matrix — so every release built its wheel and then dropped it on the floor. `pip install simple_module_site_lock` still 404s. Add it to the matrix, and add a cross-check to scripts/check_metadata.py (already run by `make lint`) so a workspace package can never again exist without a release job — and a stale matrix entry can't linger without a package. This bit us once with simple_module_branding (35bb8df) and again here. Claude-Session: https://claude.ai/code/session_01Hg8xa2po2YhoLjyxXeWwFm --- .github/workflows/release.yml | 1 + scripts/check_metadata.py | 69 ++++++++++- scripts/tests/test_check_release_matrix.py | 129 +++++++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/test_check_release_matrix.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc730fbf..87c3179d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,7 @@ jobs: - simple_module_keycloak - simple_module_permissions - simple_module_settings + - simple_module_site_lock - simple_module_users environment: name: pypi diff --git a/scripts/check_metadata.py b/scripts/check_metadata.py index 37855b35..30032e28 100644 --- a/scripts/check_metadata.py +++ b/scripts/check_metadata.py @@ -1,6 +1,10 @@ -"""Enforce per-package metadata rules across all 17 published packages. +"""Enforce per-package metadata rules across every published package. Rules: + * Every package under framework/* and modules/* must appear in the + publish-pypi matrix of `.github/workflows/release.yml` (and vice versa) — + a workspace package missing from the matrix builds a wheel on every + release and then silently never reaches PyPI. * Every `pyproject.toml` under framework/* and modules/* must have: - name starting with "simple_module_" - non-placeholder description (not "Add your description here" or empty) @@ -24,6 +28,7 @@ import argparse import json +import re import sys from pathlib import Path @@ -31,6 +36,13 @@ CANONICAL_REPO = "https://github.com/antosubash/simple_module_python" PLACEHOLDER_DESCRIPTIONS = {"", "Add your description here"} +RELEASE_WORKFLOW = Path(".github/workflows/release.yml") + +# The publish-pypi job body: every line after the job key until the next +# sibling job (a line indented exactly two spaces). +_PUBLISH_JOB_RE = re.compile(r"^ publish-pypi:\n(?P(?:(?!^ \S).*\n)*)", re.M) +# The flat `package:` list inside that job's build matrix. +_MATRIX_RE = re.compile(r"^ +package:\n(?P(?: +- \S+\n)+)", re.M) def check_python_package(pyproject: Path) -> list[str]: @@ -128,6 +140,60 @@ def discover_npm_packages(root: Path) -> list[Path]: return found +def parse_publish_matrix(workflow: Path) -> list[str]: + """Return the distribution names in the release workflow's publish-pypi matrix. + + Parsed with a targeted regex rather than a YAML dependency — the block is a + flat list in a file we own. A parse miss raises instead of returning an + empty list, so a workflow reshuffle fails loudly here rather than quietly + reporting that every package is unpublished. + """ + text = workflow.read_text(encoding="utf-8") + job = _PUBLISH_JOB_RE.search(text) + if job is None: + raise ValueError(f"{workflow}: no 'publish-pypi:' job found") + matrix = _MATRIX_RE.search(job.group("body")) + if matrix is None: + raise ValueError(f"{workflow}: publish-pypi job has no 'package:' matrix list") + return [line.split("- ", 1)[1].strip() for line in matrix.group("items").splitlines()] + + +def check_release_matrix(root: Path) -> list[str]: + """Cross-check discovered workspace packages against the publish-pypi matrix. + + Catches the failure mode where a new module ships, builds, and is offered + by the CLI, but was never added to the release matrix — so `pip install` + of it 404s forever. Hit twice already: simple_module_branding, then + simple_module_site_lock. + """ + workflow = root / RELEASE_WORKFLOW + if not workflow.exists(): + # main() also runs against partial trees (tests, scaffolded apps); + # only enforce the matrix where a release workflow actually exists. + return [] + + published = set(parse_publish_matrix(workflow)) + packaged = set() + for pyproject in discover_python_packages(root): + data = tomlkit.parse(pyproject.read_text(encoding="utf-8")) + name = str(data.get("project", {}).get("name", "")) + if name: + packaged.add(name) + + errors: list[str] = [] + for name in sorted(packaged - published): + errors.append( + f"{RELEASE_WORKFLOW}: '{name}' is a workspace package but is missing from the " + "publish-pypi matrix — it would build on release and never reach PyPI" + ) + for name in sorted(published - packaged): + errors.append( + f"{RELEASE_WORKFLOW}: publish-pypi matrix lists '{name}', which is not a " + "workspace package — that release job fails with no matching artifact" + ) + return errors + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -144,6 +210,7 @@ def main(argv: list[str] | None = None) -> int: all_errors.extend(check_python_package(pyproject)) for pkg in discover_npm_packages(root): all_errors.extend(check_npm_package(pkg)) + all_errors.extend(check_release_matrix(root)) if all_errors: for e in all_errors: diff --git a/scripts/tests/test_check_release_matrix.py b/scripts/tests/test_check_release_matrix.py new file mode 100644 index 00000000..527d2288 --- /dev/null +++ b/scripts/tests/test_check_release_matrix.py @@ -0,0 +1,129 @@ +"""Tests for the publish-pypi matrix cross-check in scripts/check_metadata.py. + +Guards the failure mode where a module ships, builds a wheel on every release, +and is offered by the CLI — but was never added to the release workflow's +matrix, so installing it from PyPI 404s forever. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.check_metadata import ( + check_release_matrix, + discover_python_packages, + parse_publish_matrix, +) + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _release_yml(*packages: str) -> str: + """A release workflow stub shaped like the real publish-pypi job.""" + entries = "".join(f" - {p}\n" for p in packages) + return ( + "name: release\n" + "jobs:\n" + " build:\n" + " runs-on: ubuntu-latest\n" + " publish-pypi:\n" + " needs: build\n" + " strategy:\n" + " fail-fast: false\n" + " matrix:\n" + " package:\n" + f"{entries}" + " environment:\n" + " name: pypi\n" + " publish-npm:\n" + " strategy:\n" + " matrix:\n" + " package: [ui, i18n, tsconfig]\n" + ) + + +def _pkg(name: str) -> str: + return f""" +[project] +name = "{name}" +version = "0.0.1" +description = "A real description" +readme = "README.md" +license = "MIT" +keywords = ["simple-module"] + +[project.urls] +Repository = "https://github.com/antosubash/simple_module_python" +""" + + +def test_parse_publish_matrix_reads_package_list(tmp_path: Path, writer) -> None: + wf = writer(tmp_path / "release.yml", _release_yml("simple_module_core", "simple_module_db")) + assert parse_publish_matrix(wf) == ["simple_module_core", "simple_module_db"] + + +def test_parse_publish_matrix_ignores_the_npm_job_matrix(tmp_path: Path, writer) -> None: + """publish-npm has its own inline `package:` list — it must not leak in.""" + wf = writer(tmp_path / "release.yml", _release_yml("simple_module_core")) + assert parse_publish_matrix(wf) == ["simple_module_core"] + + +def test_parse_publish_matrix_raises_when_job_missing(tmp_path: Path, writer) -> None: + wf = writer(tmp_path / "release.yml", "name: release\njobs:\n build:\n runs-on: x\n") + with pytest.raises(ValueError, match="publish-pypi"): + parse_publish_matrix(wf) + + +def test_parse_publish_matrix_raises_when_matrix_missing(tmp_path: Path, writer) -> None: + wf = writer( + tmp_path / "release.yml", + "name: release\njobs:\n publish-pypi:\n needs: build\n runs-on: x\n", + ) + with pytest.raises(ValueError, match="package"): + parse_publish_matrix(wf) + + +def test_flags_package_missing_from_matrix(tmp_path: Path, writer) -> None: + writer(tmp_path / "modules/site_lock/pyproject.toml", _pkg("simple_module_site_lock")) + writer(tmp_path / ".github/workflows/release.yml", _release_yml("simple_module_core")) + errors = check_release_matrix(tmp_path) + assert any("simple_module_site_lock" in e and "missing from the" in e for e in errors) + + +def test_flags_stale_matrix_entry(tmp_path: Path, writer) -> None: + writer(tmp_path / "modules/auth/pyproject.toml", _pkg("simple_module_auth")) + writer( + tmp_path / ".github/workflows/release.yml", + _release_yml("simple_module_auth", "simple_module_gone"), + ) + errors = check_release_matrix(tmp_path) + assert any("simple_module_gone" in e and "not a workspace package" in e for e in errors) + + +def test_clean_when_aligned(tmp_path: Path, writer) -> None: + writer(tmp_path / "modules/auth/pyproject.toml", _pkg("simple_module_auth")) + writer(tmp_path / "framework/core/pyproject.toml", _pkg("simple_module_core")) + writer( + tmp_path / ".github/workflows/release.yml", + _release_yml("simple_module_auth", "simple_module_core"), + ) + assert check_release_matrix(tmp_path) == [] + + +def test_skipped_when_workflow_absent(tmp_path: Path, writer) -> None: + """main() also runs against partial trees; no workflow means nothing to check.""" + writer(tmp_path / "modules/auth/pyproject.toml", _pkg("simple_module_auth")) + assert check_release_matrix(tmp_path) == [] + + +def test_real_repo_publishes_every_workspace_package() -> None: + """The invariant this guard exists for, asserted against the real repo.""" + assert check_release_matrix(REPO_ROOT) == [] + + +def test_real_repo_publishes_site_lock() -> None: + matrix = parse_publish_matrix(REPO_ROOT / ".github/workflows/release.yml") + assert "simple_module_site_lock" in matrix + assert len(matrix) == len(discover_python_packages(REPO_ROOT))