blob: a3a594698a97aaea2e9133d5207b84d3ef0fd9c6 [file] [edit]
#!/usr/bin/env python3
#
# Copyright 2026 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.
# pylint: disable=line-too-long
# pylint: disable=missing-function-docstring
# pylint: disable=unspecified-encoding
# pylint: disable=invalid-name
"""The CI script for Bazel Central Registry Downstream Test pipeline."""
import argparse
import os
import pathlib
import re
import shutil
import subprocess
import sys
import time
import yaml
import bazelci
import bcr_presubmit
SCRIPT_URL = "https://raw.githubusercontent.com/bazelbuild/continuous-integration/{}/buildkite/bazel-central-registry/bcr_downstream.py?{}".format(
bazelci.GITHUB_REF, int(time.time())
)
# Limit CI resource consumption by default (10% of machines per queue) so
# downstream test builds do not starve bcr-presubmit.
DEFAULT_CI_RESOURCE_PERCENTAGE = 10
CI_RESOURCE_PERCENTAGE = int(
os.environ.get("CI_RESOURCE_PERCENTAGE", DEFAULT_CI_RESOURCE_PERCENTAGE)
)
# Default maximum number of direct downstream modules to select by PageRank.
DEFAULT_TOP_BCR_MODULES = 50
# `bazel vendor` is only available since Bazel 7.
MIN_VENDOR_BAZEL_MAJOR_VERSION = 7
def fetch_bcr_downstream_py_command():
return bazelci.curl_download_command(SCRIPT_URL, "bcr_downstream.py")
def parse_module(value):
"""Parse and validate a module in "<name>@<version>" format."""
name, _, version = value.strip().partition("@")
if not bcr_presubmit.is_valid_module_identifier(name, version):
raise ValueError(f"Invalid module (expected <name>@<version>): {value!r}")
return name, version
# Matches the major version of a pinned or wildcard Bazel version, e.g. "8.x", "8.*", "8.4.2",
# "9.0.0rc1" or "10.0.0-pre.20260911.2".
BAZEL_MAJOR_VERSION_RE = re.compile(r"^(\d+)(?:\.|$)")
def get_bazel_major_version(bazel_version):
"""Return the major version of a Bazel version, or None for symbolic ones like "rolling"."""
m = BAZEL_MAJOR_VERSION_RE.match(str(bazel_version).strip())
return int(m.group(1)) if m else None
# Pipeline generation (`bcr_downstream.py bcr_downstream`).
def select_module_versions(selections, random_percentage=None):
"""Resolve module selection patterns (e.g. "grpc@latest") using ./tools/module_selector.py."""
args = [f"--select={s}" for s in selections]
if random_percentage:
args.append(f"--random-percentage={random_percentage}")
output = subprocess.check_output(
["python3", "./tools/module_selector.py"] + args,
cwd=bcr_presubmit.BCR_REPO_DIR,
)
return sorted({parse_module(line) for line in output.decode("utf-8").split()})
def get_target_module():
"""Return the target (module_name, module_version) to test downstream dependents for.
It's resolved from the TARGET_MODULE env var (e.g. "protobuf@latest") if it is set, or detected
from the files changed on the current branch otherwise. Returns None if there is no target.
"""
selection = os.environ.get("TARGET_MODULE", "").strip()
if selection:
target_modules = select_module_versions([selection])
else:
target_modules = bcr_presubmit.get_target_modules()
if len(target_modules) > 1:
found = ", ".join(f"{name}@{version}" for name, version in target_modules)
bcr_presubmit.error(
f"Only one target module can be tested in a build, found: {found}. "
"Please set TARGET_MODULE to choose one."
)
return target_modules[0] if target_modules else None
# Characters allowed in a Bazel version (e.g. "8.x", "9.0.0rc1", "last_green" or "<fork>/latest"),
# which also makes sure the version can be safely used in step commands.
BAZEL_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._*/+-]*$")
def parse_int_option(name, value, min_value, max_value=None):
try:
number = int(str(value).strip())
except ValueError:
number = None
if number is None or number < min_value or (max_value is not None and number > max_value):
expected = f">= {min_value}" if max_value is None else f"from {min_value} to {max_value}"
bcr_presubmit.error(f"Invalid {name}: {value!r}, expected an integer {expected}.")
return number
def parse_bool_option(name, value):
normalized = str(value).strip().lower()
if normalized not in ("1", "true", "yes", "0", "false", "no"):
bcr_presubmit.error(f"Invalid {name}: {value!r}, expected true or false.")
return normalized in ("1", "true", "yes")
def parse_list_option(name, value):
if isinstance(value, str):
value = value.split(",")
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
bcr_presubmit.error(f"Invalid {name}: {value!r}, expected a list of strings.")
return tuple(v.strip() for v in value if v.strip())
def parse_bazel_version_option(name, value):
version = str(value).strip()
if not BAZEL_VERSION_RE.match(version):
bcr_presubmit.error(f"Invalid {name}: {value!r}, expected a Bazel version such as 8.x.")
return version
# Options of the downstream test as {name: (default, parser)}. The target module can set them in its
# presubmit.yml under `bcr_downstream_test`, the environment variables with the same names in upper
# case (e.g. SELECT_TOP_BCR_MODULES) take precedence when set.
DOWNSTREAM_TEST_CONFIG_KEY = "bcr_downstream_test"
DOWNSTREAM_TEST_OPTIONS = {
"select_top_bcr_modules": (DEFAULT_TOP_BCR_MODULES, lambda n, v: parse_int_option(n, v, 0)),
"module_selections": ((), parse_list_option),
"smoke_test_percentage": (None, lambda n, v: parse_int_option(n, v, 1, 100)),
"exclude_dev_deps": (False, parse_bool_option),
"use_bazel_version": (None, parse_bazel_version_option),
}
def get_downstream_test_options(module_name, module_version):
"""Return the downstream test options for the target module.
Each option comes from its environment variable if it's set, otherwise from the target module's
presubmit.yml, otherwise its default value.
"""
target = f"{module_name}@{module_version}"
with open(bcr_presubmit.get_presubmit_yml(module_name, module_version), "r") as f:
config = (yaml.safe_load(f) or {}).get(DOWNSTREAM_TEST_CONFIG_KEY) or {}
if not isinstance(config, dict):
bcr_presubmit.error(
f"`{DOWNSTREAM_TEST_CONFIG_KEY}` in the presubmit.yml of {target} must be a map."
)
unknown_keys = sorted(str(k) for k in config if k not in DOWNSTREAM_TEST_OPTIONS)
if unknown_keys:
bcr_presubmit.error(
f"Unknown option(s) {unknown_keys} under `{DOWNSTREAM_TEST_CONFIG_KEY}` in the "
f"presubmit.yml of {target}, supported options are {list(DOWNSTREAM_TEST_OPTIONS)}."
)
options = {}
for key, (default, parse) in DOWNSTREAM_TEST_OPTIONS.items():
env_value = os.environ.get(key.upper(), "").strip()
if env_value:
options[key] = parse(key.upper(), env_value)
elif config.get(key) is not None:
options[key] = parse(f"{DOWNSTREAM_TEST_CONFIG_KEY}.{key} of {target}", config[key])
else:
options[key] = default
customized = {k: v for k, v in options.items() if v != DOWNSTREAM_TEST_OPTIONS[k][0]}
bazelci.eprint(f"* Downstream test options: {customized or 'defaults'}")
return options
def get_top_dependents(module_name, top_n, exclude_dev_deps):
"""Return the top N direct dependents of a module ranked by BCR PageRank."""
# Remove USE_BAZEL_VERSION to make this step more stable.
env = os.environ.copy()
env.pop("USE_BAZEL_VERSION", None)
cmd = ["bazel", "run", "//tools:module_analyzer", "--", "--name-only", f"--top_n={top_n}"]
if exclude_dev_deps:
cmd.append("--exclude-dev-deps")
cmd.append(f"--dependents_of={module_name}")
output = subprocess.check_output(cmd, cwd=bcr_presubmit.BCR_REPO_DIR, env=env)
return output.decode("utf-8").split()
def select_downstream_modules(target_name, options):
"""Return the downstream module versions to test against the target module."""
selections = options["module_selections"]
if not selections and options["select_top_bcr_modules"]:
dependents = get_top_dependents(
target_name, options["select_top_bcr_modules"], options["exclude_dev_deps"]
)
selections = [f"{m}@latest" for m in dependents]
if not selections:
return []
modules = select_module_versions(selections, options["smoke_test_percentage"])
if modules:
bazelci.print_expanded_group(
"The following downstream modules are selected:\n\n%s"
% "\n".join([f"{name}@{version}" for name, version in modules])
)
return modules
def get_task_bazel_versions(module_name, module_version):
"""Return the Bazel versions used by a module's presubmit tasks, including bcr_test_module."""
configs = [
bcr_presubmit.get_anonymous_module_task_config(module_name, module_version),
bcr_presubmit.get_test_module_task_config(module_name, module_version),
]
return {
str(task_config["bazel"])
for config in configs
for task_config in config.get("tasks", {}).values()
if task_config.get("bazel")
}
def get_target_bazel_major_versions(module_name, module_version):
"""Return the Bazel major versions tested by the target module's presubmit.yml.
Returns (majors, tests_newest), where `majors` are the major versions of the pinned Bazel
versions (e.g. {8, 9} for "8.x" and "9.*"), and `tests_newest` tells whether the target is also
tested with a symbolic version (e.g. "rolling", "last_green") tracking the newest Bazel, in
which case any major version newer than the highest pinned one is considered tested too.
Returns None if the target module doesn't pin any Bazel version, so that no task is filtered.
"""
target = f"{module_name}@{module_version}"
versions = get_task_bazel_versions(module_name, module_version)
majors = {get_bazel_major_version(v) for v in versions} - {None}
symbolic_versions = sorted(v for v in versions if get_bazel_major_version(v) is None)
if not majors:
bazelci.eprint(
f"* Not filtering downstream tasks by Bazel version for {target}: no pinned Bazel "
f"version in its presubmit.yml (found {symbolic_versions})"
)
return None
message = f"* {target} is tested with Bazel major versions {sorted(majors)}"
if symbolic_versions:
message += f" and {', '.join(symbolic_versions)}, newer major versions are also allowed"
bazelci.eprint(message)
return majors, bool(symbolic_versions)
def filter_tasks_by_bazel_major_versions(module_name, module_version, task_configs, tested_majors):
"""Drop tasks whose Bazel major version is not tested by the target module."""
if not tested_majors:
return task_configs
majors, tests_newest = tested_majors
filtered = {}
for task_id, task_config in task_configs.items():
bazel_version = task_config.get("bazel")
major = get_bazel_major_version(bazel_version)
# Keep tasks whose major version can't be determined statically (e.g. "latest", "rolling").
if major is None or major in majors or (tests_newest and major > max(majors)):
filtered[task_id] = task_config
else:
bazelci.eprint(
f"* Skipping {module_name}@{module_version} task {task_id!r}: "
f"Bazel {bazel_version} is not tested by the target module"
)
return filtered
def create_downstream_steps(
module_name,
module_version,
target,
task_configs,
is_test_module,
overwrite_bazel_version,
low_priority,
):
"""Return the steps running tasks of a downstream module against the target module."""
steps = []
for task_id, task_config in task_configs.items():
platform_name = bcr_presubmit.get_platform(task_id, task_config)
platform_label = bazelci.PLATFORMS[platform_name]["emoji-name"]
task_name = task_config.get("name", "")
label = f"{module_name}@{module_version} (with {target}) - {platform_label} - {task_name}"
bazel_version = task_config.get("bazel", "")
if bazel_version and not overwrite_bazel_version:
label = f":bazel:{bazel_version} - {label}"
command = [
bazelci.PLATFORMS[platform_name]["python"],
"bcr_downstream.py",
"test_module_runner" if is_test_module else "anonymous_module_runner",
f'--module="{module_name}@{module_version}"',
f'--target_module="{target}"',
f"--task={task_id}",
]
if overwrite_bazel_version:
command.append(f'--overwrite_bazel_version="{overwrite_bazel_version}"')
commands = [
bazelci.fetch_ci_scripts_command(),
bcr_presubmit.fetch_bcr_presubmit_py_command(),
fetch_bcr_downstream_py_command(),
" ".join(command),
]
queue = bazelci.PLATFORMS[platform_name].get("queue", "default")
if CI_RESOURCE_PERCENTAGE == -1:
concurrency = concurrency_group = None
else:
concurrency = max(
1, (CI_RESOURCE_PERCENTAGE * bcr_presubmit.CI_MACHINE_NUM[queue]) // 100
)
concurrency_group = f"bcr-downstream-test-queue-{queue}"
if low_priority:
concurrency = 5 if concurrency is None else min(5, concurrency)
concurrency_group = f"bcr-downstream-test-queue-{queue}-low-priority"
steps.append(
bazelci.create_step(
label,
commands,
platform_name,
concurrency=concurrency,
concurrency_group=concurrency_group,
priority=-100 if low_priority else -50,
)
)
return steps
def generate_pipeline():
"""Upload the downstream test jobs for the target module to the pipeline."""
target_module = get_target_module()
if not target_module:
bazelci.eprint("No target module version detected for downstream testing.")
return
target_name, target_version = target_module
target = f"{target_name}@{target_version}"
bazelci.print_expanded_group(f"Target module for downstream testing: {target}")
options = get_downstream_test_options(target_name, target_version)
downstream_modules = select_downstream_modules(target_name, options)
if not downstream_modules:
bazelci.eprint(f"No downstream modules selected for {target}.")
return
# An explicit Bazel version (USE_BAZEL_VERSION or `use_bazel_version`) overrides the Bazel
# versions of all downstream tasks. Otherwise, downstream tasks with Bazel major versions not
# tested by the target module are skipped.
bazel_version = options["use_bazel_version"]
if bazel_version:
bazelci.eprint(f"* Running all downstream tasks with Bazel {bazel_version}")
tested_majors = None
else:
tested_majors = get_target_bazel_major_versions(target_name, target_version)
low_priority = "low-ci-priority" in bcr_presubmit.get_labels_from_pr()
pipeline_steps = []
for module_name, module_version in downstream_modules:
for is_test_module, get_task_config in (
(False, bcr_presubmit.get_anonymous_module_task_config),
(True, bcr_presubmit.get_test_module_task_config),
):
task_configs = get_task_config(module_name, module_version, bazel_version)
task_configs = filter_tasks_by_bazel_major_versions(
module_name, module_version, task_configs.get("tasks", {}), tested_majors
)
pipeline_steps += create_downstream_steps(
module_name,
module_version,
target,
task_configs,
is_test_module,
bazel_version,
low_priority,
)
bcr_presubmit.upload_jobs_to_pipeline(pipeline_steps)
# Downstream task runner (`bcr_downstream.py {anonymous_module_runner,test_module_runner}`).
def get_vendor_bazel_version(bazel_version):
"""Return the Bazel version used to vendor the target module for a task.
Use the task's own Bazel version so that the module graph is resolved the same way as in the
downstream build, but at least Bazel 7 since `bazel vendor` doesn't exist in older versions.
"""
bazel_version = bazel_version or "latest"
major = get_bazel_major_version(bazel_version)
if major is not None and major < MIN_VENDOR_BAZEL_MAJOR_VERSION:
return f"{MIN_VENDOR_BAZEL_MAJOR_VERSION}.x"
return bazel_version
def vendor_target_module(module_name, module_version, bazel_version, root=None):
"""Vendor the target module with `bazel vendor` and return the path of its source."""
bazelci.print_collapsed_group(":package: Vendoring the target module for override")
root = pathlib.Path(root or bazelci.get_repositories_root())
root.mkdir(exist_ok=True, parents=True)
vendor_workspace = root.joinpath(".temp_vendor_target_module")
shutil.rmtree(vendor_workspace, ignore_errors=True)
vendor_workspace.mkdir(exist_ok=True, parents=True)
bcr_presubmit.scratch_file(vendor_workspace, "WORKSPACE")
bcr_presubmit.scratch_file(vendor_workspace, "BUILD")
bcr_presubmit.scratch_file(
vendor_workspace,
"MODULE.bazel",
[f"bazel_dep(name = '{module_name}', version = '{module_version}')"],
)
bcr_presubmit.scratch_file(
vendor_workspace,
".bazelrc",
[
"common --enable_bzlmod",
"common --registry=%s" % bcr_presubmit.BCR_REPO_DIR.as_uri(),
],
)
vendor_bazel_version = get_vendor_bazel_version(bazel_version)
bazelci.eprint(f"* Vendoring {module_name}@{module_version} with Bazel {vendor_bazel_version}")
bazelci.execute_command(
["bazel", "--batch"]
+ bazelci.common_startup_flags()
+ [
"vendor",
"--incompatible_use_plus_in_repo_names",
"--vendor_dir=./vendor_src",
"--repository_cache=",
"--lockfile_mode=off",
# Only the source tree is needed here, the downstream build still enforces
# bazel_compatibility (e.g. when vendoring with Bazel 7 for a Bazel 6 task).
"--check_bazel_compatibility=warning",
f"--repo=@{module_name}",
],
cwd=vendor_workspace,
env={**os.environ, "USE_BAZEL_VERSION": vendor_bazel_version},
)
vendor_src_dir = vendor_workspace.joinpath("vendor_src")
# The canonical repo name is "<name>+", except for well-known modules such as "platforms".
candidates = [vendor_src_dir.joinpath(d) for d in (f"{module_name}+", module_name)]
src_dir = next((d for d in candidates if d.is_dir()), None)
if not src_dir:
bcr_presubmit.error(f"Cannot find the vendored source of {module_name} in {vendor_src_dir}")
vendored_path = root.joinpath("vendored_target_module")
shutil.rmtree(vendored_path, ignore_errors=True)
shutil.move(src_dir, vendored_path)
vendored_path = vendored_path.resolve()
bazelci.eprint(f"* Vendored {module_name} to {vendored_path}")
return vendored_path
def configure_downstream_override(repo_location, module_name, vendored_path):
"""Append --override_module and dependency flags to the downstream module's .bazelrc."""
lines = [
"",
"# Override the target module with its vendored source for downstream testing",
"common --check_direct_dependencies=warning",
"common --lockfile_mode=update",
# Use POSIX path separators so backslashes are not treated as escapes on Windows.
f"common --override_module={module_name}={vendored_path.as_posix()}",
]
bcr_presubmit.scratch_file(repo_location, ".bazelrc", lines, mode="a")
bazelci.eprint("* Appended downstream override flags to .bazelrc:\n%s\n" % "\n".join(lines[1:]))
def run_downstream_task(args, is_test_module):
"""Run a presubmit task of a downstream module with the target module overridden."""
module_name, module_version = args.module
target_name, target_version = args.target_module
if is_test_module:
repo_location, config_file = bcr_presubmit.prepare_test_module_repo(
module_name, module_version, args.overwrite_bazel_version
)
else:
repo_location = bcr_presubmit.create_anonymous_repo(module_name, module_version)
config_file = bcr_presubmit.get_presubmit_yml(module_name, module_version)
# Vendor the target module with the same Bazel version as the task, so that
# the target module is fetched with a Bazel version it supports.
task_bazel_version = bcr_presubmit.get_bazel_version_for_task(
config_file, args.task, args.overwrite_bazel_version
)
vendored_path = vendor_target_module(target_name, target_version, task_bazel_version)
configure_downstream_override(repo_location, target_name, vendored_path)
return bcr_presubmit.run_test(
repo_location, config_file, args.task, args.overwrite_bazel_version
)
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
parser = argparse.ArgumentParser(
description="Bazel Central Registry Downstream Test Generator & Runner"
)
subparsers = parser.add_subparsers(dest="subparsers_name")
subparsers.add_parser("bcr_downstream")
for runner in ("anonymous_module_runner", "test_module_runner"):
runner_parser = subparsers.add_parser(runner)
runner_parser.add_argument("--module", type=parse_module, required=True)
runner_parser.add_argument("--target_module", type=parse_module, required=True)
runner_parser.add_argument("--overwrite_bazel_version", type=str)
runner_parser.add_argument("--task", type=str, required=True)
args = parser.parse_args(argv)
if args.subparsers_name == "bcr_downstream":
generate_pipeline()
return 0
if args.subparsers_name in ("anonymous_module_runner", "test_module_runner"):
return run_downstream_task(
args, is_test_module=args.subparsers_name == "test_module_runner"
)
parser.print_help()
return 2
if __name__ == "__main__":
sys.exit(main())