#!/usr/bin/env python3 """Install CI/CD development tools that are not Python packages. Handles installation of: - actionlint (workflow YAML linter) - git-cliff (changelog generator) - act_runner (Gitea Actions local runner, optional) - tea (Gitea CLI — official command-line tool for Gitea API operations) Each tool is installed to ``~/.local/bin`` if not already on PATH. Idempotent: skips tools that are already available. Usage:: python3 scripts/install_tools.py # install all python3 scripts/install_tools.py --tool actionlint # install one python3 scripts/install_tools.py --list # list status """ from __future__ import annotations import os import platform import shutil import tarfile import tempfile import urllib.request from pathlib import Path import click TARGET_DIR = Path.home() / ".local" / "bin" ACTIONLINT_VERSION = "1.7.12" GIT_CLIFF_VERSION = "2.13.0" ACT_RUNNER_VERSION = "0.2.11" TEA_VERSION = "0.14.1" def _arch() -> str: """Return the architecture string used by release assets.""" machine = platform.machine().lower() if machine in {"x86_64", "amd64"}: return "amd64" if machine in {"aarch64", "arm64"}: return "arm64" raise click.ClickException(f"Unsupported architecture: {machine}") def _ensure_target_dir() -> Path: """Ensure the target directory exists and return it.""" TARGET_DIR.mkdir(parents=True, exist_ok=True) return TARGET_DIR def _download(url: str, dest: Path) -> None: """Download a file from ``url`` to ``dest``.""" urllib.request.urlretrieve(url, dest) # nosec B310 def _download_and_extract_tarball(url: str, binary_name: str) -> Path: """Download a tarball, extract the binary, and install it to TARGET_DIR. Returns the path to the installed binary. """ target_dir = _ensure_target_dir() dest = target_dir / binary_name with tempfile.TemporaryDirectory() as tmpdir: tarball = Path(tmpdir) / "archive.tar.gz" _download(url, tarball) with tarfile.open(tarball, "r:gz") as tar: tar.extractall(tmpdir) # nosec B202 # Find the binary in the extracted tree extracted = Path(tmpdir).rglob(binary_name) found = next(extracted, None) if found is None: raise click.ClickException(f"Binary {binary_name} not found in archive from {url}") shutil.copy2(found, dest) dest.chmod(0o755) return dest def _download_binary(url: str, binary_name: str) -> Path: """Download a standalone binary and install it to TARGET_DIR. Returns the path to the installed binary. """ target_dir = _ensure_target_dir() dest = target_dir / binary_name _download(url, dest) dest.chmod(0o755) return dest def _is_installed(name: str) -> bool: """Check if a tool is already on PATH or in TARGET_DIR.""" if shutil.which(name) is not None: return True return (TARGET_DIR / name).exists() def install_actionlint() -> bool: """Install actionlint if not already present. Returns True if installed/skipped.""" if _is_installed("actionlint"): click.echo("actionlint: already installed") return True arch = _arch() url = ( f"https://github.com/rhysd/actionlint/releases/download/" f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz" ) dest = _download_and_extract_tarball(url, "actionlint") click.echo(f"actionlint: installed to {dest}") return True def install_git_cliff() -> bool: """Install git-cliff if not already present. Returns True if installed/skipped.""" if _is_installed("git-cliff"): click.echo("git-cliff: already installed") return True arch = _arch() url = ( f"https://github.com/orhun/git-cliff/releases/download/" f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz" ) dest = _download_and_extract_tarball(url, "git-cliff") click.echo(f"git-cliff: installed to {dest}") return True def install_act_runner() -> bool: """Install act_runner if not already present. Returns True if installed/skipped.""" if _is_installed("act_runner"): click.echo("act_runner: already installed") return True arch = _arch() url = ( f"https://gitea.com/gitea/act_runner/releases/download/" f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}" ) dest = _download_binary(url, "act_runner") click.echo(f"act_runner: installed to {dest}") return True def install_tea() -> bool: """Install tea (Gitea CLI) if not already present. Returns True if installed/skipped.""" if _is_installed("tea"): click.echo("tea: already installed") return True arch = _arch() url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}" dest = _download_binary(url, "tea") click.echo(f"tea: installed to {dest}") return True TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"] def _install_tool(name: str) -> bool: """Install a single tool by name.""" if name == "actionlint": return install_actionlint() if name == "git-cliff": return install_git_cliff() if name == "act_runner": return install_act_runner() if name == "tea": return install_tea() raise click.ClickException(f"Unknown tool: {name}") def list_tools() -> None: """Print the installation status of all tools.""" for name in TOOL_NAMES: status = "installed" if _is_installed(name) else "not installed" click.echo(f" {name}: {status}") @click.command() @click.option( "--tool", "tools", multiple=True, type=click.Choice(TOOL_NAMES), help="Install specific tool(s). Can be repeated.", ) @click.option("--list", "list_status", is_flag=True, help="List tool installation status.") def main(tools: tuple[str, ...], list_status: bool) -> None: """Install CI/CD development tools to ~/.local/bin.""" if list_status: list_tools() return tools_to_install = list(tools) if tools else TOOL_NAMES failed: list[str] = [] for name in tools_to_install: try: _install_tool(name) except Exception as exc: click.echo(f" {name}: FAILED — {exc}", err=True) failed.append(name) if failed: raise click.ClickException(f"Failed to install: {', '.join(failed)}") # Remind user to add ~/.local/bin to PATH if not already there path_env = os.environ.get("PATH", "") if str(TARGET_DIR) not in path_env: click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.") if __name__ == "__main__": # pragma: no cover main() # pragma: no cover