#!/usr/bin/env python3
"""Version convergence checker — the decision logic for the release safety net (#192).
Reads the repo's git log (via subprocess git) and reports whether master is in
a released state. Used by the push-triggered job in
``.gitea/workflows/auto-version.yml`` and by the maintenance drift
reconciliation job in ``.gitea/workflows/maintenance.yml``.
Decision (see ``assemble_release_decision``):
* ``warranted_bump`` — the highest bump kind warranted by commit subjects since
the latest tag: ``feat!``/``BREAKING CHANGE``/``feat`` -> minor (0.x
convention, matching the workflow's PR-title rule), ``fix``/``perf`` ->
patch, anything else (docs/chore/test/refactor/...) -> null.
* ``converged`` — true when no release action is warranted: either master's
``__version__`` matches the latest tag and no release-worthy commit sits
unreleased since it, or master's version is not ahead of the tag in any
actionable way. ``converged: false`` means drift: an unreleased bump or a
missing release.
Output: the plan's JSON contract on stdout (exit 0 in both converged and
drift states; exit 1 only on operational failure):
{"converged": bool, "warranted_bump": "minor"|"patch"|null,
"anomaly": "version_behind_tag"|null, "commits_since_tag": N,
"sample": [...]}
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
SAMPLE_LIMIT = 10
_VERSION_RE = re.compile(r'__version__ = "([0-9]+\.[0-9]+\.[0-9]+)"')
_FEAT_RE = re.compile(r"^\s*feat(\([^)]*\))?:")
_WARRANTY_RE = re.compile(r"^\s*(fix|perf)(\([^)]*\))?:")
# Gitea merge-commit subject: the conventional title is quoted inside
# ("Merge pull request 'feat: x' (#N)"). Spec-gate finding 2 (#192): classify
# the inner title, not the "Merge ..." wrapper (which anchors as 'other').
_MERGE_COMMIT_RE = re.compile(r"^Merge pull request '(.+)' \(#\d+\)$")
def _git(args: list[str], repo: Path | None = None) -> str:
proc = subprocess.run(
["git", *args],
cwd=str(repo) if repo else None,
check=True,
capture_output=True,
text=True,
)
return proc.stdout.strip()
def read_version(repo: Path | None = None) -> str | None:
"""Master's ``__version__`` from src/rune/__init__.py, or None if unreadable."""
root = repo if repo else Path.cwd()
init_py = root / "src" / "rune" / "__init__.py"
try:
match = _VERSION_RE.search(init_py.read_text(encoding="utf-8"))
except OSError:
return None
return match.group(1) if match else None
def latest_tag(repo: Path | None = None) -> str | None:
"""Highest version tag (e.g. ``v0.54.0``), or None when the repo has no tags."""
tags = _git(["tag", "--sort=-v:refname"], repo).splitlines()
return tags[0] if tags else None
def subjects_since_tag(tag: str, repo: Path | None = None) -> list[str]:
"""Commit subjects (``%s``) between ``tag`` and HEAD."""
out = _git(["log", f"{tag}..HEAD", "--pretty=%s"], repo)
return out.splitlines() if out else []
def _version_lt(a: str, b: str) -> bool:
"""True when semver string a < b. Numeric per segment — never lexicographic.
T15 Part F: '0.66.9' < '0.66.10' is False lexicographically ('9' > '1'),
which made a legitimately-needed patch bump read as the
version_ahead_of_target anomaly and silently skip the release
(observed live on run 6781, 2026-09-11 19:59).
"""
pa = [int(x) for x in a.split(".")]
pb = [int(x) for x in b.split(".")]
return pa < pb
def classify_subject(subject: str) -> str:
"""Classify one commit subject: ``breaking`` | ``feat`` | ``fix`` | ``other``.
Mirrors the PR-title semver rule in auto-version.yml: ``feat!``/``BREAKING
CHANGE`` are breaking; ``feat`` is a feature; ``fix``/``perf`` warrant a
patch; everything else releases nothing.
Merge commits (``Merge pull request '
' (#N)``) are classified by
their inner PR title so a no-bump feat PR merged via merge-commit stays
visible to the net and to maintenance (spec-gate finding 2, #192).
"""
merge_match = _MERGE_COMMIT_RE.match(subject)
if merge_match:
subject = merge_match.group(1)
if "BREAKING CHANGE" in subject or re.match(r"^\s*\w+(\([^)]*\))?!:", subject):
return "breaking"
if _FEAT_RE.match(subject):
return "feat"
if _WARRANTY_RE.match(subject):
return "fix"
return "other"
def highest_warranted_bump(subjects: list[str]) -> str | None:
"""Highest bump kind warranted by commit subjects: minor > patch > None."""
kinds = {classify_subject(s) for s in subjects}
if "breaking" in kinds or "feat" in kinds:
return "minor"
if "fix" in kinds:
return "patch"
return None
def is_release_commit(subject: str) -> bool:
"""True when the commit is a release/version bump commit.
ONE SOURCE OF TRUTH consumed by auto-version.yml (push convergence
guard) and by the recursion regression test: a release commit must
NEVER trigger another version bump (recursive-loop defect observed
2026-09-11: five 0.66.2 bumps before the guard landed).
"""
s = subject.strip().lower()
return s.startswith("chore(release)") or s.startswith("chore: release")
def next_version(version: str, kind: str) -> str:
"""Apply a bump kind to a version string (0.x convention: breaking -> minor)."""
major, minor, patch = version.split(".")
if kind == "major":
return f"{major}.{int(minor) + 1}.0" if int(major) == 0 else f"{int(major) + 1}.0.0"
if kind == "minor":
return f"{major}.{int(minor) + 1}.0"
if kind == "patch":
return f"{major}.{minor}.{int(patch) + 1}"
raise ValueError(f"unknown bump kind: {kind!r}")
def version_greater(a: str, b: str) -> bool:
"""Numeric semver comparison: is ``a`` strictly greater than ``b``?"""
return tuple(int(part) for part in a.split(".")) > tuple(int(part) for part in b.split("."))
def assemble_release_decision(
version: str | None,
latest_tag: str | None,
subjects: list[str],
commit_count: int,
) -> dict[str, Any]:
"""Assemble the plan's JSON decision from raw inputs.
``converged`` is false (drift) when either:
* master's ``__version__`` equals the latest tag but release-worthy
commits sit unreleased since it (the bump never rode the merge), or
* master's ``__version__`` is strictly ahead of the latest tag (a bump
rode the merge but no release/tag was cut for it), or
* master's ``__version__`` is strictly BEHIND the latest tag (spec-gate
finding 3, #192: a bump merged without riding a release, or a tag
moved backwards). That state is drift with ``anomaly:
"version_behind_tag"`` and ``warranted_bump`` forced to None — the
net must not act (bumping master cannot converge it with a tag that
is already ahead); maintenance files/flags it instead.
"""
warranted = highest_warranted_bump(subjects)
tag_version = latest_tag[1:] if latest_tag and latest_tag.startswith("v") else latest_tag
anomaly: str | None = None
converged = True
if version is None and tag_version is not None:
converged = False # master unreadable while a release history exists
elif version is not None and tag_version is None:
converged = True # no tags: first-release territory, the net never acts
elif version is not None and tag_version is not None:
if version == tag_version:
converged = warranted is None
elif version_greater(version, tag_version):
converged = False # bump rode the merge but no release was cut
else:
converged = False # version behind tag: drift, never silent
warranted = None
anomaly = "version_behind_tag"
return {
"converged": converged,
"warranted_bump": warranted,
"anomaly": anomaly,
"commits_since_tag": commit_count,
"sample": subjects[:SAMPLE_LIMIT],
}
def decision_in_repo(repo: Path | None = None) -> dict[str, Any]:
"""Read the repo's git state and return the release decision."""
version = read_version(repo)
tag = latest_tag(repo)
subjects = subjects_since_tag(tag, repo) if tag else []
return assemble_release_decision(version, tag, subjects, len(subjects))
def last_release_boundary(repo: Path | None = None) -> str | None:
"""Find the last chore(release) commit SHA (the release boundary).
This is the correct convergence boundary — NOT the git tag (tags may
lag behind releases due to D7). Falls back to the latest tag if no
release commits exist.
T15 Part F: matches SUBJECTS only (--grep matches the commit BODY by
default; a fix commit whose body mentions 'chore(release)' was
misdetected as the boundary, swallowing its own warranted bump).
"""
try:
result = subprocess.run(
["git", "log", "--format=%H %s", "-1", "--grep=^chore\\(release\\)", "--extended-regexp"],
capture_output=True,
text=True,
cwd=str(repo) if repo else None,
check=True,
)
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
sha, _, subject = line.partition(" ")
if is_release_commit(subject):
return sha
except (subprocess.CalledProcessError, IndexError):
pass
return latest_tag(repo) # fallback to tag
def release_convergence(
repo: Path | None = None,
*,
_version: str | None = None,
_boundary_subject: str | None = None,
_subjects: list[str] | None = None,
) -> dict[str, Any]:
"""Correct release convergence: compare current source version to the
warranted TARGET version computed from commits since the last release
boundary (the last chore(release) commit, NOT the tag — tags may lag
due to D7).
Invariant:
current == target → converged, no commit
current < target → create exactly one bump commit
current > target → anomaly
"""
current_version = _version or read_version(repo)
if not current_version:
return {
"converged": True,
"warranted_bump": None,
"anomaly": None,
"commits_since_tag": 0,
"sample": [],
"reason": "no readable version",
}
subjects: list[str] = []
boundary = None
if _subjects is not None:
# Test injection: skip git entirely
substantive = [s for s in _subjects if not is_release_commit(s)]
warranted = highest_warranted_bump(substantive)
if not warranted:
return {
"converged": True,
"warranted_bump": None,
"anomaly": None,
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": current_version,
"reason": "no release-worthy commits since last boundary",
"boundary_sha": "",
}
target_version = next_version(current_version, warranted)
if current_version == target_version:
return {
"converged": True,
"warranted_bump": warranted,
"anomaly": None,
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": target_version,
"reason": "current version already equals warranted target",
"boundary_sha": "",
}
elif _version_lt(current_version, target_version):
return {
"converged": False,
"warranted_bump": warranted,
"anomaly": None,
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": target_version,
"reason": f"bump required: {current_version} → {target_version}",
"boundary_sha": "",
}
else:
return {
"converged": False,
"warranted_bump": None,
"anomaly": "version_ahead_of_target",
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": target_version,
"reason": f"ANOMALY: current {current_version} > target {target_version}",
"boundary_sha": "",
}
if _boundary_subject:
result = subprocess.run(
["git", "log", "--format=%s", f"{boundary}..HEAD"],
capture_output=True,
text=True,
cwd=str(repo) if repo else None,
check=True,
)
subjects = [s for s in result.stdout.splitlines() if s.strip()]
else:
# Production path: converge over commits since the last release
# boundary. T15 Part F root cause: `boundary` was initialized to
# None and never assigned — every run classified ALL commits as
# unreleased (959+), so the warranted bump never converged and
# every queued push wrote its own bump commit (5× 0.66.9 observed
# 2026-09-11 20:21). Assign the boundary from the one source of
# truth and range the log over it.
boundary = last_release_boundary(repo)
if boundary:
result = subprocess.run(
["git", "log", "--format=%s", f"{boundary}..HEAD"],
capture_output=True,
text=True,
cwd=str(repo) if repo else None,
check=True,
)
else:
result = subprocess.run(
["git", "log", "--format=%s"],
capture_output=True,
text=True,
cwd=str(repo) if repo else None,
check=True,
)
subjects = [s for s in result.stdout.splitlines() if s.strip()]
# Exclude chore(release) commits from bump classification (they ARE releases)
substantive = [s for s in subjects if not is_release_commit(s)]
warranted = highest_warranted_bump(substantive)
# Expose the boundary in the decision JSON: consumers (release step,
# debug sessions) otherwise re-derive it from git history.
decision_boundary = {"boundary_sha": (boundary or "")[:12]}
if not warranted:
return {
"converged": True,
"warranted_bump": None,
"anomaly": None,
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": current_version,
"reason": "no release-worthy commits since last boundary",
**decision_boundary,
}
target_version = next_version(current_version, warranted)
if current_version == target_version:
return {
"converged": True,
"warranted_bump": warranted,
"anomaly": None,
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": target_version,
"reason": "current version already equals warranted target",
**decision_boundary,
}
elif _version_lt(current_version, target_version):
# Bump needed — but only if not already converged by a prior bump
# in this same cascade (the target is the SAME for all queued runs)
return {
"converged": False,
"warranted_bump": warranted,
"anomaly": None,
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": target_version,
"reason": f"bump required: {current_version} → {target_version}",
**decision_boundary,
}
else:
return {
"converged": False,
"warranted_bump": None,
"anomaly": "version_ahead_of_target",
"commits_since_boundary": len(substantive),
"sample": substantive[:5],
"current_version": current_version,
"target_version": target_version,
"reason": f"ANOMALY: current {current_version} > target {target_version}",
**decision_boundary,
}
def main(argv: list[str] | None = None) -> dict[str, Any]:
parser = argparse.ArgumentParser(description="Release convergence decision (JSON on stdout)")
parser.add_argument(
"--is-release-subject",
dest="is_release_subject",
metavar="SUBJECT",
help="exit 0 when SUBJECT is a release bump commit, exit 1 otherwise (loop guard); no JSON",
)
parser.add_argument("--repo", type=Path, default=None, help="repo root (default: cwd)")
args = parser.parse_args(argv)
if args.is_release_subject is not None:
import sys as _sys
_sys.exit(0 if is_release_commit(args.is_release_subject) else 1)
decision = release_convergence(args.repo)
# Backward compat: old consumers expect commits_since_tag
if "commits_since_tag" not in decision:
decision["commits_since_tag"] = decision.get("commits_since_boundary", 0)
print(json.dumps(decision))
return decision
if __name__ == "__main__":
try:
main()
except subprocess.CalledProcessError as exc:
print(f"version_convergence: git failed ({exc.returncode}): {exc.stderr}", file=sys.stderr)
sys.exit(1)