diff --git a/RELEASING.md b/RELEASING.md index e72ff619ba..32fc71a876 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,12 +12,14 @@ These are the steps for a regularly scheduled release from HEAD. ### 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` ``` @@ -51,7 +53,8 @@ Release automation will create a GitHub release and BCR pull request. 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/...main`. diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 3c9db2d4e9..9f3bc0542a 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -3,5 +3,8 @@ load("@rules_python//python:defs.bzl", "py_test") 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 5f0446410b..72a9a05cd6 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 @@ def test_invalid_version(self): 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 9cd8ec2fba..31cc3a0239 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -6,4 +6,7 @@ py_binary( 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 f37a5ff7de..def6754347 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 update_changelog(version, release_date, changelog_path="CHANGELOG.md"): 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 @@ def create_parser(): ) 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 @@ def main(): 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")