Files
rune/scripts/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

455 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 '<title>' (#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)