From fecb021eef9ca8cebe57628b5c7c2ec6670b43df Mon Sep 17 00:00:00 2001 From: Renn F Date: Sat, 18 Jul 2026 16:41:18 +0200 Subject: [PATCH] fix(release): version detection accepts manifest variants, never crashes the sweep _pyproject_version read pyproject.toml with no error handling, so a non-Python project failed the release-manager cycle every interval forever. _project_version now probes pyproject.toml, package.json, Cargo.toml, then a bare VERSION file; missing or unparseable manifests are skipped and no manifest degrades to an empty version. --- roboco/services/release_readiness.py | 51 +++++++++++++++++-- tests/unit/services/test_release_readiness.py | 39 ++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/roboco/services/release_readiness.py b/roboco/services/release_readiness.py index 24e78637..63d29951 100644 --- a/roboco/services/release_readiness.py +++ b/roboco/services/release_readiness.py @@ -12,11 +12,15 @@ gate state) is layered on top of these primitives in Task 3. from __future__ import annotations +import json import re import subprocess from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from collections.abc import Callable BumpKind = Literal["major", "minor", "patch"] @@ -439,12 +443,49 @@ def _run_git(root: Path, args: list[str]) -> str: return result.stdout -def _pyproject_version(root: Path) -> str: - text = (root / "pyproject.toml").read_text(encoding="utf-8") - match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) +_TOML_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) + + +def _toml_version(path: Path) -> str: + match = _TOML_VERSION_RE.search(path.read_text(encoding="utf-8")) return match.group(1) if match else "" +def _package_json_version(path: Path) -> str: + version = json.loads(path.read_text(encoding="utf-8")).get("version") + return version if isinstance(version, str) else "" + + +def _version_file_version(path: Path) -> str: + lines = path.read_text(encoding="utf-8").strip().splitlines() + return lines[0].strip() if lines else "" + + +_VERSION_PROBES: tuple[tuple[str, Callable[[Path], str]], ...] = ( + ("pyproject.toml", _toml_version), + ("package.json", _package_json_version), + ("Cargo.toml", _toml_version), + ("VERSION", _version_file_version), +) + + +def _project_version(root: Path) -> str: + """Current version from the repo's own manifest — first probe that + yields one wins (pyproject.toml, package.json, Cargo.toml, VERSION). + + Missing or unparseable manifests are skipped, never raised: a non-Python + layout must degrade to "" (readiness reports the gap) instead of + crashing the release-manager sweep every cycle.""" + for name, probe in _VERSION_PROBES: + try: + version = probe(root / name) + except (OSError, ValueError): + continue + if version: + return version + return "" + + def _last_tag(root: Path) -> str | None: tag = _run_git(root, ["describe", "--tags", "--abbrev=0"]).strip() return tag or None @@ -607,7 +648,7 @@ def gather_snapshot( (the ``tag_drift`` gap). None ⇒ degenerate/unsplit project, baseline stays ``last_tag`` (unchanged behavior). """ - version = _pyproject_version(root) + version = _project_version(root) tag = _last_tag(root) prod_tip = _rev_parse(root, f"origin/{prod_branch}") if prod_branch else None last_tag_sha = _rev_parse(root, f"{tag}^{{commit}}") if tag else None diff --git a/tests/unit/services/test_release_readiness.py b/tests/unit/services/test_release_readiness.py index aa759f14..feae84d0 100644 --- a/tests/unit/services/test_release_readiness.py +++ b/tests/unit/services/test_release_readiness.py @@ -14,6 +14,7 @@ from roboco.services.release_readiness import ( CommitInfo, _commits_since, _draft_changelog, + _project_version, _run_git, classify_changes, derive_bump, @@ -164,3 +165,41 @@ def test_draft_changelog_falls_back_to_transcription_without_curation() -> None: draft = _draft_changelog("0.25.0", changes, "2026-07-15", empty) assert "### Added" in draft assert "- shiny thing" in draft + + +# --------------------------------------------------------------------------- # +# _project_version — manifest-variant probing, never raises on a non-Python +# layout (a missing/unparseable manifest must not crash the readiness sweep). +# --------------------------------------------------------------------------- # + + +def test_project_version_reads_pyproject(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text('version = "1.2.3"\n', encoding="utf-8") + assert _project_version(tmp_path) == "1.2.3" + + +def test_project_version_falls_back_to_package_json(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text('{"version": "4.5.6"}', encoding="utf-8") + assert _project_version(tmp_path) == "4.5.6" + + +def test_project_version_falls_back_to_cargo_toml(tmp_path: Path) -> None: + (tmp_path / "Cargo.toml").write_text( + '[package]\nname = "x"\nversion = "7.8.9"\n', encoding="utf-8" + ) + assert _project_version(tmp_path) == "7.8.9" + + +def test_project_version_falls_back_to_version_file(tmp_path: Path) -> None: + (tmp_path / "VERSION").write_text("2.0.0\n", encoding="utf-8") + assert _project_version(tmp_path) == "2.0.0" + + +def test_project_version_empty_when_no_manifest(tmp_path: Path) -> None: + assert _project_version(tmp_path) == "" + + +def test_project_version_skips_unparseable_manifest(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text("{not json", encoding="utf-8") + (tmp_path / "VERSION").write_text("3.1.4\n", encoding="utf-8") + assert _project_version(tmp_path) == "3.1.4"