Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
```
Expand Down Expand Up @@ -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/<VERSION>...main`.
Expand Down
5 changes: 4 additions & 1 deletion tests/tools/private/release/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
40 changes: 40 additions & 0 deletions tests/tools/private/release/release_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import shutil
import tempfile
import unittest
from unittest.mock import patch

from tools.private.release import release as releaser

Expand Down Expand Up @@ -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()
3 changes: 3 additions & 0 deletions tools/private/release/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ py_binary(
name = "release",
srcs = ["release.py"],
main = "release.py",
deps = [
"@dev_pip//packaging",
],
)
164 changes: 117 additions & 47 deletions tools/private/release/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand All @@ -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")

Expand Down