diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index f2c3987b2..58fbd370f 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -2,6 +2,7 @@ """Install Codex native binaries (Rust CLI plus ripgrep helpers).""" import argparse +from contextlib import contextmanager import json import os import shutil @@ -12,6 +13,7 @@ import zipfile from dataclasses import dataclass from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path +import sys from typing import Iterable, Sequence from urllib.parse import urlparse from urllib.request import urlopen @@ -77,6 +79,45 @@ RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS} DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS] +# urllib.request.urlopen() defaults to no timeout (can hang indefinitely), which is painful in CI. +DOWNLOAD_TIMEOUT_SECS = 60 + + +def _gha_enabled() -> bool: + # GitHub Actions supports "workflow commands" (e.g. ::group:: / ::error::) that make logs + # much easier to scan: groups collapse noisy sections and error annotations surface the + # failure in the UI without changing the actual exception/traceback output. + return os.environ.get("GITHUB_ACTIONS") == "true" + + +def _gha_escape(value: str) -> str: + # Workflow commands require percent/newline escaping. + return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def _gha_error(*, title: str, message: str) -> None: + # Emit a GitHub Actions error annotation. This does not replace stdout/stderr logs; it just + # adds a prominent summary line to the job UI so the root cause is easier to spot. + if not _gha_enabled(): + return + print( + f"::error title={_gha_escape(title)}::{_gha_escape(message)}", + flush=True, + ) + + +@contextmanager +def _gha_group(title: str): + # Wrap a block in a collapsible log group on GitHub Actions. Outside of GHA this is a no-op + # so local output remains unchanged. + if _gha_enabled(): + print(f"::group::{_gha_escape(title)}", flush=True) + try: + yield + finally: + if _gha_enabled(): + print("::endgroup::", flush=True) + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Install native Codex binaries.") @@ -131,18 +172,20 @@ def main() -> int: workflow_id = workflow_url.rstrip("/").split("/")[-1] print(f"Downloading native artifacts from workflow {workflow_id}...") - with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: - artifacts_dir = Path(artifacts_dir_str) - _download_artifacts(workflow_id, artifacts_dir) - install_binary_components( - artifacts_dir, - vendor_dir, - [BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS], - ) + with _gha_group(f"Download native artifacts from workflow {workflow_id}"): + with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: + artifacts_dir = Path(artifacts_dir_str) + _download_artifacts(workflow_id, artifacts_dir) + install_binary_components( + artifacts_dir, + vendor_dir, + [BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS], + ) if "rg" in components: - print("Fetching ripgrep binaries...") - fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) + with _gha_group("Fetch ripgrep binaries"): + print("Fetching ripgrep binaries...") + fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) print(f"Installed native dependencies into {vendor_dir}") return 0 @@ -203,7 +246,14 @@ def fetch_rg( for future in as_completed(future_map): target = future_map[future] - results[target] = future.result() + try: + results[target] = future.result() + except Exception as exc: + _gha_error( + title="ripgrep install failed", + message=f"target={target} error={exc!r}", + ) + raise RuntimeError(f"Failed to install ripgrep for target {target}.") from exc print(f" installed ripgrep for {target}") return [results[target] for target in targets] @@ -301,6 +351,8 @@ def _fetch_single_rg( url = providers[0]["url"] archive_format = platform_info.get("format", "zst") archive_member = platform_info.get("path") + digest = platform_info.get("digest") + expected_size = platform_info.get("size") dest_dir = vendor_dir / target / "path" dest_dir.mkdir(parents=True, exist_ok=True) @@ -313,10 +365,32 @@ def _fetch_single_rg( tmp_dir = Path(tmp_dir_str) archive_filename = os.path.basename(urlparse(url).path) download_path = tmp_dir / archive_filename - _download_file(url, download_path) + print( + f" downloading ripgrep for {target} ({platform_key}) from {url}", + flush=True, + ) + try: + _download_file(url, download_path) + except Exception as exc: + _gha_error( + title="ripgrep download failed", + message=f"target={target} platform={platform_key} url={url} error={exc!r}", + ) + raise RuntimeError( + "Failed to download ripgrep " + f"(target={target}, platform={platform_key}, format={archive_format}, " + f"expected_size={expected_size!r}, digest={digest!r}, url={url}, dest={download_path})." + ) from exc dest.unlink(missing_ok=True) - extract_archive(download_path, archive_format, archive_member, dest) + try: + extract_archive(download_path, archive_format, archive_member, dest) + except Exception as exc: + raise RuntimeError( + "Failed to extract ripgrep " + f"(target={target}, platform={platform_key}, format={archive_format}, " + f"member={archive_member!r}, url={url}, archive={download_path})." + ) from exc if not is_windows: dest.chmod(0o755) @@ -326,7 +400,9 @@ def _fetch_single_rg( def _download_file(url: str, dest: Path) -> None: dest.parent.mkdir(parents=True, exist_ok=True) - with urlopen(url) as response, open(dest, "wb") as out: + dest.unlink(missing_ok=True) + + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response, open(dest, "wb") as out: shutil.copyfileobj(response, out)