Simplify and update CI setup (#123)

Use `setup-bazel` and a regular GHA matrix directly, which makes it
easier to control Bazel versions and submodules to test.
diff --git a/.bazelrc b/.bazelrc
index 86581a6..cdfcdbf 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -2,6 +2,8 @@
 # Take care to document any settings that you expect users to apply.
 # Settings that apply only to CI are in .github/workflows/ci.bazelrc
 
+common --@protobuf//bazel/toolchains:prefer_prebuilt_protoc
+common --incompatible_enable_proto_toolchain_resolution
 
 # Load any settings specific to the current user.
 # .bazelrc.user should appear in .gitignore so that settings are not shared with team members
diff --git a/.bazelversion b/.bazelversion
index 815da58..f7ee066 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-7.4.1
+9.0.0
diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml
index b459441..9da917e 100644
--- a/.bcr/presubmit.yml
+++ b/.bcr/presubmit.yml
@@ -4,11 +4,10 @@
     platform:
       - rockylinux8
       - debian11
-      - ubuntu2204
-      - macos
+      - ubuntu2404
       - macos_arm64
       - windows
-    bazel: [6.x, 7.x, 8.x]
+    bazel: [6.*, 7.*, 8.*, 9.*]
   tasks:
     run_test_module:
       name: Run test module
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 9eda7bf..5497847 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -9,6 +9,8 @@
     branches: [main]
   # Allows you to run this workflow manually from the Actions tab
   workflow_dispatch:
+  schedule:
+    - cron: "0 0 * * *"
 
 concurrency:
   # Cancel previous actions from the same PR: https://stackoverflow.com/a/72408109
@@ -16,26 +18,39 @@
   cancel-in-progress: true
 
 jobs:
-  unit_tests:
-    uses: bazel-contrib/.github/.github/workflows/bazel.yaml@v6
-    with:
-      folders: '["."]'
-  bcr_test:
-    uses: bazel-contrib/.github/.github/workflows/bazel.yaml@v6
-    with:
-      folders: '["test/bcr_test"]'
-      exclude: |
-        [
-          {"bzlmodEnabled": false},
-          {"bazelversion": "5.4.0"}
-        ]
   test:
-    runs-on: ubuntu-latest
-    needs:
-      - unit_tests
-      - bcr_test
-    if: ${{ always() }}
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest]
+        bazelversion: [6.*, 7.*, 8.*, 9.*, last_green]
+        folder: ["test/bcr_test", "."]
+        include:
+          # bazel_features is intended to be OS-agnostic, so we only test other OSes with the latest Bazel version
+          - os: macos-latest
+            bazelversion: 9.*
+          - os: windows-latest
+            bazelversion: 9.*
+          - bazelversion: 6.*
+            # Protobuf isn't provided by the WORKSPACE setup, but referenced in the .bazelrc file.
+            extra_arg: "--ignore_all_rc_files"
+        exclude:
+          - bazelversion: 6.*
+            folder: "test/bcr_test"
+
     steps:
-      - uses: cgrindel/gha_join_jobs@v1
+      - uses: actions/checkout@v6
+
+      - uses: bazel-contrib/setup-bazel@0.18.0
         with:
-          github_token: ${{ secrets.GITHUB_TOKEN }}
+          bazelisk-cache: true
+          disk-cache: ${{ github.workflow }}-${{ matrix.os }}
+          repository-cache: true
+          cache-save: ${{ github.event_name != 'pull_request' }}
+
+      - name: bazel test //...
+        working-directory: ${{ matrix.folder }}
+        shell: bash
+        run: USE_BAZEL_VERSION=${{ matrix.bazelversion }} bazel ${{ matrix.extra_arg }} test //...
diff --git a/MODULE.bazel b/MODULE.bazel
index 11a87b5..a66ed08 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -5,12 +5,17 @@
 
 bazel_dep(name = "bazel_skylib", version = "1.6.1")
 
+version = use_extension("//private:extensions.bzl", "version_extension")
+use_repo(version, "bazel_features_globals", "bazel_features_version")
+
 bazel_dep(
     name = "stardoc",
     version = "0.6.2",
     dev_dependency = True,
     repo_name = "io_bazel_stardoc",
 )
-
-version = use_extension("//private:extensions.bzl", "version_extension")
-use_repo(version, "bazel_features_globals", "bazel_features_version")
+bazel_dep(
+    name = "protobuf",
+    version = "33.4",
+    dev_dependency = True,
+)
diff --git a/private/globals_repo.bzl b/private/globals_repo.bzl
index 4c1e8ab..34b0713 100644
--- a/private/globals_repo.bzl
+++ b/private/globals_repo.bzl
@@ -27,9 +27,13 @@
             fail("Invalid global name: %s" % global_)
         if not min_version and not max_version:
             fail("invalid config for:", global_, "expected at least one version")
-        value = (
-            global_ if not min_version or bazel_version >= parse_version(min_version) else "None"
-        ) if bazel_version < parse_version(max_version) else "None"
+        if (
+            (not min_version or bazel_version >= parse_version(min_version)) and
+            (not max_version or bazel_version < parse_version(max_version))
+        ):
+            value = global_
+        else:
+            value = "None"
 
         # If the legacy_globals is available, we take the value from it.
         # The value is populated by --incompatible_autoload_externally and may apply to older Bazel versions
diff --git a/test/test.bzl b/test/test.bzl
index 195b227..ff0f9f7 100644
--- a/test/test.bzl
+++ b/test/test.bzl
@@ -3,27 +3,7 @@
 load("//:features.bzl", "bazel_features")
 load("//private:parse.bzl", "parse_version")
 load("//private:util.bzl", "BAZEL_VERSION", "ge", "lt")
-
-def _empty_test_impl(ctx):
-    extension = ".bat" if ctx.attr.is_windows else ".sh"
-    content = "exit 0" if ctx.attr.is_windows else "#!/usr/bin/env bash\nexit 0"
-    executable = ctx.actions.declare_file(ctx.label.name + extension)
-    ctx.actions.write(
-        output = executable,
-        is_executable = True,
-        content = content,
-    )
-
-    return [DefaultInfo(
-        files = depset([executable]),
-        executable = executable,
-    )]
-
-_empty_test = rule(
-    implementation = _empty_test_impl,
-    attrs = {"is_windows": attr.bool(mandatory = True)},
-    test = True,
-)
+load("@bazel_skylib//rules:build_test.bzl", "build_test")
 
 def _assert_lt(a, b):
     if parse_version(a) >= parse_version(b):
@@ -56,9 +36,8 @@
     if not bazel_features.globals.DefaultInfo == DefaultInfo:
         fail("bazel_features.globals.DefaultInfo != DefaultInfo")
 
-    # TODO: add tests with --incompatible_autoload_symbols
-    if lt("8.0.0") and not bazel_features.globals.ProtoInfo == ProtoInfo:
-        fail("bazel_features.globals.ProtoInfo != ProtoInfo")
+    if lt("8.0.0") != (bazel_features.globals.ProtoInfo != None):
+        fail("lt(\"8.0.0\") != (bazel_features.globals.ProtoInfo != None)")
 
     if not bazel_features.globals.__TestingOnly_NeverAvailable == None:
         fail("bazel_features.globals.__TestingOnly_NeverAvailable != None")
@@ -69,10 +48,7 @@
         fail("bazel_features.globals.CcSharedLibraryInfo == None")
 
     # the pseudo test target that doesn't actually test anything
-    _empty_test(
+    build_test(
         name = name,
-        is_windows = select({
-            "@bazel_tools//src/conditions:host_windows": True,
-            "//conditions:default": False,
-        }),
+        targets = ["BUILD.bazel"],
     )