Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 4 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions tests/tools/private/release/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)
188 changes: 188 additions & 0 deletions tests/tools/private/release/release_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# 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 = """
<!--
BEGIN_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.

END_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()
9 changes: 9 additions & 0 deletions tools/private/release/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
)
146 changes: 146 additions & 0 deletions tools/private/release/release.py
Original file line number Diff line number Diff line change
@@ -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()