From 16b0f38875246023f14184ed556574b5d004f4b8 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 24 Aug 2025 02:44:30 +0000 Subject: [PATCH 1/4] chore: release helper tool Right now, it just updates the changelog and replaces the version placeholders. --- RELEASING.md | 12 +- tests/tools/private/release/BUILD.bazel | 7 + tests/tools/private/release/release_test.py | 186 ++++++++++++++++++++ tools/private/release/BUILD.bazel | 9 + tools/private/release/release.py | 146 +++++++++++++++ 5 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 tests/tools/private/release/BUILD.bazel create mode 100644 tests/tools/private/release/release_test.py create mode 100644 tools/private/release/BUILD.bazel create mode 100644 tools/private/release/release.py diff --git a/RELEASING.md b/RELEASING.md index c9d46c39f0..a99b7d8d00 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,14 +13,10 @@ These are the steps for a regularly scheduled release from HEAD. ### Steps 1. [Determine the next semantic version number](#determining-semantic-version). -1. Update CHANGELOG.md: replace the `v0-0-0` and `0.0.0` with `X.Y.0`. - ``` - awk -v version=X.Y.0 'BEGIN { hv=version; gsub(/\./, "-", hv) } /END_UNRELEASED_TEMPLATE/ { found_marker = 1 } found_marker { gsub(/v0-0-0/, hv, $0); gsub(/Unreleased/, "[" version "] - " strftime("%Y-%m-%d"), $0); gsub(/0.0.0/, version, $0); } { print } ' CHANGELOG.md > /tmp/changelog && cp /tmp/changelog CHANGELOG.md - ``` -1. Replace `VERSION_NEXT_*` strings with `X.Y.0`. - ``` - grep -l --exclude=CONTRIBUTING.md --exclude=RELEASING.md --exclude-dir=.* VERSION_NEXT_ -r \ - | xargs sed -i -e 's/VERSION_NEXT_FEATURE/X.Y.0/' -e 's/VERSION_NEXT_PATCH/X.Y.0/' +1. Update the changelog and replace the version placeholders by running the + release tool: + ```shell + bazel run //tools/private/release -- X.Y.Z ``` 1. Send these changes for review and get them merged. 1. Create a branch for the new release, named `release/X.Y` diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel new file mode 100644 index 0000000000..3c9db2d4e9 --- /dev/null +++ b/tests/tools/private/release/BUILD.bazel @@ -0,0 +1,7 @@ +load("@rules_python//python:defs.bzl", "py_test") + +py_test( + name = "release_test", + srcs = ["release_test.py"], + deps = ["//tools/private/release"], +) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py new file mode 100644 index 0000000000..1001d9257d --- /dev/null +++ b/tests/tools/private/release/release_test.py @@ -0,0 +1,186 @@ +# Copyright 2025 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import os +import pathlib +import shutil +import tempfile +import unittest + +from tools.private.release import release as releaser + +_UNRELEASED_TEMPLATE = """ + +""" + + +class ReleaserTest(unittest.TestCase): + def setUp(self): + self.tmpdir = pathlib.Path(tempfile.mkdtemp()) + self.original_cwd = os.getcwd() + os.chdir(self.tmpdir) + self.addCleanup(os.chdir, self.original_cwd) + self.addCleanup(shutil.rmtree, self.tmpdir) + + def test_update_changelog(self): + changelog = f""" +# Changelog + +{_UNRELEASED_TEMPLATE} + +{{#v0-0-0}} +## Unreleased + +[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 + +{{#v0-0-0-changed}} +### Changed +* Nothing changed + +{{#v0-0-0-fixed}} +### Fixed +* Nothing fixed + +{{#v0-0-0-added}} +### Added +* Nothing added + +{{#v0-0-0-removed}} +### Removed +* Nothing removed. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + # Act + releaser.update_changelog( + "1.23.4", + "2025-01-01", + changelog_path=changelog_path, + ) + + # Assert + new_content = changelog_path.read_text() + + self.assertIn( + _UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n" + ) + self.assertIn(f"## [1.23.4] - 2025-01-01", new_content) + self.assertIn( + f"[1.23.4]: https://github.com/bazel-contrib/rules_python/releases/tag/1.23.4", + new_content, + ) + self.assertIn("{#v1-23-4}", new_content) + self.assertIn("{#v1-23-4-changed}", new_content) + self.assertIn("{#v1-23-4-fixed}", new_content) + self.assertIn("{#v1-23-4-added}", new_content) + self.assertIn("{#v1-23-4-removed}", new_content) + + def test_replace_version_next(self): + # Arrange + mock_file_content = """ +:::{versionadded} VERSION_NEXT_FEATURE +blabla +::: + +:::{versionchanged} VERSION_NEXT_PATCH +blabla +::: +""" + (self.tmpdir / "mock_file.bzl").write_text(mock_file_content) + + releaser.replace_version_next("0.28.0") + + new_content = (self.tmpdir / "mock_file.bzl").read_text() + + self.assertIn(":::{versionadded} 0.28.0", new_content) + self.assertIn(":::{versionadded} 0.28.0", new_content) + self.assertNotIn("VERSION_NEXT_FEATURE", new_content) + self.assertNotIn("VERSION_NEXT_PATCH", new_content) + + def test_replace_version_next_excludes_bazel_dirs(self): + # Arrange + mock_file_content = """ +:::{versionadded} VERSION_NEXT_FEATURE +blabla +::: +""" + bazel_dir = self.tmpdir / "bazel-rules_python" + bazel_dir.mkdir() + (bazel_dir / "mock_file.bzl").write_text(mock_file_content) + + tools_dir = self.tmpdir / "tools" / "private" / "release" + tools_dir.mkdir(parents=True) + (tools_dir / "mock_file.bzl").write_text(mock_file_content) + + tests_dir = self.tmpdir / "tests" / "tools" / "private" / "release" + tests_dir.mkdir(parents=True) + (tests_dir / "mock_file.bzl").write_text(mock_file_content) + + version = "0.28.0" + + # Act + releaser.replace_version_next(version) + + # Assert + new_content = (bazel_dir / "mock_file.bzl").read_text() + self.assertIn("VERSION_NEXT_FEATURE", new_content) + + new_content = (tools_dir / "mock_file.bzl").read_text() + self.assertIn("VERSION_NEXT_FEATURE", new_content) + + new_content = (tests_dir / "mock_file.bzl").read_text() + self.assertIn("VERSION_NEXT_FEATURE", new_content) + + def test_valid_version(self): + # These should not raise an exception + releaser.create_parser().parse_args(["0.28.0"]) + releaser.create_parser().parse_args(["1.0.0"]) + releaser.create_parser().parse_args(["1.2.3rc4"]) + + def test_invalid_version(self): + with self.assertRaises(SystemExit): + releaser.create_parser().parse_args(["0.28"]) + with self.assertRaises(SystemExit): + releaser.create_parser().parse_args(["a.b.c"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel new file mode 100644 index 0000000000..9cd8ec2fba --- /dev/null +++ b/tools/private/release/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_python//python:defs.bzl", "py_binary") + +package(default_visibility = ["//visibility:public"]) + +py_binary( + name = "release", + srcs = ["release.py"], + main = "release.py", +) diff --git a/tools/private/release/release.py b/tools/private/release/release.py new file mode 100644 index 0000000000..d1a78472a2 --- /dev/null +++ b/tools/private/release/release.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# Copyright 2025 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A tool to perform release steps. + +This tool automates steps 2 and 3 of the release process described in +RELEASING.md. +""" + +import argparse +import datetime +import fnmatch +import os +import pathlib +import re + + +def update_changelog(version, release_date, changelog_path="CHANGELOG.md"): + """Performs the version replacements in CHANGELOG.md.""" + + header_version = version.replace(".", "-") + + changelog_path_obj = pathlib.Path(changelog_path) + lines = changelog_path_obj.read_text().splitlines() + + new_lines = [] + after_template = False + before_already_released = True + for line in lines: + if "END_UNRELEASED_TEMPLATE" in line: + after_template = True + if re.match("#v[1-9]-", line): + before_already_released = False + + if after_template and before_already_released: + line = line.replace("## Unreleased", f"## [{version}] - {release_date}") + line = line.replace("v0-0-0", f"v{header_version}") + line = line.replace("0.0.0", version) + + new_lines.append(line) + + changelog_path_obj.write_text("\n".join(new_lines)) + + +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 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) + + +def _semver_type(value): + if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): + raise argparse.ArgumentTypeError( + f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)" + ) + return value + + +def create_parser(): + """Creates the argument parser.""" + parser = argparse.ArgumentParser( + description="Automate release steps for rules_python." + ) + parser.add_argument( + "version", + help="The new release version (e.g., 0.28.0).", + type=_semver_type, + ) + return parser + + +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)" + ) + + # Change to the workspace root so the script can be run from anywhere. + 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) + + print("Replacing VERSION_NEXT placeholders ...") + replace_version_next(args.version) + + print("Done") + + +if __name__ == "__main__": + main() From a321191000fee364adc0896bdb24da116cf23fbe Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 24 Aug 2025 21:44:24 +0000 Subject: [PATCH 2/4] fix windows --- tests/tools/private/release/release_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 1001d9257d..b9f728d93f 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -55,9 +55,11 @@ class ReleaserTest(unittest.TestCase): def setUp(self): self.tmpdir = pathlib.Path(tempfile.mkdtemp()) self.original_cwd = os.getcwd() + self.addCleanup(shutil.rmtree, self.tmpdir) + os.chdir(self.tmpdir) + # NOTE: On windows, this must be done before files are deleted. self.addCleanup(os.chdir, self.original_cwd) - self.addCleanup(shutil.rmtree, self.tmpdir) def test_update_changelog(self): changelog = f""" From 8c9dd9169856b6759c086d1ee5cab50b4f3c231a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 25 Aug 2025 20:12:59 -0700 Subject: [PATCH 3/4] remove wrong release_test.py copyright --- tests/tools/private/release/release_test.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index b9f728d93f..5f0446410b 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -1,17 +1,3 @@ -# Copyright 2025 The Bazel Authors. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import datetime import os import pathlib From 656e473a45d9e942860fa2a87918445d46bb2a6c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 25 Aug 2025 20:13:58 -0700 Subject: [PATCH 4/4] remote incorrect release.py copyright --- tools/private/release/release.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/tools/private/release/release.py b/tools/private/release/release.py index d1a78472a2..f37a5ff7de 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -1,23 +1,4 @@ -#!/usr/bin/env python3 -# Copyright 2025 The Bazel Authors. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""A tool to perform release steps. - -This tool automates steps 2 and 3 of the release process described in -RELEASING.md. -""" +"""A tool to perform release steps.""" import argparse import datetime