Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ jobs:
- simple_module_keycloak
- simple_module_permissions
- simple_module_settings
- simple_module_site_lock
- simple_module_users
environment:
name: pypi
Expand Down
69 changes: 68 additions & 1 deletion scripts/check_metadata.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -24,13 +28,21 @@

import argparse
import json
import re
import sys
from pathlib import Path

import tomlkit

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<body>(?:(?!^ \S).*\n)*)", re.M)
# The flat `package:` list inside that job's build matrix.
_MATRIX_RE = re.compile(r"^ +package:\n(?P<items>(?: +- \S+\n)+)", re.M)


def check_python_package(pyproject: Path) -> list[str]:
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
129 changes: 129 additions & 0 deletions scripts/tests/test_check_release_matrix.py
Original file line number Diff line number Diff line change
@@ -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))
Loading