From 76c7a0acce680ba61430325e59ee802c8be90c53 Mon Sep 17 00:00:00 2001 From: srichs <13246896+srichs@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:09:25 -0700 Subject: [PATCH] Add release preflight checks for artifacts and changelog --- CHANGELOG.md | 2 +- README.md | 7 +- src/devr/release_preflight.py | 118 ++++++++++++++++++++++++++++++++ tests/test_release_preflight.py | 53 ++++++++++++++ 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 src/devr/release_preflight.py create mode 100644 tests/test_release_preflight.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a0d4152..a9118e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] ### Added -- _No changes yet._ +- Added `python -m devr.release_preflight` to run release artifact smoke tests and validate changelog/version consistency before tagging. ## [0.1.0] - 2026-02-17 diff --git a/README.md b/README.md index 5edd487..91adc41 100644 --- a/README.md +++ b/README.md @@ -127,10 +127,9 @@ Use this checklist when cutting a release: - Move completed entries from `Unreleased` into a new version section. - Add release date (`YYYY-MM-DD`) and keep entries grouped by change type. 3. Bump version in `pyproject.toml` under `[project].version`. -4. Build and smoke-test the package artifacts: - - `python -m build` - - `python -m pip install --force-reinstall dist/*.whl` - - `python -m devr --version` +4. Run release preflight checks (artifact smoke tests + changelog/version consistency): + - `python -m devr.release_preflight` + - This builds both wheel and sdist artifacts, installs each in an isolated venv, and verifies both `devr --version` and `python -m devr --version`. 5. Commit release metadata (`CHANGELOG.md`, version bump, and any final docs updates). 6. Tag the release (for example, `vX.Y.Z`) and push branch + tag. 7. Publish to PyPI using your standard release workflow. diff --git a/src/devr/release_preflight.py b/src/devr/release_preflight.py new file mode 100644 index 0000000..9516fbc --- /dev/null +++ b/src/devr/release_preflight.py @@ -0,0 +1,118 @@ +"""Release preflight checks for packaging and changelog consistency.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - exercised on older runtimes + import tomli as tomllib + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +class ReleasePreflightError(RuntimeError): + """Raised when a release preflight check fails.""" + + +def project_version(pyproject_path: Path) -> str: + """Return the ``[project].version`` from ``pyproject.toml``.""" + data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + if not isinstance(version, str) or not version.strip(): + raise ReleasePreflightError("Could not determine [project].version from pyproject.toml") + return version.strip() + + +def changelog_versions(changelog_path: Path) -> list[str]: + """Return version labels from markdown changelog headings.""" + versions: list[str] = [] + pattern = re.compile(r"^## \[(.+?)\]") + for line in changelog_path.read_text(encoding="utf-8").splitlines(): + match = pattern.match(line.strip()) + if match: + versions.append(match.group(1)) + return versions + + +def validate_changelog(changelog_path: Path, version: str) -> None: + """Validate changelog has expected release structure for ``version``.""" + versions = changelog_versions(changelog_path) + if not versions or versions[0] != "Unreleased": + raise ReleasePreflightError("CHANGELOG.md must have '## [Unreleased]' as the first section") + if version not in versions: + raise ReleasePreflightError( + f"CHANGELOG.md is missing a section for version {version!r}. " + "Move completed entries from Unreleased into that release section before tagging." + ) + + +def run_checked(cmd: list[str], cwd: Path) -> None: + """Run command and fail with a clear message on non-zero exit.""" + print(f"$ {' '.join(cmd)}") + completed = subprocess.run(cmd, cwd=cwd, check=False) + if completed.returncode != 0: + raise ReleasePreflightError(f"Command failed with exit code {completed.returncode}: {' '.join(cmd)}") + + +def artifact_path(dist_dir: Path, suffix: str) -> Path: + """Return the first dist artifact matching a suffix.""" + matches = sorted(dist_dir.glob(f"*{suffix}")) + if not matches: + raise ReleasePreflightError(f"No {suffix} artifact found in {dist_dir}") + return matches[0] + + +def smoke_test_artifact(artifact: Path, repo_root: Path) -> None: + """Install an artifact in a temporary venv and run version entrypoint checks.""" + with tempfile.TemporaryDirectory(prefix="devr-release-") as tmp: + venv_dir = Path(tmp) / ".venv" + python_bin = venv_dir / ("Scripts/python.exe" if sys.platform.startswith("win") else "bin/python") + run_checked([sys.executable, "-m", "venv", str(venv_dir)], cwd=repo_root) + run_checked([str(python_bin), "-m", "pip", "install", "--upgrade", "pip"], cwd=repo_root) + run_checked([str(python_bin), "-m", "pip", "install", "--force-reinstall", str(artifact)], cwd=repo_root) + run_checked([str(python_bin), "-m", "devr", "--version"], cwd=repo_root) + run_checked([str(python_bin), "-m", "pip", "show", "devr"], cwd=repo_root) + scripts_dir = python_bin.parent + devr_bin = scripts_dir / ("devr.exe" if sys.platform.startswith("win") else "devr") + run_checked([str(devr_bin), "--version"], cwd=repo_root) + + +def main() -> int: + """Run release preflight checks for changelog, artifacts, and entrypoints.""" + repo_root = REPO_ROOT + pyproject_path = repo_root / "pyproject.toml" + changelog_path = repo_root / "CHANGELOG.md" + dist_dir = repo_root / "dist" + + version = project_version(pyproject_path) + print(f"Detected project version: {version}") + validate_changelog(changelog_path, version) + print("Changelog/version check passed.") + + if dist_dir.exists(): + shutil.rmtree(dist_dir) + run_checked([sys.executable, "-m", "build", "--version"], cwd=repo_root) + run_checked([sys.executable, "-m", "build"], cwd=repo_root) + + wheel = artifact_path(dist_dir, ".whl") + sdist = artifact_path(dist_dir, ".tar.gz") + + print(f"Smoke testing wheel artifact: {wheel.name}") + smoke_test_artifact(wheel, repo_root) + print(f"Smoke testing sdist artifact: {sdist.name}") + smoke_test_artifact(sdist, repo_root) + + print("Release preflight checks completed successfully.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_preflight.py b/tests/test_release_preflight.py new file mode 100644 index 0000000..4797c63 --- /dev/null +++ b/tests/test_release_preflight.py @@ -0,0 +1,53 @@ +from pathlib import Path + +import pytest + +from devr.release_preflight import ( + ReleasePreflightError, + changelog_versions, + project_version, + validate_changelog, +) + + +def test_project_version_reads_pyproject(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") + + assert project_version(pyproject) == "1.2.3" + + +def test_validate_changelog_requires_unreleased_first(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("## [0.1.0] - 2026-01-01\n", encoding="utf-8") + + with pytest.raises(ReleasePreflightError, match="Unreleased"): + validate_changelog(changelog, "0.1.0") + + +def test_validate_changelog_requires_current_version_section(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("## [Unreleased]\n\n## [0.0.9] - 2026-01-01\n", encoding="utf-8") + + with pytest.raises(ReleasePreflightError, match="0.1.0"): + validate_changelog(changelog, "0.1.0") + + +def test_changelog_versions_extracts_headings(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "\n".join( + [ + "# Changelog", + "", + "## [Unreleased]", + "", + "## [0.1.0] - 2026-02-17", + "", + "## [0.0.9] - 2026-01-10", + ] + ), + encoding="utf-8", + ) + + assert changelog_versions(changelog) == ["Unreleased", "0.1.0", "0.0.9"]