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.
455 lines
18 KiB
Python
455 lines
18 KiB
Python
#!/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)
|