Add bcr_compatibility.py (#2079)

Stack on https://github.com/bazelbuild/continuous-integration/pull/2077

- Added `bcr_compatibility.py` for
https://buildkite.com/bazel/bcr-bazel-compatibility-test
- Added documentation for BCR scripts.
diff --git a/buildkite/bazel-central-registry/README.md b/buildkite/bazel-central-registry/README.md
new file mode 100644
index 0000000..f002499
--- /dev/null
+++ b/buildkite/bazel-central-registry/README.md
@@ -0,0 +1,39 @@
+## BCR Postsubmit
+
+`bcr_postsubmit.py` is a script used for Bazel Central Registry (BCR) postsubmit operations. It synchronizes the `bazel_registry.json` and the `modules/` directory from the main branch of the Bazel Central Registry to the BCR's public cloud storage bucket.
+
+## BCR Presubmit
+
+`bcr_presubmit.py` is a script used for Bazel Central Registry (BCR) [presubmit operations](https://github.com/bazelbuild/bazel-central-registry/blob/main/docs/README.md#presubmit). This script primarily handles the preparation and execution of tests for new modules or updated versions of modules being added to the Bazel Central Registry.
+
+This script powers the [BCR Presubmit](https://buildkite.com/bazel/bcr-presubmit) pipeline.
+
+## BCR Bazel Compatibility Test
+
+`bcr_compatibility.py` is a script used for testing compatibility between any versions of Bazel and BCR modules, and optionally with given incompatible flags.
+
+A new build can be triggered via the [BCR Bazel Compatibility Test](https://buildkite.com/bazel/bcr-bazel-compatibility-test) pipeline with the following environment variables:
+
+* `MODULE_SELECTIONS`: (Mandatory) A comma-separated list of module patterns to be tested in the format `<module_pattern>@<version_pattern>`. A module is selected if it matches any of the given patterns.
+
+    The `<module_pattern>` can include wildcards (*) to match multiple modules (e.g. `rules_*`).
+
+    The `<version_pattern>` can be:
+
+    - A specific version (e.g. `1.2.3`)
+    - `latest` to select the latest version
+    - A comparison operator followed by a version (e.g. `>=1.0.0`, `<2.0.0`)
+
+    Examples: `rules_cc@0.0.13,rules_java@latest`, `rules_*@latest`, `protobuf@<29.0-rc1`
+
+* `SMOKE_TEST_PERCENTAGE`: (Optional) Specifies a percentage of selected modules to be randomly sampled for smoke testing.
+
+    For example, if `MODULE_SELECTIONS=rules_*@latest` and `SMOKE_TEST_PERCENTAGE=10`, then 10% of modules with name starting with `rules_` will be randomly selected.
+
+* `USE_BAZEL_VERSION`: (Optional) Specifies the Bazel version to be used. The script will override Bazel version for all task configs.
+
+* `USE_BAZELISK_MIGRATE`: (Optional) Set this env var to `1` to enable testing incompatible flags with Bazelisk's [`--migrate`](https://github.com/bazelbuild/bazelisk?tab=readme-ov-file#--migrate) feature. A report will be generated for the pipeline if this feature is enabled.
+
+* `INCOMPATIBLE_FLAGS`: (Optional) Specifies the list of incompatible flags to be tested with Bazelisk. By default incompatible flags are fetched by parsing titles of [open Bazel Github issues](https://github.com/bazelbuild/bazel/issues?q=is%3Aopen+is%3Aissue+label%3Aincompatible-change+label%3Amigration-ready) with `incompatible-change` and `migration-ready` labels. Make sure the Bazel version you select support those flags.
+
+* `CI_RESOURCE_PERCENTAGE`: (Optional) Specifies the percentage of CI machine resources to use for running tests. Default is 30%. **ATTENTION**: please do NOT overwhelm CI during busy hours.
diff --git a/buildkite/bazel-central-registry/bcr_compatibility.py b/buildkite/bazel-central-registry/bcr_compatibility.py
new file mode 100644
index 0000000..a3397d3
--- /dev/null
+++ b/buildkite/bazel-central-registry/bcr_compatibility.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+#
+# Copyright 2024 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 BCR Bazel Compatibility Test pipeline."""
+
+
+import os
+import sys
+import subprocess
+
+import bazelci
+import bcr_presubmit
+
+CI_MACHINE_NUM = {
+    "bazel": {
+        "default": 100,
+        "windows": 30,
+        "macos_arm64": 45,
+        "macos": 110,
+        "arm64": 1,
+    },
+    "bazel-testing": {
+        "default": 30,
+        "windows": 4,
+        "macos_arm64": 1,
+        "macos": 10,
+        "arm64": 1,
+    },
+}[bazelci.BUILDKITE_ORG]
+
+# Default to use only 30% of CI resources for each type of machines.
+CI_RESOURCE_PERCENTAGE = int(os.environ.get('CI_RESOURCE_PERCENTAGE', 30))
+
+
+def select_modules_from_env_vars():
+    """
+    Parses MODULE_SELECTIONS and SMOKE_TEST_PERCENTAGE environment variables
+    and returns a list of selected module versions.
+    """
+    MODULE_SELECTIONS = os.environ.get('MODULE_SELECTIONS', '')
+    SMOKE_TEST_PERCENTAGE = os.environ.get('SMOKE_TEST_PERCENTAGE', None)
+
+    if not MODULE_SELECTIONS:
+        return []
+
+    selections = [s.strip() for s in MODULE_SELECTIONS.split(',') if s.strip()]
+    args = [f"--select={s}" for s in selections]
+    if SMOKE_TEST_PERCENTAGE:
+        args += [f"--random-percentage={SMOKE_TEST_PERCENTAGE}"]
+    output = subprocess.check_output(
+        ["python3", "./tools/module_selector.py"] + args,
+    )
+    modules = []
+    for line in output.decode("utf-8").split():
+        name, version = line.strip().split("@")
+        modules.append((name, version))
+    return modules
+
+
+def get_target_modules():
+    """
+    If the `MODULE_SELECTIONS` and `SMOKE_TEST_PERCENTAGE(S)` are specified, calculate the target modules from those env vars.
+    Otherwise, calculate target modules based on changed files from the main branch.
+    """
+    if "MODULE_SELECTIONS" not in os.environ:
+        raise ValueError("Please set MODULE_SELECTIONS env var to select modules for testing!")
+
+    modules = select_modules_from_env_vars()
+    if modules:
+        bazelci.print_expanded_group("The following modules are selected:\n\n%s" % "\n".join([f"{name}@{version}" for name, version in modules]))
+        return sorted(list(set(modules)))
+    else:
+        raise ValueError("MODULE_SELECTIONS env var didn't select any modules!")
+
+
+def create_step_for_report_flags_results():
+    parts = [
+        bazelci.PLATFORMS[bazelci.DEFAULT_PLATFORM]["python"],
+        "aggregate_incompatible_flags_test_result.py",
+        "--build_number=%s" % os.getenv("BUILDKITE_BUILD_NUMBER"),
+    ]
+    return [
+        {"wait": "~", "continue_on_failure": "true"},
+        bazelci.create_step(
+            label="Aggregate incompatible flags test result",
+            commands=[
+                bazelci.fetch_bazelcipy_command(),
+                bazelci.fetch_aggregate_incompatible_flags_test_result_command(),
+                " ".join(parts),
+            ],
+            platform=bazelci.DEFAULT_PLATFORM,
+        ),
+    ]
+
+def main():
+    modules = get_target_modules()
+    pipeline_steps = []
+    # A function to calculate concurrency number for each BuildKite queue
+    calc_concurrency = lambda queue : max(1, (CI_RESOURCE_PERCENTAGE * CI_MACHINE_NUM[queue]) // 100)
+    # Respect USE_BAZEL_VERSION to override bazel version in presubmit.yml files.
+    bazel_version = os.environ.get("USE_BAZEL_VERSION")
+    for module_name, module_version in modules:
+        previous_size = len(pipeline_steps)
+
+        configs = bcr_presubmit.get_anonymous_module_task_config(module_name, module_version, bazel_version)
+        bcr_presubmit.add_presubmit_jobs(module_name, module_version, configs.get("tasks", {}), pipeline_steps, overwrite_bazel_version=bazel_version, calc_concurrency=calc_concurrency)
+        configs = bcr_presubmit.get_test_module_task_config(module_name, module_version, bazel_version)
+        bcr_presubmit.add_presubmit_jobs(module_name, module_version, configs.get("tasks", {}), pipeline_steps, is_test_module=True, overwrite_bazel_version=bazel_version, calc_concurrency=calc_concurrency)
+
+        if len(pipeline_steps) == previous_size:
+            bcr_presubmit.error("No pipeline steps generated for %s@%s. Please check the configuration." % (module_name, module_version))
+
+    if pipeline_steps:
+        # Always wait for approval to proceed
+        pipeline_steps = [{"block": "Please review generated jobs before proceeding", "blocked_state": "running"}] + pipeline_steps
+        if bazelci.use_bazelisk_migrate():
+            pipeline_steps += create_step_for_report_flags_results()
+
+    bcr_presubmit.upload_jobs_to_pipeline(pipeline_steps)
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/buildkite/bazel-central-registry/bcr_presubmit.py b/buildkite/bazel-central-registry/bcr_presubmit.py
index b48f459..ef4d99d 100755
--- a/buildkite/bazel-central-registry/bcr_presubmit.py
+++ b/buildkite/bazel-central-registry/bcr_presubmit.py
@@ -499,7 +499,7 @@
                 error("No pipeline steps generated for %s@%s. Please check the configuration." % (module_name, module_version))
 
         if should_wait_bcr_maintainer_review(modules) and pipeline_steps:
-            pipeline_steps = [{"block": "Wait on BCR maintainer review", "blocked_state": "running"}] + pipeline_steps
+            pipeline_steps.insert(0, {"block": "Wait on BCR maintainer review", "blocked_state": "running"})
 
         upload_jobs_to_pipeline(pipeline_steps)
     elif args.subparsers_name == "anonymous_module_runner":