|
| 1 | +"""Prints the next release version, or nothing when there is nothing to release. |
| 2 | +
|
| 3 | +Every merge to main that changes the package is released (rolling releases). The bump follows |
| 4 | +Conventional Commits in the commit subjects since the last ``vX.Y.Z`` tag: |
| 5 | +
|
| 6 | +- ``feat!:`` / ``fix!:`` or a ``BREAKING CHANGE:`` footer: major (minor while below 1.0) |
| 7 | +- ``feat:``: minor |
| 8 | +- anything else: patch |
| 9 | +
|
| 10 | +Commits that only touch docs, tests or CI do not trigger a release. With no tag yet, the first |
| 11 | +release is 0.1.0. |
| 12 | +
|
| 13 | +Usage: python scripts/next_version.py [--force] |
| 14 | +""" |
| 15 | + |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +import re |
| 19 | +import subprocess |
| 20 | +import sys |
| 21 | + |
| 22 | +RELEASE_PATHS = ("src/", "pyproject.toml", "spec/") |
| 23 | +BREAKING = re.compile(r"^[a-z]+(\([^)]*\))?!:", re.IGNORECASE) |
| 24 | +FEATURE = re.compile(r"^feat(\([^)]*\))?:", re.IGNORECASE) |
| 25 | + |
| 26 | + |
| 27 | +def git(*args: str) -> str: |
| 28 | + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout.strip() |
| 29 | + |
| 30 | + |
| 31 | +def latest_tag() -> str | None: |
| 32 | + tags = git("tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname").splitlines() |
| 33 | + return tags[0] if tags else None |
| 34 | + |
| 35 | + |
| 36 | +def main() -> int: |
| 37 | + force = "--force" in sys.argv |
| 38 | + tag = latest_tag() |
| 39 | + if tag is None: |
| 40 | + print("0.1.0") |
| 41 | + return 0 |
| 42 | + |
| 43 | + revision = f"{tag}..HEAD" |
| 44 | + changed = git("diff", "--name-only", revision).splitlines() |
| 45 | + if not force and not any(path.startswith(RELEASE_PATHS) for path in changed): |
| 46 | + return 0 # nothing user-facing changed |
| 47 | + |
| 48 | + subjects = git("log", "--format=%s", revision).splitlines() |
| 49 | + bodies = git("log", "--format=%b", revision) |
| 50 | + major, minor, patch = (int(part) for part in tag.removeprefix("v").split(".")) |
| 51 | + |
| 52 | + if any(BREAKING.match(subject) for subject in subjects) or "BREAKING CHANGE:" in bodies: |
| 53 | + if major == 0: |
| 54 | + minor, patch = minor + 1, 0 |
| 55 | + else: |
| 56 | + major, minor, patch = major + 1, 0, 0 |
| 57 | + elif any(FEATURE.match(subject) for subject in subjects): |
| 58 | + minor, patch = minor + 1, 0 |
| 59 | + else: |
| 60 | + patch += 1 |
| 61 | + print(f"{major}.{minor}.{patch}") |
| 62 | + return 0 |
| 63 | + |
| 64 | + |
| 65 | +if __name__ == "__main__": |
| 66 | + raise SystemExit(main()) |
0 commit comments