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..5f0446410b --- /dev/null +++ b/tests/tools/private/release/release_test.py @@ -0,0 +1,174 @@ +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() + 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) + + 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..f37a5ff7de --- /dev/null +++ b/tools/private/release/release.py @@ -0,0 +1,127 @@ +"""A tool to perform release steps.""" + +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()