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.
This commit is contained in:
Renn F
2026-07-18 16:41:18 +02:00
parent 6a30cca4af
commit fecb021eef
2 changed files with 85 additions and 5 deletions
+46 -5
View File
@@ -12,11 +12,15 @@ gate state) is layered on top of these primitives in Task 3.
from __future__ import annotations from __future__ import annotations
import json
import re import re
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path 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"] BumpKind = Literal["major", "minor", "patch"]
@@ -439,12 +443,49 @@ def _run_git(root: Path, args: list[str]) -> str:
return result.stdout return result.stdout
def _pyproject_version(root: Path) -> str: _TOML_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
text = (root / "pyproject.toml").read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, 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 "" 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: def _last_tag(root: Path) -> str | None:
tag = _run_git(root, ["describe", "--tags", "--abbrev=0"]).strip() tag = _run_git(root, ["describe", "--tags", "--abbrev=0"]).strip()
return tag or None return tag or None
@@ -607,7 +648,7 @@ def gather_snapshot(
(the ``tag_drift`` gap). None ⇒ degenerate/unsplit project, baseline stays (the ``tag_drift`` gap). None ⇒ degenerate/unsplit project, baseline stays
``last_tag`` (unchanged behavior). ``last_tag`` (unchanged behavior).
""" """
version = _pyproject_version(root) version = _project_version(root)
tag = _last_tag(root) tag = _last_tag(root)
prod_tip = _rev_parse(root, f"origin/{prod_branch}") if prod_branch else None 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 last_tag_sha = _rev_parse(root, f"{tag}^{{commit}}") if tag else None
@@ -14,6 +14,7 @@ from roboco.services.release_readiness import (
CommitInfo, CommitInfo,
_commits_since, _commits_since,
_draft_changelog, _draft_changelog,
_project_version,
_run_git, _run_git,
classify_changes, classify_changes,
derive_bump, 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) draft = _draft_changelog("0.25.0", changes, "2026-07-15", empty)
assert "### Added" in draft assert "### Added" in draft
assert "- shiny thing" 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"