blob: f9447060783cb588fee5068f1be296cd221f383b [file] [log] [blame]
#!/usr/bin/env python3
#
# Copyright (C) 2022 The Android Open Source Project
#
# 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.
"""Manages the release process for Bazel binary.
This script walks the user through all steps required to cut a new
Bazel binary (and related artifacts) for AOSP. This script is intended
for use only by the current Bazel release manager.
Use './release_bazel.py --help` for usage details.
"""
import argparse
import glob
import os
import pathlib
import re
import subprocess
import sys
import tempfile
from typing import Final
# String formatting constants for prettyprint output.
BOLD: Final[str] = "\033[1m"
RESET: Final[str] = "\033[0m"
# The sourceroot-relative-path of the shell script which updates
# Bazel-related prebuilts to a given commit.
UPDATE_SCRIPT_PATH: Final[str] = "prebuilts/bazel/common/update.sh"
# All project directories that may be changed as a result of updating
# release prebuilts.
AFFECTED_PROJECT_DIRECTORIES: Final[list[str]] = [
"prebuilts/bazel/common", "prebuilts/bazel/linux-x86_64",
"prebuilts/bazel/darwin-x86_64"
]
MIXED_DROID_PATH: Final[str] = "build/bazel/ci/mixed_droid.sh"
# Global that represents the value of --dry-run
dry_run: bool
# Temporary directory used for log files.
# This directory should be unique per run of this script, and should
# thus only be initialized on demand and then reused for the rest
# of the run.
log_dir: str = None
def print_step_header(description):
"""Print the release process step with the given description."""
print()
print(f"{BOLD}===== {description}{RESET}")
def temp_file_path(filename):
global log_dir
if log_dir is None:
log_dir = tempfile.mkdtemp()
result = pathlib.Path(log_dir).joinpath(filename)
result.touch()
return result
def temp_dir_path(dirname):
global log_dir
if log_dir is None:
log_dir = tempfile.mkdtemp()
result = pathlib.Path(log_dir).joinpath(dirname)
result.mkdir(exist_ok=True)
return result
def prompt(s):
"""Prompts the user for y/n input using the given string.
Will not return until the user specifies either "y" or "n".
Returns True if the user responded "y" and False if "n".
"""
while True:
response = input(s + " (y/n): ")
if response == "y":
print()
return True
elif response == "n":
print()
return False
else:
print("'%s' invalid, please specify y or n." % response)
def target_update_commit(args):
if args.commit is None:
# TODO(b/239044269): Obtain the most recent pre-release Bazel commit
# from github.
raise Exception("Must specify a value for --commit")
return args.commit
def current_bazel_commit():
"""Returns the commit of the current checked-in Bazel binary."""
current_bazel_files = glob.glob(
"prebuilts/bazel/linux-x86_64/bazel_nojdk-*-linux-x86_64")
if len(current_bazel_files) < 1:
print("could not find an existing bazel named " +
"prebuilts/bazel/linux-x86_64/bazel_nojdk.*")
sys.exit(1)
if len(current_bazel_files) > 1:
print("found multiple bazel binaries under " +
"prebuilts/bazel/linux-x86_64. Ensure that project is clean " +
f"and synced. Found: {current_bazel_files}")
sys.exit(1)
match_group = re.search(
"^prebuilts/bazel/linux-x86_64/bazel_nojdk-(.*)-linux-x86_64$",
current_bazel_files[0])
return match_group.group(1)
def ensure_commit_is_new(target_commit, bazel_src_dir):
"""Verify that the target commit is newer than the current Bazel."""
curr_commit = current_bazel_commit()
if target_commit == curr_commit:
print("Requested commit matches current Bazel binary version.\n" +
"If this is your first time running this script, this indicates " +
"that no new release is necessary.\n\n" +
"Alternatively:\n" +
" - If you want to rerun release verification after already " +
"running this script, specify --verify-only.\n" +
" - If you want to rerun the update anyway (for example, " +
"in the case that updating other tools failed), specify -f.")
sys.exit(1)
result = subprocess.run(
["git", "merge-base", "--is-ancestor", curr_commit, target_commit],
cwd=bazel_src_dir,
check=False)
if result.returncode != 0:
print(f"Requested commit {target_commit} is not a descendant of " +
f"current Bazel binary commit {curr_commit}. Are you trying to " +
"update to an older commit?\n" +
"To force an update anyway, specify -f.")
sys.exit(1)
def checkout_bazel_at(commit):
clone_dir = temp_dir_path("bazelsrc")
print(f"Cloning Bazel into {clone_dir}...")
result = subprocess.run(
["git", "clone", "https://github.com/bazelbuild/bazel.git"],
cwd=clone_dir,
check=False)
if result.returncode != 0:
print("Clone failed.")
sys.exit(1)
bazel_src_dir = clone_dir.joinpath("bazel")
result = subprocess.run(
["git", "checkout", commit],
cwd=bazel_src_dir,
check=False)
if result.returncode != 0:
print("Sync @%s failed." % commit)
return bazel_src_dir
def ensure_projects_clean():
"""Ensure that relevant projects in the working directory are ready.
The relevant projects must be clean, have fresh branches, and synced.
"""
# TODO(b/239044269): Automate instead of asking the user.
print_step_header("Manual step: Clear and sync all local projects.")
is_new_input = prompt("Are all relevant local projects in your working " +
"directory clean (fresh branches) and synced to " +
"HEAD?")
if not is_new_input:
print("Please ready your local projects before continuing with the " +
"release script")
sys.exit(1)
def run_update(commit, bazel_src_dir):
"""Run the update script to update prebuilts.
Retrieves a prebuilt bazel at the given commit, and updates other checked
in bazel prebuilts using bazel source tree at that commit.
"""
print_step_header("Updating prebuilts...")
update_script_path = pathlib.Path(UPDATE_SCRIPT_PATH).resolve()
cmd_args = [f"./{update_script_path.name}", commit, str(bazel_src_dir.absolute())]
target_cwd = update_script_path.parent.absolute()
print(f"Runnning update script (CWD: {target_cwd}): {' '.join(cmd_args)}")
if not dry_run:
logfile_path = temp_file_path("update.log")
print(f"Streaming results to {logfile_path}")
with logfile_path.open("w") as logfile:
result = subprocess.run(
cmd_args, cwd=target_cwd, check=False, stdout=logfile, stderr=logfile)
if result.returncode != 0:
print(f"Update failed. Check {logfile_path} for failure info.")
sys.exit(1)
print("Updated prebuilts successfully.")
print("Note this may have changed the following directories:")
for directory in AFFECTED_PROJECT_DIRECTORIES:
print(" " + directory)
def verify_update():
"""Run tests to verify the integrity of the Bazel release.
Failure during this step will require manual intervention by the user;
a failure might be fixed by updating other dependencies in the Android
tree, or might indicate that Bazel at the given commit is problematic
and the release may need to be abandoned.
"""
print_step_header("Verifying the update...")
cmd_args = [MIXED_DROID_PATH]
env = {
"TARGET_BUILD_VARIANT": "userdebug",
"TARGET_PRODUCT": "aosp_arm64",
"PATH": os.environ["PATH"]
}
env_string = " ".join([k + "=" + v for k, v in env.items()])
cmd_string = " ".join(cmd_args)
print(f"Running {env_string} {cmd_string}")
if not dry_run:
logfile_path = temp_file_path("verify.log")
print(f"Streaming results to {logfile_path}")
with logfile_path.open("w") as logfile:
result = subprocess.run(
cmd_args, env=env, check=False, stdout=logfile, stderr=logfile)
if result.returncode != 0:
print(f"Verification failed. Check {logfile_path} for failure info.")
print("Please remedy all issues until verification runs successfully.\n" +
"You may skip to the verify step in this script by using " +
"--verify-only")
sys.exit(1)
print("Verification successful.")
else:
print("Dry run: Verification skipped")
def create_commits():
"""Create commits for all projects related to the Bazel release."""
print_step_header(
"Manual step: Create CLs for all projects that need to be " + "updated.")
# TODO(b/239044269): Automate instead of asking the user.
commits_created = prompt("Have you created CLs for all projects that need " +
"to be updated?")
if not commits_created:
print(
"Create CLs for all projects. After approval and CL submission, the " +
"release is complete.")
sys.exit(1)
def verify_run_from_top():
"""Verifies that this script is being run from the workspace root.
Prints an error and exits if this is not the case.
"""
if not pathlib.Path(UPDATE_SCRIPT_PATH).is_file():
print(f"{UPDATE_SCRIPT_PATH} not found. Are you running from the " +
"source root?")
sys.exit(1)
def main():
verify_run_from_top()
parser = argparse.ArgumentParser(
description="Walks the user through all steps required to cut a new " +
"Bazel binary (and related artifacts) for AOSP. This script is " +
"intended for use only by the current Bazel release manager.")
parser.add_argument(
"--commit",
default=None,
required=True,
help="The bazel commit hash to use. Must be specified.")
parser.add_argument(
"--force",
"-f",
action=argparse.BooleanOptionalAction,
help="If true, will update bazel to the given commit " +
"even if it is older than the current bazel binary.")
parser.add_argument(
"--verify-only",
action=argparse.BooleanOptionalAction,
help="If true, will only do verification and CL " +
"creation if verification passes.")
parser.add_argument(
"--dry-run",
action=argparse.BooleanOptionalAction,
help="If true, will not make any changes to local " +
"projects, and will instead output commands that " +
"should be run to do so.")
args = parser.parse_args()
global dry_run
dry_run = args.dry_run
if not args.verify_only:
commit = target_update_commit(args)
bazel_src_dir = checkout_bazel_at(commit)
if not args.force:
ensure_commit_is_new(commit, bazel_src_dir)
ensure_projects_clean()
run_update(commit, bazel_src_dir)
verify_update()
create_commits()
print("Bazel release CLs created. After approval and " +
"CL submission, the release is complete.")
if __name__ == "__main__":
main()