blob: 6fce0ff3b0ce28b89b87fe63776fba6e47451a96 [file] [log] [blame]
"""A tool to perform release steps."""
import argparse
import datetime
import fnmatch
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"):
"""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."""
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 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",
nargs="?",
type=_semver_type,
help="The new release version (e.g., 0.28.0). If not provided, "
"it will be determined automatically.",
)
return parser
def main():
# 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"])
parser = create_parser()
args = parser.parse_args()
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}")
print("Updating changelog ...")
release_date = datetime.date.today().strftime("%Y-%m-%d")
update_changelog(version, release_date)
print("Replacing VERSION_NEXT placeholders ...")
replace_version_next(version)
print("Done")
if __name__ == "__main__":
main()