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))