diff --git a/src/pkgmgr/actions/release/versioning.py b/src/pkgmgr/actions/release/versioning.py index 2d9c5cf..3dea3a4 100644 --- a/src/pkgmgr/actions/release/versioning.py +++ b/src/pkgmgr/actions/release/versioning.py @@ -4,6 +4,8 @@ Version discovery and bumping helpers for the release workflow. from __future__ import annotations +import re + from pkgmgr.core.git.queries import get_tags from pkgmgr.core.version.semver import ( SemVer, @@ -12,26 +14,60 @@ from pkgmgr.core.version.semver import ( bump_patch, find_latest_version, ) +from pkgmgr.core.version.source import ( + read_cargo_version, + read_changelog_version, + read_debian_changelog_version, + read_flake_version, + read_pkgbuild_version, + read_pyproject_version, + read_spec_version, +) + +FILE_SOURCES = { + "pyproject.toml": read_pyproject_version, + "CHANGELOG.md": read_changelog_version, + "flake.nix": read_flake_version, + "PKGBUILD": read_pkgbuild_version, + "RPM spec": read_spec_version, + "debian/changelog": read_debian_changelog_version, + "Cargo.toml": read_cargo_version, +} +_CORE_VERSION = re.compile(r"^v?(\d+\.\d+\.\d+)") -def determine_current_version() -> SemVer: +def _file_versions(repo_dir: str) -> dict[str, SemVer]: + versions: dict[str, SemVer] = {} + for source, read in FILE_SOURCES.items(): + match = _CORE_VERSION.match(read(repo_dir) or "") + if match: + versions[source] = SemVer.parse(match.group(1)) + return versions + + +def determine_current_version(repo_dir: str) -> SemVer: """ - Determine the current semantic version from Git tags. + Determine the highest version across the latest SemVer tag and every + version file the release writes. - Behaviour: - - If there are no tags or no SemVer-compatible tags, return 0.0.0. - - Otherwise, use the latest SemVer tag as current version. + Args: + repo_dir: repository root holding the version files. + + Returns: + The highest version found, or 0.0.0 when no source has one. """ - tags = get_tags() - if not tags: + versions = _file_versions(repo_dir) + latest = find_latest_version(get_tags() or []) + if latest is not None: + versions["tag"] = latest[1] + if not versions: return SemVer(0, 0, 0) - latest = find_latest_version(tags) - if latest is None: - return SemVer(0, 0, 0) - - _tag, ver = latest - return ver + highest = max(versions.values()) + if len(set(versions.values())) > 1: + found = ", ".join(f"{source} {ver}" for source, ver in versions.items()) + print(f"[INFO] Version sources disagree ({found}); starting from {highest}.") + return highest def bump_semver(current: SemVer, release_type: str) -> SemVer: diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py index 31a58f3..319f733 100644 --- a/src/pkgmgr/actions/release/workflow.py +++ b/src/pkgmgr/actions/release/workflow.py @@ -49,7 +49,8 @@ def _release_impl( # Pull BEFORE making any modifications ensure_clean_and_synced(preview=preview) - current_ver = determine_current_version() + repo_root = os.path.dirname(os.path.abspath(pyproject_path)) + current_ver = determine_current_version(repo_root) new_ver = bump_semver(current_ver, release_type) new_ver_str = str(new_ver) new_tag = new_ver.to_tag(with_prefix=True) @@ -59,7 +60,6 @@ def _release_impl( print(f"Current version: {current_ver}") print(f"New version: {new_ver_str} ({release_type})") - repo_root = os.path.dirname(os.path.abspath(pyproject_path)) paths = resolve_repo_paths(repo_root) # --- Update versioned files ------------------------------------------------ diff --git a/src/pkgmgr/core/version/source.py b/src/pkgmgr/core/version/source.py index e082c0a..dbf0a1b 100644 --- a/src/pkgmgr/core/version/source.py +++ b/src/pkgmgr/core/version/source.py @@ -232,3 +232,40 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None: return None return None + + +def read_cargo_version(repo_dir: str) -> str | None: + """ + Read [package].version from Cargo.toml in repo_dir, if present. + """ + path = os.path.join(repo_dir, "Cargo.toml") + if not os.path.isfile(path): + return None + + try: + import tomllib + except ImportError: + import tomli as tomllib # type: ignore + + try: + with open(path, "rb") as f: + version = (tomllib.load(f).get("package") or {}).get("version") + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + return None + return version.strip() if isinstance(version, str) and version.strip() else None + + +def read_changelog_version(repo_dir: str) -> str | None: + """ + Read the newest release from CHANGELOG.md ("## [X.Y.Z] - date"). + """ + path = os.path.join(repo_dir, "CHANGELOG.md") + if not os.path.isfile(path): + return None + + try: + with open(path, encoding="utf-8") as f: + match = re.search(r"(?m)^##\s+\[(v?\d+\.\d+\.\d+)\]", f.read()) + except (OSError, UnicodeDecodeError): + return None + return match.group(1).strip() if match else None diff --git a/tests/unit/pkgmgr/actions/release/test_versioning.py b/tests/unit/pkgmgr/actions/release/test_versioning.py index adf09e2..c8002a2 100644 --- a/tests/unit/pkgmgr/actions/release/test_versioning.py +++ b/tests/unit/pkgmgr/actions/release/test_versioning.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import tempfile import unittest from unittest.mock import patch @@ -11,12 +13,23 @@ from pkgmgr.core.version.semver import SemVer class TestDetermineCurrentVersion(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.repo = self._tmp.name + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _write(self, name: str, content: str) -> None: + with open(os.path.join(self.repo, name), "w", encoding="utf-8") as f: + f.write(content) + @patch("pkgmgr.actions.release.versioning.get_tags", return_value=[]) def test_determine_current_version_no_tags_returns_zero( self, mock_get_tags, ) -> None: - ver = determine_current_version() + ver = determine_current_version(self.repo) self.assertIsInstance(ver, SemVer) self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0)) mock_get_tags.assert_called_once() @@ -31,12 +44,29 @@ class TestDetermineCurrentVersion(unittest.TestCase): mock_get_tags.return_value = ["v0.1.0", "v1.2.3"] mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 2, 3)) - ver = determine_current_version() + ver = determine_current_version(self.repo) self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3)) mock_get_tags.assert_called_once() mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"]) + @patch("pkgmgr.actions.release.versioning.get_tags", return_value=["v0.3.0"]) + def test_lagging_cargo_version_does_not_lower_the_base(self, _tags) -> None: + self._write("Cargo.toml", '[package]\nname = "x"\nversion = "0.1.0"\n') + self._write("CHANGELOG.md", "# Changelog\n\n## [0.3.0] - 2026-10-30\n") + + self.assertEqual(determine_current_version(self.repo), SemVer(0, 3, 0)) + + @patch("pkgmgr.actions.release.versioning.get_tags", return_value=["v1.0.0"]) + def test_file_ahead_of_the_tag_raises_the_base(self, _tags) -> None: + self._write("pyproject.toml", '[project]\nname = "x"\nversion = "1.4.0"\n') + self._write( + "CHANGELOG.md", + "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-10-01\n", + ) + + self.assertEqual(determine_current_version(self.repo), SemVer(1, 4, 0)) + class TestBumpSemVer(unittest.TestCase): def test_bump_semver_major(self) -> None: