feat(release): start from the highest version across all sources
CI / security-codeql (push) Blocked by required conditions
CI / test-unit (push) Blocked by required conditions
CI / test-integration (push) Blocked by required conditions
CI / test-env-virtual (push) Blocked by required conditions
CI / test-env-nix (push) Blocked by required conditions
CI / test-e2e (push) Blocked by required conditions
CI / test-virgin-user (push) Blocked by required conditions
CI / test-virgin-root (push) Blocked by required conditions
CI / lint-shell (push) Blocked by required conditions
CI / lint-python (push) Blocked by required conditions
CI / lint-docker (push) Blocked by required conditions
Docker Linter / Lint Dockerfile (push) Blocked by required conditions
CodeQL Advanced / Check security (none, actions) (push) Blocked by required conditions
CodeQL Advanced / Check security (none, python) (push) Blocked by required conditions
Test Units / test-unit (push) Blocked by required conditions
Test Code Integration / test-integration (push) Blocked by required conditions
Test OS Containers / test-env-virtual (arch) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (centos) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (debian) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (fedora) (push) Blocked by required conditions
Test OS Containers / test-env-virtual (ubuntu) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Blocked by required conditions
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Blocked by required conditions
Test End-To-End / test-e2e (arch) (push) Blocked by required conditions
Test End-To-End / test-e2e (centos) (push) Blocked by required conditions
Test End-To-End / test-e2e (debian) (push) Blocked by required conditions
Test End-To-End / test-e2e (fedora) (push) Blocked by required conditions
Test End-To-End / test-e2e (ubuntu) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (arch) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (centos) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (debian) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (fedora) (push) Blocked by required conditions
Test Virgin User / test-virgin-user (ubuntu) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (arch) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (centos) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (debian) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (fedora) (push) Blocked by required conditions
Test Virgin Root / test-virgin-root (ubuntu) (push) Blocked by required conditions
ShellCheck / lint-shell (push) Blocked by required conditions
Ruff (Python code sniffer) / lint-python (push) Blocked by required conditions

The release base used to be the latest v* tag only. It is now the highest version found in that tag and in every file the release writes: pyproject.toml, CHANGELOG.md, flake.nix, PKGBUILD, the RPM spec, debian/changelog and Cargo.toml. A lagging file can no longer lower the base and a file ahead of the tag raises it; when the sources disagree the release says so and names the version it starts from. Repos whose sources agree release exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-10-31 14:18:07 +01:00
1 parent d823a42edc
commit 810d27badc
4 files changed
+120 -17

No files matched your search

+49 -13
View File
@@ -4,6 +4,8 @@ Version discovery and bumping helpers for the release workflow.
from __future__ import annotations from __future__ import annotations
import re
from pkgmgr.core.git.queries import get_tags from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.version.semver import ( from pkgmgr.core.version.semver import (
SemVer, SemVer,
@@ -12,26 +14,60 @@ from pkgmgr.core.version.semver import (
bump_patch, bump_patch,
find_latest_version, 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: Args:
- If there are no tags or no SemVer-compatible tags, return 0.0.0. repo_dir: repository root holding the version files.
- Otherwise, use the latest SemVer tag as current version.
Returns:
The highest version found, or 0.0.0 when no source has one.
""" """
tags = get_tags() versions = _file_versions(repo_dir)
if not tags: latest = find_latest_version(get_tags() or [])
if latest is not None:
versions["tag"] = latest[1]
if not versions:
return SemVer(0, 0, 0) return SemVer(0, 0, 0)
latest = find_latest_version(tags) highest = max(versions.values())
if latest is None: if len(set(versions.values())) > 1:
return SemVer(0, 0, 0) found = ", ".join(f"{source} {ver}" for source, ver in versions.items())
print(f"[INFO] Version sources disagree ({found}); starting from {highest}.")
_tag, ver = latest return highest
return ver
def bump_semver(current: SemVer, release_type: str) -> SemVer: def bump_semver(current: SemVer, release_type: str) -> SemVer:
+2 -2
View File
@@ -49,7 +49,8 @@ def _release_impl(
# Pull BEFORE making any modifications # Pull BEFORE making any modifications
ensure_clean_and_synced(preview=preview) 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 = bump_semver(current_ver, release_type)
new_ver_str = str(new_ver) new_ver_str = str(new_ver)
new_tag = new_ver.to_tag(with_prefix=True) new_tag = new_ver.to_tag(with_prefix=True)
@@ -59,7 +60,6 @@ def _release_impl(
print(f"Current version: {current_ver}") print(f"Current version: {current_ver}")
print(f"New version: {new_ver_str} ({release_type})") 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) paths = resolve_repo_paths(repo_root)
# --- Update versioned files ------------------------------------------------ # --- Update versioned files ------------------------------------------------
+37
View File
@@ -232,3 +232,40 @@ def read_ansible_galaxy_version(repo_dir: str) -> str | None:
return None return 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
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os
import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -11,12 +13,23 @@ from pkgmgr.core.version.semver import SemVer
class TestDetermineCurrentVersion(unittest.TestCase): 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=[]) @patch("pkgmgr.actions.release.versioning.get_tags", return_value=[])
def test_determine_current_version_no_tags_returns_zero( def test_determine_current_version_no_tags_returns_zero(
self, self,
mock_get_tags, mock_get_tags,
) -> None: ) -> None:
ver = determine_current_version() ver = determine_current_version(self.repo)
self.assertIsInstance(ver, SemVer) self.assertIsInstance(ver, SemVer)
self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0)) self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0))
mock_get_tags.assert_called_once() 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_get_tags.return_value = ["v0.1.0", "v1.2.3"]
mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 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)) self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3))
mock_get_tags.assert_called_once() mock_get_tags.assert_called_once()
mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"]) 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): class TestBumpSemVer(unittest.TestCase):
def test_bump_semver_major(self) -> None: def test_bump_semver_major(self) -> None: