blob: 29c96bcb6e987b3f726a001ca305767de9eb3365 [file]
#!/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.
# Primary URLs use v1.30.0 (latest GitHub release); the npm mirror fallback uses
# 1.28.1 (latest version published to the npm registry).
# fmt: off
BAZELISK_URLS: dict[str, list[tuple[str, str]]] = {
"darwin-amd64": [
("https://github.com/bazelbuild/bazelisk/releases/download/v1.30.0/bazelisk-darwin-amd64", "a0e6468b0c6b812f782d8cbfb05824121f6980c0bd70aaa1774fb8272bb7b2ad"),
("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "023225736cea5dc88f2b0807d5b1af4eb0f69a4ed45e3994b2c18c263bc80e48"),
],
"darwin-arm64": [
("https://github.com/bazelbuild/bazelisk/releases/download/v1.30.0/bazelisk-darwin-arm64", "555d2ff912ccd336d0060bdb5251ef2f62629c6f8fbadb59005a45d5625793ec"),
("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "dea3f3f5de2dbc5e269e0132cdd369d5efe738f7b973d5d4eb2b4f7055a97b39"),
],
"linux-amd64": [
("https://github.com/bazelbuild/bazelisk/releases/download/v1.30.0/bazelisk-linux-amd64", "a248ae2b2b62b5baebb748f6fba7203bff1a27e5d01b55399a8406744cb539e8"),
("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "22e7d3a188699982f661cf4687137ee52d1f24fec1ec893d91a6c4d791a75de8"),
],
"linux-arm64": [
("https://github.com/bazelbuild/bazelisk/releases/download/v1.30.0/bazelisk-linux-arm64", "b2bcc040c213b44586b121bda6d983c35df121b917c3a7a66fb16f2fa0df1a4f"),
("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "8ded44b58a0d9425a4178af26cf17693feac3b87bdcfef0a2a0898fcd1afc9f2"),
],
"windows-amd64": [
("https://github.com/bazelbuild/bazelisk/releases/download/v1.30.0/bazelisk-windows-amd64.exe", "5d14a31e9d8a363c48ba1900dbd56764d730835232eb0d113cce255856ffe3ef"),
("https://registry.npmjs.org/@bazel/bazelisk/-/bazelisk-1.28.1.tgz", "b9d65a1f7c2d7af885a96a4fd5aa36b40fb41816d30944390569eef908bdc954"),
],
"windows-arm64": [
("https://github.com/bazelbuild/bazelisk/releases/download/v1.30.0/bazelisk-windows-arm64.exe", "cb832b1d157e0dc5f89108da426f9c6fe087f5b15873e072480ec49cc5afe488"),
("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()