Created
October 25, 2025 11:28
-
-
Save themasch/cce26c1220480e355f988b1ab8f43acc to your computer and use it in GitHub Desktop.
script to install tools from github releases if they are not properly packages for your distribution
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.13" | |
| # dependencies = ["PyGithub", "requests", "xdg", "tomlkit"] | |
| # /// | |
| # | |
| # example config: ~/.config/install-from-github/packages.toml | |
| # ```toml | |
| # github_api_token="github_<GET_YOUR_OWNY>" | |
| # | |
| # [picotool] | |
| # repo = "raspberrypi/pico-sdk-tools" | |
| # artifact_pattern = "picotool-*-x86_64-lin.tar.gz" | |
| # binary_path = "picotool/picotool" | |
| # ``` | |
| from dataclasses import dataclass | |
| from github import Github, GitReleaseAsset, Auth | |
| import fnmatch | |
| import hashlib | |
| import os | |
| from xdg import BaseDirectory | |
| @dataclass | |
| class GlobalConfig: | |
| cache_base: str | |
| state_base: str | |
| config_base: str | |
| @dataclass | |
| class ToolConfig: | |
| name: str | |
| repo: str | |
| artifact_pattern: str | |
| binary_path: str | |
| class Storage: | |
| cfg: GlobalConfig | |
| def __init__(self, cfg: GlobalConfig): | |
| self.cfg = cfg | |
| def tool_download_dir(self, tool_name: str) -> str: | |
| tool_cache_dir = os.path.join(self.cfg.cache_base, tool_name) | |
| os.makedirs(tool_cache_dir, exist_ok=True) | |
| return tool_cache_dir | |
| def tool_download_artifact_path(self, tool_name: str, artifact_name: str) -> str: | |
| base_dir = self.tool_download_dir(tool_name) | |
| return os.path.join(base_dir, artifact_name) | |
| def tool_unpack_directory_path(self, tool_name: str, version: str) -> str: | |
| return os.path.join( | |
| self.cfg.state_base, "packages", tool_name, "versions", version | |
| ) | |
| def tool_unpack_directory(self, tool_name: str, version: str) -> str: | |
| dir = self.tool_unpack_directory_path(tool_name, version) | |
| os.makedirs(dir, exist_ok=True) | |
| return dir | |
| def bin_directory(self) -> str: | |
| dir = os.path.join(self.cfg.state_base, "bin") | |
| os.makedirs(dir, exist_ok=True) | |
| return dir | |
| def tool_binary_path(self, tool_name: str) -> str: | |
| bindir = self.bin_directory() | |
| return os.path.join(bindir, tool_name) | |
| def package_config_path(self) -> str: | |
| return os.path.join(self.cfg.config_base, "packages.toml") | |
| def unpack_archive(archive_path: str, destination_dir: str) -> bool: | |
| """Unpack a tar archive (or other archive type) into a directory. | |
| Args: | |
| archive_path: Path to the archive file (tar, tar.gz, tar.bz2, etc.) | |
| destination_dir: Directory to extract files into | |
| Returns: | |
| True if extraction was successful, False otherwise | |
| written by claude, modified by me | |
| """ | |
| import subprocess | |
| import shutil | |
| # Try unar first if available (supports many archive formats) | |
| if shutil.which("unar"): | |
| try: | |
| subprocess.run( | |
| [ | |
| "unar", | |
| "-no-directory", | |
| "-force-skip", | |
| "-output-directory", | |
| destination_dir, | |
| archive_path, | |
| ], | |
| check=True, | |
| capture_output=True, | |
| text=True, | |
| ) | |
| return True | |
| except subprocess.CalledProcessError as e: | |
| print(f"unar failed: {e.stderr}") | |
| return False | |
| # Fall back to tar for tar-based archives | |
| import tarfile | |
| try: | |
| with tarfile.open(archive_path, "r:*") as tar: | |
| tar.extractall(path=destination_dir) | |
| return True | |
| except Exception as e: | |
| print(f"Failed to extract archive: {e}") | |
| return False | |
| def unpack_artifact( | |
| asset: GitReleaseAsset, tool: ToolConfig, storage: Storage | |
| ) -> bool | str: | |
| file_path = storage.tool_download_artifact_path(tool.name, asset.name) | |
| if not os.path.exists(file_path): | |
| print(f"[ERROR] File {file_path} does not exist") | |
| return False | |
| unpack_dir = storage.tool_unpack_directory(tool.name, asset.name) | |
| if not unpack_archive(file_path, unpack_dir): | |
| print("[ERROR] Failed to extract archive") | |
| return False | |
| return unpack_dir | |
| def download_artifact(asset: GitReleaseAsset, tool: ToolConfig, storage: Storage): | |
| file_path = storage.tool_download_artifact_path(tool.name, asset.name) | |
| if os.path.exists(file_path): | |
| print(f"[DEBUG] File {file_path} already exists") | |
| return file_path | |
| status, _, stream = asset.download_asset(chunk_size=1024) | |
| if status != 200: | |
| print(f"Failed to download {asset.name}") | |
| return None | |
| with open(file_path, "wb") as f: | |
| hasher = hashlib.sha256() | |
| for chunk in stream: | |
| if chunk: | |
| hasher.update(chunk) | |
| f.write(chunk) | |
| actual_digest = hasher.hexdigest() | |
| if actual_digest.lower() != asset.digest.replace("sha256:", "").lower(): | |
| print(f"SHA256 hash mismatch for {asset.name}") | |
| os.remove(file_path) | |
| return None | |
| print(f"[DEBUG] downloaded {asset.name} to {file_path}") | |
| return file_path | |
| def symlink_tool(unpacked_path: str, tool: ToolConfig, cfg: Storage) -> bool: | |
| source = os.path.join(unpacked_path, tool.binary_path) | |
| if not os.path.exists(source): | |
| print(f"Source binary {source} does not exist") | |
| return False | |
| target = cfg.tool_binary_path(tool.name) | |
| if os.path.islink(target): | |
| os.unlink(target) | |
| if os.path.exists(target): | |
| print(f"Target binary {target} already exists") | |
| return False | |
| os.symlink(source, target) | |
| return True | |
| def install_tool(gh: Github, tool: ToolConfig, storage: Storage): | |
| release = gh.get_repo(tool.repo).get_latest_release() | |
| print(f"latest release for {tool.name} is {release.name}") | |
| for assets in release.get_assets(): | |
| if fnmatch.fnmatch(assets.name, tool.artifact_pattern): | |
| fp = download_artifact(assets, tool, storage) | |
| if fp is None: | |
| print(f"Failed to download {assets.name}") | |
| return | |
| dp = unpack_artifact(assets, tool, storage) | |
| if not dp: | |
| print(f"failed unpacking {assets.name}") | |
| return | |
| if symlink_tool(dp, tool, storage): | |
| print(f"{tool.name} version {release.name} installed successfully!") | |
| else: | |
| print(f"{tool.name} version {release.tag_} installation failed!") | |
| return | |
| def main() -> None: | |
| global_config = GlobalConfig( | |
| cache_base=BaseDirectory.save_cache_path("install-from-github"), | |
| state_base=BaseDirectory.save_data_path("install-from-github"), | |
| config_base=BaseDirectory.save_config_path("install-from-github"), | |
| ) | |
| storage = Storage(global_config) | |
| import tomlkit | |
| packages = [] | |
| with open(storage.package_config_path(), "r") as f: | |
| config = tomlkit.load(f) | |
| for key in config: | |
| if isinstance(config[key], tomlkit.items.Table): | |
| cfg = ToolConfig( | |
| name=key, | |
| repo=config[key]["repo"], | |
| artifact_pattern=config[key]["artifact_pattern"], | |
| binary_path=config[key]["binary_path"], | |
| ) | |
| packages.append(cfg) | |
| if "github_api_token" in config: | |
| token_auth = Auth.Token(config["github_api_token"]) | |
| gh = Github(auth=token_auth) | |
| else: | |
| gh = Github() | |
| for package in packages: | |
| install_tool(gh, package, storage) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment