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
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.
400 lines
16 KiB
Python
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"] == ""
|