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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 118 additions & 0 deletions src/devr/release_preflight.py
Original file line number Diff line number Diff line change
@@ -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())
53 changes: 53 additions & 0 deletions tests/test_release_preflight.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading