chore: make release tool auto detect next version (#3219)
This makes the release tool determine the next version automatically. It
does so
by searching for the VERSION_NEXT strings. If VERSION_NEXT_FEATURE is
found, then
it increments the minor version. If only patch placeholders are found,
then it
increments the patch version.
When the latest version is an RC, an error is raised. This is to protect
against
accidentally running it when we're in the middle of the RC phase.
---------
Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com>
diff --git a/RELEASING.md b/RELEASING.md
index 3d58a93..e4cf738 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -12,12 +12,14 @@
### Steps
-1. [Determine the next semantic version number](#determining-semantic-version).
1. Update the changelog and replace the version placeholders by running the
- release tool:
+ release tool. The next version number will by automatically determined
+ based on the presence of `VERSION_NEXT_*` placeholders and git tags.
+
```shell
- bazel run //tools/private/release -- X.Y.Z
+ bazel run //tools/private/release
```
+
1. Send these changes for review and get them merged.
1. Create a branch for the new release, named `release/X.Y`
```
@@ -70,7 +72,8 @@
API changes and new features bump the minor, and those with only bug fixes and
other minor changes bump the patch digit.
-To find if there were any features added or incompatible changes made, review
+The release tool will automatically determine the next version number. To find
+if there were any features added or incompatible changes made, review
[CHANGELOG.md](CHANGELOG.md) and the commit history. This can be done using
github by going to the url:
`https://github.com/bazel-contrib/rules_python/compare/<VERSION>...main`.
diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel
index 3c9db2d..9f3bc05 100644
--- a/tests/tools/private/release/BUILD.bazel
+++ b/tests/tools/private/release/BUILD.bazel
@@ -3,5 +3,8 @@
py_test(
name = "release_test",
srcs = ["release_test.py"],
- deps = ["//tools/private/release"],
+ deps = [
+ "//tools/private/release",
+ "@dev_pip//packaging",
+ ],
)
diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py
index 5f04464..72a9a05 100644
--- a/tests/tools/private/release/release_test.py
+++ b/tests/tools/private/release/release_test.py
@@ -4,6 +4,7 @@
import shutil
import tempfile
import unittest
+from unittest.mock import patch
from tools.private.release import release as releaser
@@ -170,5 +171,44 @@
releaser.create_parser().parse_args(["a.b.c"])
+class GetLatestVersionTest(unittest.TestCase):
+ @patch("tools.private.release.release._get_git_tags")
+ def test_get_latest_version_success(self, mock_get_tags):
+ mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"]
+ self.assertEqual(releaser.get_latest_version(), "1.0.0")
+
+ @patch("tools.private.release.release._get_git_tags")
+ def test_get_latest_version_rc_is_latest(self, mock_get_tags):
+ mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"]
+ with self.assertRaisesRegex(
+ ValueError, "The latest version is a pre-release version: 1.1.0rc0"
+ ):
+ releaser.get_latest_version()
+
+ @patch("tools.private.release.release._get_git_tags")
+ def test_get_latest_version_no_tags(self, mock_get_tags):
+ mock_get_tags.return_value = []
+ with self.assertRaisesRegex(
+ RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format."
+ ):
+ releaser.get_latest_version()
+
+ @patch("tools.private.release.release._get_git_tags")
+ def test_get_latest_version_no_matching_tags(self, mock_get_tags):
+ mock_get_tags.return_value = ["v1.0", "latest"]
+ with self.assertRaisesRegex(
+ RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format."
+ ):
+ releaser.get_latest_version()
+
+ @patch("tools.private.release.release._get_git_tags")
+ def test_get_latest_version_only_rc_tags(self, mock_get_tags):
+ mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"]
+ with self.assertRaisesRegex(
+ ValueError, "The latest version is a pre-release version: 1.1.0rc0"
+ ):
+ releaser.get_latest_version()
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel
index 9cd8ec2..31cc3a0 100644
--- a/tools/private/release/BUILD.bazel
+++ b/tools/private/release/BUILD.bazel
@@ -6,4 +6,7 @@
name = "release",
srcs = ["release.py"],
main = "release.py",
+ deps = [
+ "@dev_pip//packaging",
+ ],
)
diff --git a/tools/private/release/release.py b/tools/private/release/release.py
index f37a5ff..def6754 100644
--- a/tools/private/release/release.py
+++ b/tools/private/release/release.py
@@ -6,6 +6,100 @@
import os
import pathlib
import re
+import subprocess
+
+from packaging.version import parse as parse_version
+
+_EXCLUDE_PATTERNS = [
+ "./.git/*",
+ "./.github/*",
+ "./.bazelci/*",
+ "./.bcr/*",
+ "./bazel-*/*",
+ "./CONTRIBUTING.md",
+ "./RELEASING.md",
+ "./tools/private/release/*",
+ "./tests/tools/private/release/*",
+]
+
+
+def _iter_version_placeholder_files():
+ for root, dirs, files in os.walk(".", topdown=True):
+ # Filter directories
+ dirs[:] = [
+ d
+ for d in dirs
+ if not any(
+ fnmatch.fnmatch(os.path.join(root, d), pattern)
+ for pattern in _EXCLUDE_PATTERNS
+ )
+ ]
+
+ for filename in files:
+ filepath = os.path.join(root, filename)
+ if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS):
+ continue
+
+ yield filepath
+
+
+def _get_git_tags():
+ """Runs a git command and returns the output."""
+ return subprocess.check_output(["git", "tag"]).decode("utf-8").splitlines()
+
+
+def get_latest_version():
+ """Gets the latest version from git tags."""
+ tags = _get_git_tags()
+ # The packaging module can parse PEP440 versions, including RCs.
+ # It has a good understanding of version precedence.
+ versions = [
+ (tag, parse_version(tag))
+ for tag in tags
+ if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip())
+ ]
+ if not versions:
+ raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.")
+
+ versions.sort(key=lambda v: v[1])
+ latest_tag, latest_version = versions[-1]
+
+ if latest_version.is_prerelease:
+ raise ValueError(f"The latest version is a pre-release version: {latest_tag}")
+
+ # After all that, we only want to consider stable versions for the release.
+ stable_versions = [tag for tag, version in versions if not version.is_prerelease]
+ if not stable_versions:
+ raise ValueError("No stable git tags found matching X.Y.Z format.")
+
+ # The versions are already sorted, so the last one is the latest.
+ return stable_versions[-1]
+
+
+def should_increment_minor():
+ """Checks if the minor version should be incremented."""
+ for filepath in _iter_version_placeholder_files():
+ try:
+ with open(filepath, "r") as f:
+ content = f.read()
+ except (IOError, UnicodeDecodeError):
+ # Ignore binary files or files with read errors
+ continue
+
+ if "VERSION_NEXT_FEATURE" in content:
+ return True
+ return False
+
+
+def determine_next_version():
+ """Determines the next version based on git tags and placeholders."""
+ latest_version = get_latest_version()
+ major, minor, patch = [int(n) for n in latest_version.split(".")]
+
+ if should_increment_minor():
+ return f"{major}.{minor + 1}.0"
+ else:
+ return f"{major}.{minor}.{patch + 1}"
def update_changelog(version, release_date, changelog_path="CHANGELOG.md"):
@@ -37,46 +131,19 @@
def replace_version_next(version):
"""Replaces all VERSION_NEXT_* placeholders with the new version."""
- exclude_patterns = [
- "./.git/*",
- "./.github/*",
- "./.bazelci/*",
- "./.bcr/*",
- "./bazel-*/*",
- "./CONTRIBUTING.md",
- "./RELEASING.md",
- "./tools/private/release/*",
- "./tests/tools/private/release/*",
- ]
+ for filepath in _iter_version_placeholder_files():
+ try:
+ with open(filepath, "r") as f:
+ content = f.read()
+ except (IOError, UnicodeDecodeError):
+ # Ignore binary files or files with read errors
+ continue
- for root, dirs, files in os.walk(".", topdown=True):
- # Filter directories
- dirs[:] = [
- d
- for d in dirs
- if not any(
- fnmatch.fnmatch(os.path.join(root, d), pattern)
- for pattern in exclude_patterns
- )
- ]
-
- for filename in files:
- filepath = os.path.join(root, filename)
- if any(fnmatch.fnmatch(filepath, pattern) for pattern in exclude_patterns):
- continue
-
- try:
- with open(filepath, "r") as f:
- content = f.read()
- except (IOError, UnicodeDecodeError):
- # Ignore binary files or files with read errors
- continue
-
- if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
- new_content = content.replace("VERSION_NEXT_FEATURE", version)
- new_content = new_content.replace("VERSION_NEXT_PATCH", version)
- with open(filepath, "w") as f:
- f.write(new_content)
+ if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
+ new_content = content.replace("VERSION_NEXT_FEATURE", version)
+ new_content = new_content.replace("VERSION_NEXT_PATCH", version)
+ with open(filepath, "w") as f:
+ f.write(new_content)
def _semver_type(value):
@@ -94,8 +161,10 @@
)
parser.add_argument(
"version",
- help="The new release version (e.g., 0.28.0).",
+ nargs="?",
type=_semver_type,
+ help="The new release version (e.g., 0.28.0). If not provided, "
+ "it will be determined automatically.",
)
return parser
@@ -104,21 +173,22 @@
parser = create_parser()
args = parser.parse_args()
- if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", args.version):
- raise ValueError(
- f"Version '{args.version}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)"
- )
+ version = args.version
+ if version is None:
+ print("No version provided, determining next version automatically...")
+ version = determine_next_version()
+ print(f"Determined next version: {version}")
- # Change to the workspace root so the script can be run from anywhere.
+ # Change to the workspace root so the script can be run using `bazel run`
if "BUILD_WORKSPACE_DIRECTORY" in os.environ:
os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"])
print("Updating changelog ...")
release_date = datetime.date.today().strftime("%Y-%m-%d")
- update_changelog(args.version, release_date)
+ update_changelog(version, release_date)
print("Replacing VERSION_NEXT placeholders ...")
- replace_version_next(args.version)
+ replace_version_next(version)
print("Done")