From ae8b0462adc93e1dfa52d6709b6ef3480f9795e0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 22 Oct 2026 19:29:46 -0700 Subject: [PATCH] workflow: download Bazelisk and Bazel with retries and mirror fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .agents/rules/python.md | 4 + .agents/rules/starlark.md | 2 + .github/actions/setup-bazel/action.yaml | 14 +- .github/actions/setup-bazel/download_bazel.py | 261 +++++++++++++++++ .github/actions/setup-bazel/download_bazel.sh | 38 --- AGENTS.md | 4 +- tests/workflows/BUILD.bazel | 9 + tests/workflows/setup_bazel_test.py | 271 ++++++++++++++---- 8 files changed, 496 insertions(+), 107 deletions(-) create mode 100755 .github/actions/setup-bazel/download_bazel.py delete mode 100755 .github/actions/setup-bazel/download_bazel.sh diff --git a/.agents/rules/python.md b/.agents/rules/python.md index 9921b5a597..376f8949b1 100644 --- a/.agents/rules/python.md +++ b/.agents/rules/python.md @@ -73,3 +73,7 @@ globs: "*.py,*.sh" ## 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 97ed3d2b25..8a1b5017f0 100644 --- a/.agents/rules/starlark.md +++ b/.agents/rules/starlark.md @@ -109,6 +109,8 @@ globs: "*.bzl,BUILD,BUILD.bazel,*.bazel" 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 aac5fd6a77..d8f12d45df 100644 --- a/.github/actions/setup-bazel/action.yaml +++ b/.github/actions/setup-bazel/action.yaml @@ -17,9 +17,6 @@ runs: - 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 @@ -38,13 +35,14 @@ runs: build --//python/config_settings:py_pbs_distribution=install_only_stripped ${{ inputs.buildbuddy_api_key && format('common --config={0}', inputs.buildbuddy_config) || '' }} - # 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 0000000000..c8d201703b --- /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 `/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 "-" 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 088b470151..0000000000 --- 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 9f673d82ab..58031f822f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,9 @@ Ask for user input and provide a justificaiton if trying to violate them. ## 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 41f0927fbf..de839eb67f 100644 --- a/tests/workflows/BUILD.bazel +++ b/tests/workflows/BUILD.bazel @@ -78,6 +78,14 @@ pytest_test( ], ) +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 @@ pytest_test( ], 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 ba04a964c4..4a655990aa 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 @@ def _action_dir() -> Path: 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 @@ def _write_fake_bazel(bin_dir: Path, body: str) -> Path: 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(): @@ -82,35 +91,182 @@ def test_buildbuddy_bazelrc_disables_remote_executor_on_macos_and_windows(): 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", @@ -127,32 +283,30 @@ def test_download_bazel_retries_transient_failure(tmp_path: Path): ) 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, - ) - assert result.returncode == 0 + monkeypatch.setenv( + "PATH", f"{tmp_path / 'bin'}{os.pathsep}{os.environ.get('PATH', '')}" + ) + 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", @@ -166,22 +320,19 @@ def test_download_bazel_exhausts_retries_and_cleans_cache(tmp_path: Path): 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, - ) - assert result.returncode == 1 + monkeypatch.setenv( + "PATH", f"{tmp_path / 'bin'}{os.pathsep}{os.environ.get('PATH', '')}" + ) + 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()