workflow: download Bazelisk and Bazel with retries and mirror fallback (#4325)

`bazel-contrib/setup-bazel` resolves `bazelisk-version` by querying the
GitHub Releases API without retries—which intermittently returns an
empty `[]` response—and downloads the binary without retries or SHA-256
verification. Additionally, Bazelisk does not retry mid-stream transfer
failures when downloading the Bazel binary.

Replace the `bazelisk-version` input and shell retry script with a
Python download script in the `setup-bazel` composite action. The
script downloads Bazelisk directly from GitHub Releases with an npm
registry tarball fallback, verifies SHA-256 checksums, caches the
binary under the Bazelisk cache directory, and retries `bazel --version`
with exponential backoff.

* Also updates agent style rules to allow long string literals (such as
  URLs and hashes) to exceed line length limits.
diff --git a/.agents/rules/python.md b/.agents/rules/python.md
index 38006fd..3bacd7b 100644
--- a/.agents/rules/python.md
+++ b/.agents/rules/python.md
@@ -76,3 +76,7 @@
 ## Context Managers
 * Implement temporary state changes (e.g., `Git.checkout()`, `os.chdir()`) as
   context managers that restore prior state on exit.
+
+## Formatting
+* Do not line-break long string literals (such as URLs or hashes) to fit line
+  length limits; allow them to exceed the limit.
diff --git a/.agents/rules/starlark.md b/.agents/rules/starlark.md
index 97ed3d2..8a1b501 100644
--- a/.agents/rules/starlark.md
+++ b/.agents/rules/starlark.md
@@ -109,6 +109,8 @@
   existing files unless explicitly directed by the user.
 * **Line Length & Wrapping**: Wrap Markdown and Starlark lines to 80 columns in
   accordance with `.editorconfig`.
+  * **Long string literals**: Do not line-break long string literals (such as
+    URLs or hashes) to fit line length limits; allow them to exceed the limit.
   * **Long ternaries**: Use `if`/`else` statements when `a if cond else b`
     exceeds 80 columns (`buildifier` collapses multi-line ternaries).
 
diff --git a/.github/actions/setup-bazel/action.yaml b/.github/actions/setup-bazel/action.yaml
index c1ae945..c6fe1a5 100644
--- a/.github/actions/setup-bazel/action.yaml
+++ b/.github/actions/setup-bazel/action.yaml
@@ -17,9 +17,6 @@
     - name: Setup Bazel
       uses: bazel-contrib/setup-bazel@0.19.1
       with:
-        # Windows images have a fixed-version `bazel` on PATH, and Bazelisk
-        # < 1.29.0 has no windows-arm64 build.
-        bazelisk-version: 1.29.0
         bazelisk-cache: true
         disk-cache: true
         # Files hashed to compute the repository cache key. If only `true` is
@@ -46,13 +43,14 @@
           ${{ (github.head_ref || github.ref_name) && format('build --build_metadata=BRANCH_NAME={0}', github.head_ref || github.ref_name) || '' }}
           build --build_metadata="BUILDBUDDY_LINKS=[GHA Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
 
-    # setup-bazel only configures Bazelisk and restores caches; the Bazel
-    # binary itself is downloaded by Bazelisk on first invocation. Because
-    # Bazelisk does not retry mid-stream download failures, trigger and retry
-    # the download upfront via `bazel --version`.
+    # setup-bazel configures .bazelrc and restores caches. Download Bazelisk
+    # and the Bazel binary here with retries, SHA-256 verification, and mirror
+    # fallback instead of using setup-bazel's `bazelisk-version` input (which
+    # queries api.github.com/repos/bazelbuild/bazelisk/releases without
+    # retries) or leaving the Bazel download to the first build step.
     - name: Download Bazel
       shell: bash
-      run: "${GITHUB_ACTION_PATH}/download_bazel.sh"
+      run: "${GITHUB_ACTION_PATH}/download_bazel.py"
 
     # Use github-script to write the key directly to user.bazelrc without
     # passing it via environment variables or CLI args, keeping it out of
diff --git a/.github/actions/setup-bazel/download_bazel.py b/.github/actions/setup-bazel/download_bazel.py
new file mode 100755
index 0000000..c8d2017
--- /dev/null
+++ b/.github/actions/setup-bazel/download_bazel.py
@@ -0,0 +1,261 @@
+#!/usr/bin/env python3
+"""Downloads Bazelisk and the Bazel binary with retries and mirror fallback.
+
+Why we download Bazelisk here instead of setting `bazelisk-version` on
+`bazel-contrib/setup-bazel`:
+- `setup-bazel` resolves `bazelisk-version` by querying
+  `https://api.github.com/repos/bazelbuild/bazelisk/releases` without retries
+  (which intermittently returns HTTP 200 with an empty `[]` body) and downloads
+  the binary without retries or SHA-256 verification.
+- Storing the downloaded Bazelisk binary in `<bazelisk-cache>/bin` allows
+  `setup-bazel`'s `bazelisk-cache: true` to persist Bazelisk across workflow
+  runs alongside the downloaded Bazel binary.
+
+Why we also run `bazel --version` with retries:
+- Bazelisk retries initial HTTP GET headers (`client.Do`), but does not retry
+  mid-stream transfer errors during `io.Copy` (such as HTTP/2 `INTERNAL_ERROR`
+  stream resets from `releases.bazel.build`). Running `bazel --version`
+  triggers the Bazel download upfront (without starting the Bazel server JVM)
+  and retries on transient failures.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import io
+import os
+import platform
+import shutil
+import stat
+import subprocess
+import sys
+import tarfile
+import time
+import urllib.request
+from pathlib import Path
+
+DOWNLOAD_TIMEOUT_SECONDS = 120
+MAX_ATTEMPTS = 5
+INITIAL_DELAY_SECONDS = 2.0
+
+# Maps "<os>-<arch>" to a list of (url, sha256) tuples tried in order.
+# fmt: off
+BAZELISK_URLS: dict[str, list[tuple[str, str]]] = {
+    "darwin-amd64": [
+        ("https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-darwin-amd64", "023225736cea5dc88f2b0807d5b1af4eb0f69a4ed45e3994b2c18c263bc80e48"),
+        ("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "023225736cea5dc88f2b0807d5b1af4eb0f69a4ed45e3994b2c18c263bc80e48"),
+    ],
+    "darwin-arm64": [
+        ("https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-darwin-arm64", "dea3f3f5de2dbc5e269e0132cdd369d5efe738f7b973d5d4eb2b4f7055a97b39"),
+        ("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "dea3f3f5de2dbc5e269e0132cdd369d5efe738f7b973d5d4eb2b4f7055a97b39"),
+    ],
+    "linux-amd64": [
+        ("https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-linux-amd64", "22e7d3a188699982f661cf4687137ee52d1f24fec1ec893d91a6c4d791a75de8"),
+        ("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "22e7d3a188699982f661cf4687137ee52d1f24fec1ec893d91a6c4d791a75de8"),
+    ],
+    "linux-arm64": [
+        ("https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-linux-arm64", "8ded44b58a0d9425a4178af26cf17693feac3b87bdcfef0a2a0898fcd1afc9f2"),
+        ("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "8ded44b58a0d9425a4178af26cf17693feac3b87bdcfef0a2a0898fcd1afc9f2"),
+    ],
+    "windows-amd64": [
+        ("https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-windows-amd64.exe", "b9d65a1f7c2d7af885a96a4fd5aa36b40fb41816d30944390569eef908bdc954"),
+        ("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "b9d65a1f7c2d7af885a96a4fd5aa36b40fb41816d30944390569eef908bdc954"),
+    ],
+    "windows-arm64": [
+        ("https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-windows-arm64.exe", "85ba3d92a8bdcbecc06657b8c0ae30f4307b552d601d9d6246f8a98aec36c346"),
+        ("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "85ba3d92a8bdcbecc06657b8c0ae30f4307b552d601d9d6246f8a98aec36c346"),
+    ],
+}
+# fmt: on
+
+
+def _detect_os() -> str:
+    raw = platform.system().lower()
+    if raw.startswith("linux"):
+        return "linux"
+    if raw in ("macos", "darwin") or raw.startswith("darwin"):
+        return "darwin"
+    if raw.startswith(("windows", "mingw", "msys", "cygwin")):
+        return "windows"
+    raise RuntimeError(f"Unsupported operating system: {raw}")
+
+
+def _detect_arch() -> str:
+    raw = platform.machine().lower()
+    if raw in ("x64", "x86_64", "amd64"):
+        return "amd64"
+    if raw in ("arm64", "aarch64"):
+        return "arm64"
+    raise RuntimeError(f"Unsupported architecture: {raw}")
+
+
+def _verify_sha256_bytes(data: bytes, expected: str) -> bool:
+    actual = hashlib.sha256(data).hexdigest().lower()
+    if actual != expected.lower():
+        print(
+            f"::warning::SHA-256 mismatch: expected {expected.lower()}, got {actual}",
+            flush=True,
+        )
+        return False
+    return True
+
+
+def _bazelisk_cache_dir(os_name: str) -> Path:
+    bazelisk_home = os.environ.get("BAZELISK_HOME")
+    if bazelisk_home:
+        return Path(bazelisk_home)
+    home = Path.home()
+    if os_name == "darwin":
+        return home / "Library/Caches/bazelisk"
+    if os_name == "windows":
+        return home / "AppData/Local/bazelisk"
+    xdg_cache = os.environ.get("XDG_CACHE_HOME")
+    base = Path(xdg_cache) if xdg_cache else (home / ".cache")
+    return base / "bazelisk"
+
+
+def _clean_bazelisk_cache() -> None:
+    """Removes partial Bazelisk cache dirs so setup-bazel does not cache them."""
+    home = Path.home()
+    xdg_cache = os.environ.get("XDG_CACHE_HOME")
+    candidates = [
+        (Path(xdg_cache) if xdg_cache else (home / ".cache")) / "bazelisk",
+        home / "Library/Caches/bazelisk",
+        home / "AppData/Local/bazelisk",
+    ]
+    bazelisk_home = os.environ.get("BAZELISK_HOME")
+    if bazelisk_home:
+        candidates.append(Path(bazelisk_home))
+    for path in candidates:
+        shutil.rmtree(path, ignore_errors=True)
+
+
+def _add_to_path(bin_dir: Path) -> None:
+    github_path = os.environ.get("GITHUB_PATH")
+    if github_path:
+        with Path(github_path).open("a", encoding="utf-8") as f:
+            f.write(f"{bin_dir}\n")
+    current_path = os.environ.get("PATH", "")
+    os.environ["PATH"] = (
+        f"{bin_dir}{os.pathsep}{current_path}" if current_path else str(bin_dir)
+    )
+
+
+def _fetch_bazelisk(url: str, os_name: str, arch_name: str, ext: str) -> bytes | None:
+    try:
+        with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response:
+            data = response.read()
+        if url.endswith(".tgz"):
+            member_name = f"package/bazelisk-{os_name}_{arch_name}{ext}"
+            with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
+                member = tar.extractfile(member_name)
+                if member is None:
+                    return None
+                return member.read()
+        return data
+    except Exception as exc:  # noqa: BLE001
+        print(f"::warning::Failed to fetch {url}: {exc}", flush=True)
+        return None
+
+
+def _install_bazelisk_binary(
+    data: bytes, bin_dir: Path, bazel_bin: Path, bazelisk_bin: Path
+) -> None:
+    bin_dir.mkdir(parents=True, exist_ok=True)
+    bazel_bin.write_bytes(data)
+    exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
+    bazel_bin.chmod(bazel_bin.stat().st_mode | exec_bits)
+    shutil.copy2(bazel_bin, bazelisk_bin)
+    bazelisk_bin.chmod(bazelisk_bin.stat().st_mode | exec_bits)
+    _add_to_path(bin_dir)
+
+
+def _setup_bazelisk(
+    max_attempts: int = MAX_ATTEMPTS,
+    initial_delay: float = INITIAL_DELAY_SECONDS,
+) -> None:
+    os_name = _detect_os()
+    arch_name = _detect_arch()
+    ext = ".exe" if os_name == "windows" else ""
+    key = f"{os_name}-{arch_name}"
+    if key not in BAZELISK_URLS:
+        raise RuntimeError(f"No Bazelisk URLs configured for {key}")
+    sources = BAZELISK_URLS[key]
+    cache_dir = _bazelisk_cache_dir(os_name)
+    bin_dir = cache_dir / "bin"
+    bazel_bin = bin_dir / f"bazel{ext}"
+    bazelisk_bin = bin_dir / f"bazelisk{ext}"
+
+    if bazel_bin.is_file():
+        actual_sha = hashlib.sha256(bazel_bin.read_bytes()).hexdigest().lower()
+        if any(actual_sha == sha256.lower() for _, sha256 in sources):
+            exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
+            bazel_bin.chmod(bazel_bin.stat().st_mode | exec_bits)
+            if not bazelisk_bin.is_file():
+                shutil.copy2(bazel_bin, bazelisk_bin)
+                bazelisk_bin.chmod(bazelisk_bin.stat().st_mode | exec_bits)
+            _add_to_path(bin_dir)
+            return
+
+    delay = initial_delay
+    for attempt in range(1, max_attempts + 1):
+        for url, expected_sha in sources:
+            data = _fetch_bazelisk(url, os_name, arch_name, ext)
+            if data is not None and _verify_sha256_bytes(data, expected_sha):
+                _install_bazelisk_binary(data, bin_dir, bazel_bin, bazelisk_bin)
+                return
+
+        if attempt < max_attempts:
+            print(
+                f"::warning::Failed to download Bazelisk (attempt {attempt}/{max_attempts}); retrying in {delay:g}s...",
+                flush=True,
+            )
+            time.sleep(delay)
+            delay *= 2
+
+    print(
+        f"::error::Failed to download Bazelisk after {max_attempts} attempts",
+        flush=True,
+    )
+    _clean_bazelisk_cache()
+    sys.exit(1)
+
+
+def _run_bazel_version() -> bool:
+    bazel_exe = shutil.which("bazel", path=os.environ.get("PATH")) or "bazel"
+    result = subprocess.run([bazel_exe, "--version"], check=False)
+    return result.returncode == 0
+
+
+def _download_bazel(
+    max_attempts: int = MAX_ATTEMPTS,
+    initial_delay: float = INITIAL_DELAY_SECONDS,
+) -> None:
+    delay = initial_delay
+    for attempt in range(1, max_attempts + 1):
+        if _run_bazel_version():
+            return
+
+        if attempt < max_attempts:
+            print(
+                f"::warning::Failed to download Bazel (attempt {attempt}/{max_attempts}); retrying in {delay:g}s...",
+                flush=True,
+            )
+            time.sleep(delay)
+            delay *= 2
+
+    print(
+        f"::error::Failed to download Bazel after {max_attempts} attempts",
+        flush=True,
+    )
+    _clean_bazelisk_cache()
+    sys.exit(1)
+
+
+def main() -> None:
+    _setup_bazelisk()
+    _download_bazel()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.github/actions/setup-bazel/download_bazel.sh b/.github/actions/setup-bazel/download_bazel.sh
deleted file mode 100755
index 088b470..0000000
--- a/.github/actions/setup-bazel/download_bazel.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env bash
-# Downloads the Bazel binary via Bazelisk with retries on transient network
-# errors.
-#
-# `bazel-contrib/setup-bazel` only configures Bazelisk and restores caches; the
-# Bazel binary itself is downloaded by Bazelisk on first invocation. Bazelisk
-# retries initial HTTP GET headers (`client.Do`), but does not retry mid-stream
-# transfer errors during `io.Copy` (such as HTTP/2 `INTERNAL_ERROR` stream
-# resets from `releases.bazel.build`). Running `bazel --version` here triggers
-# the download upfront (without starting the Bazel server JVM) and retries on
-# transient failures.
-set -euo pipefail
-
-max_attempts="${BAZEL_DOWNLOAD_MAX_ATTEMPTS:-5}"
-delay="${BAZEL_DOWNLOAD_INITIAL_DELAY:-2}"
-
-for ((attempt = 1; attempt <= max_attempts; attempt++)); do
-  if bazel --version; then
-    exit 0
-  fi
-  if ((attempt < max_attempts)); then
-    echo "::warning::Failed to download Bazel (attempt $attempt/$max_attempts); retrying in ${delay}s..."
-    sleep "$delay"
-    delay=$((delay * 2))
-  fi
-done
-
-echo "::error::Failed to download Bazel after $max_attempts attempts"
-# Remove any empty/partial Bazelisk cache directory created before the failed
-# download so `setup-bazel`'s post step does not cache an empty directory.
-if [[ -n "${BAZELISK_HOME:-}" ]]; then
-  rm -rf "$BAZELISK_HOME"
-fi
-rm -rf \
-  "${XDG_CACHE_HOME:-$HOME/.cache}/bazelisk" \
-  "$HOME/Library/Caches/bazelisk" \
-  "$HOME/AppData/Local/bazelisk"
-exit 1
diff --git a/AGENTS.md b/AGENTS.md
index 9f673d8..58031f8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,9 @@
 
 ## Style and conventions
 
-Read `.editorconfig` for line length wrapping
+Read `.editorconfig` for line length wrapping. Do not line-break long string
+literals (such as URLs or hashes) to fit line length limits; allow them to
+exceed the limit.
 
 Read `CONTRIBUTING.md` for additional style rules and conventions.
 
diff --git a/tests/workflows/BUILD.bazel b/tests/workflows/BUILD.bazel
index 41f0927..de839eb 100644
--- a/tests/workflows/BUILD.bazel
+++ b/tests/workflows/BUILD.bazel
@@ -78,6 +78,14 @@
     ],
 )
 
+py_library(
+    name = "download_bazel",
+    testonly = True,
+    srcs = ["//.github/actions/setup-bazel:download_bazel.py"],
+    imports = ["../../.github/actions/setup-bazel"],
+    target_compatible_with = NOT_WINDOWS,
+)
+
 pytest_test(
     name = "setup_bazel_test",
     srcs = ["setup_bazel_test.py"],
@@ -88,6 +96,7 @@
     ],
     target_compatible_with = NOT_WINDOWS,
     deps = [
+        ":download_bazel",
         "//python/runfiles",
         "@pypi//pyyaml",
     ],
diff --git a/tests/workflows/setup_bazel_test.py b/tests/workflows/setup_bazel_test.py
index aebfb63..c0fd902 100644
--- a/tests/workflows/setup_bazel_test.py
+++ b/tests/workflows/setup_bazel_test.py
@@ -2,11 +2,14 @@
 
 from __future__ import annotations
 
+import hashlib
 import os
 import stat
-import subprocess
+import tarfile
 from pathlib import Path
 
+import download_bazel
+import pytest
 import yaml
 
 from python.runfiles import runfiles
@@ -16,9 +19,9 @@
     return runfiles.CreateOrRaise().root() / "rules_python/.github/actions/setup-bazel"
 
 
-def _write_fake_bazel(bin_dir: Path, body: str) -> Path:
+def _write_fake_bazel(bin_dir: Path, body: str, name: str = "bazel") -> Path:
     bin_dir.mkdir(parents=True, exist_ok=True)
-    fake_bazel = bin_dir / "bazel"
+    fake_bazel = bin_dir / name
     fake_bazel.write_text(
         f"#!/usr/bin/env bash\nset -euo pipefail\n{body}\n", encoding="utf-8"
     )
@@ -26,18 +29,24 @@
     return fake_bazel
 
 
+def _sha256_file(path: Path) -> str:
+    return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
 def test_action_yaml_invokes_executable_download_script():
-    """Verifies setup-bazel action.yaml runs download_bazel.sh and the script is +x."""
+    """Verifies setup-bazel action.yaml runs download_bazel.py and omits bazelisk-version."""
     action_dir = _action_dir()
-    script = action_dir / "download_bazel.sh"
+    script = action_dir / "download_bazel.py"
     assert script.is_file()
     assert bool(script.stat().st_mode & stat.S_IXUSR), (
-        "download_bazel.sh must be executable"
+        "download_bazel.py must be executable"
     )
 
     action = yaml.safe_load((action_dir / "action.yaml").read_text(encoding="utf-8"))
     steps = action["runs"]["steps"]
-    assert any("download_bazel.sh" in step.get("run", "") for step in steps)
+    assert any("download_bazel.py" in step.get("run", "") for step in steps)
+    setup_step = next(s for s in steps if s.get("name") == "Setup Bazel")
+    assert "bazelisk-version" not in setup_step["with"]
 
 
 def test_action_yaml_enables_buildbuddy_on_all_os():
@@ -123,35 +132,182 @@
     assert "common:windows --remote_executor=" in bazelrc_lines
 
 
-def test_download_bazel_succeeds_first_try(tmp_path: Path):
-    """Verifies download_bazel.sh succeeds without warnings when bazel --version succeeds."""
-    script = _action_dir() / "download_bazel.sh"
+def test_download_bazelisk_from_primary_url(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    capsys: pytest.CaptureFixture[str],
+):
+    """Verifies download_bazel.py downloads Bazelisk, checks SHA-256, and updates GITHUB_PATH."""
+    calls_file = tmp_path / "calls.txt"
+    release_dir = tmp_path / "releases" / "v1.28.1"
+    asset = _write_fake_bazel(
+        release_dir,
+        f'echo "$@" >> "{calls_file}"\necho "bazel 9.1.1"\n',
+        name="bazelisk-linux-amd64",
+    )
+    github_path = tmp_path / "github_path"
+    home = tmp_path / "home"
+
+    monkeypatch.setenv("HOME", str(home))
+    monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
+    monkeypatch.delenv("BAZELISK_HOME", raising=False)
+    monkeypatch.setenv("GITHUB_PATH", str(github_path))
+    monkeypatch.setattr(download_bazel, "_detect_os", lambda: "linux")
+    monkeypatch.setattr(download_bazel, "_detect_arch", lambda: "amd64")
+    monkeypatch.setitem(
+        download_bazel.BAZELISK_URLS,
+        "linux-amd64",
+        [(asset.as_uri(), _sha256_file(asset))],
+    )
+
+    download_bazel.main()
+
+    out = capsys.readouterr().out
+    bin_dir = home / ".cache/bazelisk/bin"
+    assert (bin_dir / "bazel").is_file()
+    assert (bin_dir / "bazelisk").is_file()
+    assert github_path.read_text(encoding="utf-8").splitlines() == [str(bin_dir)]
+    assert calls_file.read_text(encoding="utf-8").splitlines() == ["--version"]
+    assert "::warning::" not in out
+    assert "::error::" not in out
+
+
+def test_download_bazelisk_falls_back_to_npm_mirror(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    capsys: pytest.CaptureFixture[str],
+):
+    """Verifies download_bazel.py falls back to the npm tarball mirror when primary fails."""
+    calls_file = tmp_path / "calls.txt"
+    pkg_dir = tmp_path / "npm" / "package"
+    asset = _write_fake_bazel(
+        pkg_dir,
+        f'echo "$@" >> "{calls_file}"\necho "bazel 9.1.1"\n',
+        name="bazelisk-linux_amd64",
+    )
+    tgz_path = tmp_path / "bazelisk-1.28.1.tgz"
+    with tarfile.open(tgz_path, "w:gz") as tar:
+        tar.add(asset, arcname="package/bazelisk-linux_amd64")
+
+    home = tmp_path / "home"
+    missing_url = (tmp_path / "missing").as_uri()
+    sha = _sha256_file(asset)
+
+    monkeypatch.setenv("HOME", str(home))
+    monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
+    monkeypatch.delenv("BAZELISK_HOME", raising=False)
+    monkeypatch.setattr(download_bazel, "_detect_os", lambda: "linux")
+    monkeypatch.setattr(download_bazel, "_detect_arch", lambda: "amd64")
+    monkeypatch.setitem(
+        download_bazel.BAZELISK_URLS,
+        "linux-amd64",
+        [(missing_url, sha), (tgz_path.as_uri(), sha)],
+    )
+
+    download_bazel.main()
+
+    out = capsys.readouterr().out
+    assert f"::warning::Failed to fetch {missing_url}" in out
+    assert calls_file.read_text(encoding="utf-8").splitlines() == ["--version"]
+    assert (home / ".cache/bazelisk/bin/bazel").is_file()
+
+
+def test_download_bazelisk_reuses_cached_binary(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    capsys: pytest.CaptureFixture[str],
+):
+    """Verifies download_bazel.py reuses a cached Bazelisk binary with matching SHA-256."""
+    calls_file = tmp_path / "calls.txt"
+    home = tmp_path / "home"
+    cached_bazel = _write_fake_bazel(
+        home / ".cache/bazelisk/bin",
+        f'echo "$@" >> "{calls_file}"\necho "bazel 9.1.1"\n',
+        name="bazel",
+    )
+
+    monkeypatch.setenv("HOME", str(home))
+    monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
+    monkeypatch.delenv("BAZELISK_HOME", raising=False)
+    monkeypatch.setattr(download_bazel, "_detect_os", lambda: "linux")
+    monkeypatch.setattr(download_bazel, "_detect_arch", lambda: "amd64")
+    monkeypatch.setitem(
+        download_bazel.BAZELISK_URLS,
+        "linux-amd64",
+        [((tmp_path / "missing").as_uri(), _sha256_file(cached_bazel))],
+    )
+
+    download_bazel.main()
+
+    out = capsys.readouterr().out
+    assert (home / ".cache/bazelisk/bin/bazelisk").is_file()
+    assert calls_file.read_text(encoding="utf-8").splitlines() == ["--version"]
+    assert "::warning::" not in out
+
+
+def test_download_bazelisk_exhausts_retries_and_cleans_cache(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    capsys: pytest.CaptureFixture[str],
+):
+    """Verifies download_bazel.py cleans up cache dirs when Bazelisk download fails."""
+    home = tmp_path / "home"
+    (home / ".cache/bazelisk/partial").mkdir(parents=True, exist_ok=True)
+
+    monkeypatch.setenv("HOME", str(home))
+    monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
+    monkeypatch.delenv("BAZELISK_HOME", raising=False)
+    monkeypatch.setattr(download_bazel, "_detect_os", lambda: "linux")
+    monkeypatch.setattr(download_bazel, "_detect_arch", lambda: "amd64")
+    monkeypatch.setitem(
+        download_bazel.BAZELISK_URLS,
+        "linux-amd64",
+        [
+            ((tmp_path / "missing").as_uri(), "0" * 64),
+            ((tmp_path / "missing.tgz").as_uri(), "0" * 64),
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc_info:
+        download_bazel._setup_bazelisk(max_attempts=2, initial_delay=0)
+
+    assert exc_info.value.code == 1
+    out = capsys.readouterr().out
+    assert "::error::Failed to download Bazelisk after 2 attempts" in out
+    assert not (home / ".cache/bazelisk").exists()
+
+
+def test_download_bazel_succeeds_first_try(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    capsys: pytest.CaptureFixture[str],
+):
+    """Verifies _download_bazel succeeds without warnings when bazel --version succeeds."""
     calls_file = tmp_path / "calls.txt"
     _write_fake_bazel(
         tmp_path / "bin",
         f'echo "$@" >> "{calls_file}"\necho "bazel 9.1.1"\n',
     )
 
-    env = os.environ.copy()
-    env["PATH"] = f"{tmp_path / 'bin'}:{env.get('PATH', '')}"
-    env["HOME"] = str(tmp_path / "home")
-
-    result = subprocess.run(
-        [str(script)],
-        env=env,
-        capture_output=True,
-        text=True,
-        check=False,
+    monkeypatch.setenv(
+        "PATH", f"{tmp_path / 'bin'}{os.pathsep}{os.environ.get('PATH', '')}"
     )
-    assert result.returncode == 0
+    monkeypatch.setenv("HOME", str(tmp_path / "home"))
+
+    download_bazel._download_bazel(max_attempts=5, initial_delay=0)
+
+    out = capsys.readouterr().out
     assert calls_file.read_text(encoding="utf-8").splitlines() == ["--version"]
-    assert "::warning::" not in result.stdout
-    assert "::error::" not in result.stdout
+    assert "::warning::" not in out
+    assert "::error::" not in out
 
 
-def test_download_bazel_retries_transient_failure(tmp_path: Path):
-    """Verifies download_bazel.sh retries transient failures and succeeds."""
-    script = _action_dir() / "download_bazel.sh"
+def test_download_bazel_retries_transient_failure(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    capsys: pytest.CaptureFixture[str],
+):
+    """Verifies _download_bazel retries transient failures and succeeds."""
     calls_file = tmp_path / "calls.txt"
     _write_fake_bazel(
         tmp_path / "bin",
@@ -168,32 +324,30 @@
     )
 
     home = tmp_path / "home"
-    env = os.environ.copy()
-    env["PATH"] = f"{tmp_path / 'bin'}:{env.get('PATH', '')}"
-    env["HOME"] = str(home)
-    env["BAZEL_DOWNLOAD_INITIAL_DELAY"] = "0"
-
-    result = subprocess.run(
-        [str(script)],
-        env=env,
-        capture_output=True,
-        text=True,
-        check=False,
+    monkeypatch.setenv(
+        "PATH", f"{tmp_path / 'bin'}{os.pathsep}{os.environ.get('PATH', '')}"
     )
-    assert result.returncode == 0
+    monkeypatch.setenv("HOME", str(home))
+
+    download_bazel._download_bazel(max_attempts=5, initial_delay=0)
+
+    out = capsys.readouterr().out
     assert calls_file.read_text(encoding="utf-8").splitlines() == [
         "--version",
         "--version",
         "--version",
     ]
-    assert "::warning::Failed to download Bazel (attempt 1/5)" in result.stdout
-    assert "::warning::Failed to download Bazel (attempt 2/5)" in result.stdout
+    assert "::warning::Failed to download Bazel (attempt 1/5)" in out
+    assert "::warning::Failed to download Bazel (attempt 2/5)" in out
     assert (home / ".cache/bazelisk/downloads").is_dir()
 
 
-def test_download_bazel_exhausts_retries_and_cleans_cache(tmp_path: Path):
-    """Verifies download_bazel.sh cleans up partial cache dirs when all retries fail."""
-    script = _action_dir() / "download_bazel.sh"
+def test_download_bazel_exhausts_retries_and_cleans_cache(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    capsys: pytest.CaptureFixture[str],
+):
+    """Verifies _download_bazel cleans up partial cache dirs when all retries fail."""
     calls_file = tmp_path / "calls.txt"
     _write_fake_bazel(
         tmp_path / "bin",
@@ -207,22 +361,19 @@
 
     home = tmp_path / "home"
     bazelisk_home = tmp_path / "bazelisk_home"
-    env = os.environ.copy()
-    env["PATH"] = f"{tmp_path / 'bin'}:{env.get('PATH', '')}"
-    env["HOME"] = str(home)
-    env["BAZELISK_HOME"] = str(bazelisk_home)
-    env["BAZEL_DOWNLOAD_MAX_ATTEMPTS"] = "3"
-    env["BAZEL_DOWNLOAD_INITIAL_DELAY"] = "0"
-
-    result = subprocess.run(
-        [str(script)],
-        env=env,
-        capture_output=True,
-        text=True,
-        check=False,
+    monkeypatch.setenv(
+        "PATH", f"{tmp_path / 'bin'}{os.pathsep}{os.environ.get('PATH', '')}"
     )
-    assert result.returncode == 1
+    monkeypatch.setenv("HOME", str(home))
+    monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
+    monkeypatch.setenv("BAZELISK_HOME", str(bazelisk_home))
+
+    with pytest.raises(SystemExit) as exc_info:
+        download_bazel._download_bazel(max_attempts=3, initial_delay=0)
+
+    assert exc_info.value.code == 1
+    out = capsys.readouterr().out
     assert len(calls_file.read_text(encoding="utf-8").splitlines()) == 3
-    assert "::error::Failed to download Bazel after 3 attempts" in result.stdout
+    assert "::error::Failed to download Bazel after 3 attempts" in out
     assert not (home / ".cache/bazelisk").exists()
     assert not bazelisk_home.exists()