Files
rune/tests/test_version_convergence.py
T
Nick 9a05fdfa1f
CI Pipeline / best-practices (push) Successful in 7s
CI Pipeline / documentation (push) Successful in 5s
Auto-version and Release / release (push) Successful in 25s
CI Pipeline / canary (push) Successful in 36s
CI Pipeline / lint (push) Successful in 43s
CI Pipeline / type-check (push) Successful in 30s
CI Pipeline / gitea-offline (push) Successful in 15s
CI Pipeline / unit-tests (push) Successful in 1m3s
CI Pipeline / required-checks (push) Successful in 2s
deploy / deploy (push) Successful in 2m40s
fix(convergence): decision JSON omits boundary_sha — consumers re-derive it from git
Every debug session of the release pipeline (T15 Part F forensics included)
had to re-derive the release boundary by hand. The decision JSON now
carries boundary_sha on every return path.
2026-09-11 22:33:10 +01:00

400 lines
16 KiB
Python

"""Tests for scripts/version_convergence.py — the push-triggered release safety net (#192).
Pure decision-logic tests are fed fixture commit-subject lists (no live git).
One subprocess-level class exercises the real git pipeline in a temp repo.
"""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import unittest
from pathlib import Path
from typing import Any
import pytest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
def _load_module() -> Any:
spec = importlib.util.spec_from_file_location("version_convergence", ROOT / "scripts" / "version_convergence.py")
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
vc = _load_module()
# ---------------------------------------------------------------------------
# Subject classification (pure fixtures)
# ---------------------------------------------------------------------------
class TestClassifySubject:
@pytest.mark.parametrize(
("subject", "expected"),
[
("feat: add widget", "feat"),
("feat(tui): add widget", "feat"),
("feat!: remove legacy bus", "breaking"),
("feat(tui)!: remove legacy bus", "breaking"),
("BREAKING CHANGE: drop legacy bus", "breaking"),
("fix: stop event race", "fix"),
("fix(tui): stop event race", "fix"),
("perf: cache capsule lookups", "fix"),
("perf(tui): cache capsule lookups", "fix"),
("docs: update readme", "other"),
("docs(readme): update", "other"),
("chore: bump version", "other"),
("chore(release): auto-bump version to 0.55.0 (post-merge automation)", "other"),
("test: add convergence tests", "other"),
("refactor: extract helper", "other"),
("style: format code", "other"),
("ci: tweak workflow", "other"),
("build: bump dep", "other"),
("Merge pull request 'x' (#12) from y into master", "other"),
("revert feat: add widget", "other"),
("no prefix at all, just words", "other"),
("", "other"),
],
)
def test_classification(self, subject: str, expected: str) -> None:
assert vc.classify_subject(subject) == expected
@pytest.mark.parametrize(
("merge_subject", "expected"),
[
# Gitea/GitHub merge-commit subjects: the conventional title is
# quoted inside — classification must anchor at the inner title
# (#192 spec-gate finding 2), not at "Merge ...".
("Merge pull request 'feat: add widget' (#12)", "feat"),
("Merge pull request 'feat(tui): add widget' (#3)", "feat"),
("Merge pull request 'fix: stop event race' (#14)", "fix"),
("Merge pull request 'fix(api): stop event race' (#15)", "fix"),
("Merge pull request 'feat!: remove legacy bus' (#16)", "breaking"),
("Merge pull request 'BREAKING CHANGE: drop legacy bus' (#17)", "breaking"),
("Merge pull request 'perf: cache lookups' (#18)", "fix"),
("Merge pull request 'docs: update readme' (#19)", "other"),
("Merge pull request 'chore: tidy' (#20)", "other"),
("Merge pull request 'no prefix at all' (#21)", "other"),
],
)
def test_merge_commit_inner_title_classification(self, merge_subject: str, expected: str) -> None:
assert vc.classify_subject(merge_subject) == expected
def test_merge_commit_without_quoted_title_stays_other(self) -> None:
# Truncated/unusual merge subjects must not crash or misclassify.
assert vc.classify_subject("Merge branch 'master' into release") == "other"
assert vc.classify_subject("Merge pull request #22 from nick/branch") == "other"
class TestHighestWarrantedBump:
@pytest.mark.parametrize(
("subjects", "expected"),
[
# 0.x convention (matches the workflow's existing rule): breaking -> minor
(["feat!: remove legacy bus", "fix: race"], "minor"),
(["feat: add widget", "fix: race"], "minor"),
(["feat(tui): widget", "docs: notes"], "minor"),
(["chore: x", "BREAKING CHANGE: api removed"], "minor"),
(["fix: race", "perf: cache", "chore: tidy"], "patch"),
(["docs: update", "chore: tidy", "test: add", "refactor: split"], None),
([], None),
],
)
def test_decision(self, subjects: list[str], expected: str | None) -> None:
assert vc.highest_warranted_bump(subjects) == expected
# ---------------------------------------------------------------------------
# Version arithmetic helpers (used by the push path to compute the new version)
# ---------------------------------------------------------------------------
class TestNextVersion:
@pytest.mark.parametrize(
("version", "kind", "expected"),
[
("0.51.0", "minor", "0.52.0"),
("0.51.3", "patch", "0.51.4"),
("0.51.0", "major", "0.52.0"), # 0.x convention: breaking bumps the minor
("1.2.3", "major", "2.0.0"),
("1.2.3", "minor", "1.3.0"),
("1.2.3", "patch", "1.2.4"),
],
)
def test_next(self, version: str, kind: str, expected: str) -> None:
assert vc.next_version(version, kind) == expected
def test_rejects_bad_kind(self) -> None:
with pytest.raises(ValueError):
vc.next_version("0.51.0", "docs")
class TestVersionGreater:
@pytest.mark.parametrize(
("a", "b", "expected"),
[
("0.56.0", "0.55.0", True),
("0.55.0", "0.55.0", False),
("0.9.0", "0.10.0", False), # numeric, not lexicographic
("1.0.0", "0.99.99", True),
("0.55.1", "0.55.0", True),
],
)
def test_compare(self, a: str, b: str, expected: bool) -> None:
assert vc.version_greater(a, b) is expected
# ---------------------------------------------------------------------------
# Decision assembly — the JSON contract from the plan
# ---------------------------------------------------------------------------
class TestAssembleReleaseDecision:
def test_unreleased_feat_work_is_drift(self) -> None:
# version == tag with release-worthy commits since: the bump never rode
# the merge — exactly the dropped-event scenario from #190/#192.
data = vc.assemble_release_decision(
version="0.55.0", latest_tag="v0.55.0", subjects=["feat: w", "fix: r"], commit_count=2
)
assert data == {
"converged": False,
"warranted_bump": "minor",
"commits_since_tag": 2,
"sample": ["feat: w", "fix: r"],
"anomaly": None,
}
def test_fully_released_is_converged(self) -> None:
data = vc.assemble_release_decision(version="0.55.0", latest_tag="v0.55.0", subjects=[], commit_count=0)
assert data == {
"converged": True,
"warranted_bump": None,
"commits_since_tag": 0,
"sample": [],
"anomaly": None,
}
def test_docs_only_since_tag_is_converged(self) -> None:
data = vc.assemble_release_decision(
version="0.55.0", latest_tag="v0.55.0", subjects=["docs: notes"], commit_count=1
)
assert data == {
"converged": True,
"warranted_bump": None,
"commits_since_tag": 1,
"sample": ["docs: notes"],
"anomaly": None,
}
def test_version_ahead_of_tag_is_drift(self) -> None:
# Bump rode the merge but the tag never got cut (closed-event dropped on
# a bump-carrying PR): a release is missing.
data = vc.assemble_release_decision(
version="0.56.0", latest_tag="v0.55.0", subjects=["feat: w"], commit_count=1
)
assert data == {
"converged": False,
"warranted_bump": "minor",
"commits_since_tag": 1,
"sample": ["feat: w"],
"anomaly": None,
}
def test_version_behind_tag_is_drift_with_anomaly(self) -> None:
# Spec-gate finding 3 (#192): master's version BELOW the latest tag is
# DRIFT (a bump merged without riding a release, or a tag moved
# backwards) — it must not be silently converged. The net must also not
# act on it (warranted_bump forced None even when subjects warrant one):
# bumping master cannot converge it with a tag that is already ahead.
data = vc.assemble_release_decision(
version="0.54.0", latest_tag="v0.55.0", subjects=["feat: w"], commit_count=1
)
assert data["converged"] is False
assert data["warranted_bump"] is None
assert data["anomaly"] == "version_behind_tag"
def test_version_behind_tag_with_no_warranting_subjects_still_drifts(self) -> None:
data = vc.assemble_release_decision(
version="0.54.0", latest_tag="v0.55.0", subjects=["docs: notes"], commit_count=1
)
assert data["converged"] is False
assert data["warranted_bump"] is None
assert data["anomaly"] == "version_behind_tag"
def test_sample_capped_at_ten(self) -> None:
subjects = [f"fix: thing {i}" for i in range(14)]
data = vc.assemble_release_decision(version="0.55.0", latest_tag="v0.55.0", subjects=subjects, commit_count=14)
assert data["sample"] == subjects[:10]
assert data["commits_since_tag"] == 14
assert data["warranted_bump"] == "patch"
def test_missing_version_is_drift(self) -> None:
data = vc.assemble_release_decision(version=None, latest_tag="v0.55.0", subjects=[], commit_count=0)
assert data["converged"] is False
assert data["warranted_bump"] is None
def test_no_tags_is_converged_noop(self) -> None:
# Fresh repo / first-release territory: the safety net never acts.
data = vc.assemble_release_decision(version="0.1.0", latest_tag=None, subjects=[], commit_count=0)
assert data == {
"converged": True,
"warranted_bump": None,
"commits_since_tag": 0,
"sample": [],
"anomaly": None,
}
class TestMainOutputShape:
def test_main_json_contract(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(vc, "release_convergence", lambda repo=None: {
"converged": False, "warranted_bump": "minor", "anomaly": None,
"commits_since_tag": 2, "commits_since_boundary": 2,
"sample": ["feat: w", "fix: r"]})
out = vc.main([])
assert set(out) >= {"converged", "warranted_bump", "anomaly"}
assert out["converged"] is False
assert out["warranted_bump"] == "minor"
assert out["anomaly"] is None
assert out["commits_since_tag"] == 2
assert out["sample"] == ["feat: w", "fix: r"]
# ---------------------------------------------------------------------------
# Subprocess-level: real temp git repo (skipped when git is unavailable)
# ---------------------------------------------------------------------------
git_available = shutil.which("git") is not None
GIT_ENV = {
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
@pytest.mark.skipif(not git_available, reason="git not available")
class TestRealGitRepo:
def _git(self, repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, env=GIT_ENV)
def _repo(self, tmp_path: Path, commits: list[str]) -> Path:
repo = tmp_path / "repo"
repo.mkdir()
self._git(repo, "init", "-q")
(repo / "src" / "rune").mkdir(parents=True)
(repo / "src" / "rune" / "__init__.py").write_text('__version__ = "0.1.0"\n')
self._git(repo, "add", "-A")
self._git(repo, "commit", "-q", "-m", "feat: base")
for subject in commits:
self._git(repo, "commit", "-q", "--allow-empty", "-m", subject)
return repo
def test_drift_minor(self, tmp_path: Path) -> None:
repo = self._repo(tmp_path, ["feat: add widget", "fix: race"])
self._git(repo, "tag", "v0.1.0")
self._git(repo, "commit", "-q", "--allow-empty", "-m", "feat: post-tag feature")
decision = vc.decision_in_repo(repo)
assert decision["converged"] is False
assert decision["warranted_bump"] == "minor"
assert decision["commits_since_tag"] == 1
assert decision["sample"] == ["feat: post-tag feature"]
def test_converged_docs_only(self, tmp_path: Path) -> None:
repo = self._repo(tmp_path, [])
self._git(repo, "tag", "v0.1.0")
self._git(repo, "commit", "-q", "--allow-empty", "-m", "docs: notes")
decision = vc.decision_in_repo(repo)
assert decision == {
"converged": True,
"warranted_bump": None,
"commits_since_tag": 1,
"sample": ["docs: notes"],
"anomaly": None,
}
def test_converged_no_commits_since_tag(self, tmp_path: Path) -> None:
repo = self._repo(tmp_path, [])
self._git(repo, "tag", "v0.1.0")
decision = vc.decision_in_repo(repo)
assert decision == {
"converged": True,
"warranted_bump": None,
"commits_since_tag": 0,
"sample": [],
"anomaly": None,
}
def test_version_behind_tag_is_drift_anomaly(self, tmp_path: Path) -> None:
# Real-repo check for spec-gate finding 3: tag ahead of master's
# __version__ must surface as drift with the anomaly flag.
repo = self._repo(tmp_path, [])
self._git(repo, "tag", "v0.2.0") # tag moved ahead of the 0.1.0 version
decision = vc.decision_in_repo(repo)
assert decision["converged"] is False
assert decision["warranted_bump"] is None
assert decision["anomaly"] == "version_behind_tag"
def test_cli_prints_plan_json(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
import json
repo = self._repo(tmp_path, ["fix: race"])
self._git(repo, "tag", "v0.1.0")
self._git(repo, "commit", "-q", "--allow-empty", "-m", "fix: post-tag race")
out = vc.main(["--repo", str(repo)])
printed = json.loads(capsys.readouterr().out)
assert printed == out
# Boundary semantics (T15 Part F fix): the release boundary falls
# back to the latest tag, so only post-tag commits count —
# "feat: base" sits below the boundary and must NOT warrant minor.
assert out["warranted_bump"] == "patch"
assert out["commits_since_boundary"] == 1
class TestNumericVersionCompare(unittest.TestCase):
"""_version_lt must be numeric per segment (T15 Part F).
Live defect: '0.66.9' < '0.66.10' was False lexicographically, so a
warranted patch bump surfaced as version_ahead_of_target and the
release was silently skipped (run 6781, 2026-09-11)."""
def test_patch_crossing_digit_boundary(self):
self.assertTrue(vc._version_lt("0.66.9", "0.66.10"))
self.assertFalse(vc._version_lt("0.66.10", "0.66.9"))
def test_equal(self):
self.assertFalse(vc._version_lt("0.66.9", "0.66.9"))
def test_minor_ordering(self):
self.assertTrue(vc._version_lt("0.66.9", "0.67.0"))
self.assertFalse(vc._version_lt("0.67.0", "0.66.9"))
def test_convergence_uses_numeric_compare(self):
result = vc.release_convergence(
_version="0.66.9",
_boundary_subject="chore(release): 0.66.9",
_subjects=["fix: real defect"],
)
self.assertEqual(result["target_version"], "0.66.10")
self.assertFalse(result["converged"])
self.assertIsNone(result["anomaly"])
self.assertEqual(result["warranted_bump"], "patch")
def test_decision_exposes_boundary_sha(self):
result = vc.release_convergence(
_version="0.66.9",
_boundary_subject="chore(release): 0.66.9",
_subjects=["fix: x"],
)
# Test-injection path: no git, boundary unknown → empty but present.
assert result["boundary_sha"] == ""