From 25adff380c4d085c2a33f1c214a6fabd1e114444 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 24 Aug 2026 07:44:40 +0200 Subject: [PATCH] Feat: Implement GitHub release installer script with asset downloading and installation --- python/install_github_release.py | 342 +++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100755 python/install_github_release.py diff --git a/python/install_github_release.py b/python/install_github_release.py new file mode 100755 index 0000000..768ac98 --- /dev/null +++ b/python/install_github_release.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +SYSTEM_PATTERNS = { + 'linux': 'linux', + 'darwin': '(darwin|macos|mac|osx)', + 'win32': '(windows|win)', +} + +ARCH_PATTERNS = { + 'x64': '(x86_64|x64|amd64|universal)', + 'arm64': '(aarch64|arm64|universal)', +} + +MACHINE_TO_ARCH = { + 'x86_64': 'x64', + 'amd64': 'x64', + 'aarch64': 'arm64', + 'arm64': 'arm64', +} + +KNOWN_FILE_TYPES = { + 'archive': ('.zip', '.tar.gz', '.tgz'), + 'package': ('.deb', '.pkg', '.rpm'), + 'linux': ('.deb', '.rpm'), + 'macos': ('.pkg',), + 'targz': ('.tgz', '.tar.gz'), +} + + +class PlatformInfo: + def __init__(self, system, arch): + self.system = system + self.arch = arch + self.system_pattern = SYSTEM_PATTERNS.get(system, system) + self.arch_pattern = ARCH_PATTERNS.get(arch, arch) + + +def replace_platform_placeholders(pattern, platform): + return pattern.replace('{{SYSTEM}}', platform.system_pattern).replace('{{ARCH}}', platform.arch_pattern) + + +def get_github_api_headers(token=None): + headers = { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'setup-github-release-action', + } + if token: + headers['Authorization'] = f'token {token}' + return headers + + +def fetch_latest_release(repository, token=None): + url = f'https://api.github.com/repos/{repository}/releases/latest' + request = urllib.request.Request(url, headers=get_github_api_headers(token)) + try: + with urllib.request.urlopen(request) as response: + import json + return json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError as error: + body = error.read().decode('utf-8', errors='replace') + raise RuntimeError(f'Failed to fetch latest release for {repository}: {error.reason}. {body}') from error + + +def download_asset(url, dest_path, token=None): + headers = {'User-Agent': 'setup-github-release-action'} + if token: + headers['Authorization'] = f'token {token}' + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request) as response, open(dest_path, 'wb') as out_file: + shutil.copyfileobj(response, out_file) + except urllib.error.HTTPError as error: + raise RuntimeError(f'Failed to download asset: {error.reason}') from error + + +def validate_output_directory(output_directory): + resolved = Path(output_directory).resolve() + if not resolved.is_dir(): + raise RuntimeError(f'Output directory "{resolved}" does not exist.') + return str(resolved) + + +def main(): + parser = argparse.ArgumentParser( + prog='install-github-release', + usage='%(prog)s [options] ', + allow_abbrev=False, + ) + parser.add_argument('repository', nargs='?', help='The GitHub repository (owner/repo)') + parser.add_argument('--dry-run', action='store_true', help='Run in test mode') + parser.add_argument('-l', '--list', nargs='?', const=True, default=None, metavar='repository', + help='List available assets from latest release and exit') + parser.add_argument('-a', '--app-name', metavar='name', help='Application name (optional, for output messages)') + parser.add_argument('-f', '--file-name', metavar='name', help='Asset file name or regex pattern (prefixed with ~)') + parser.add_argument('-b', '--binary-name', metavar='name', help='Binary name (supports source:destination form)') + parser.add_argument('-t', '--file-type', metavar='type', + help='Known: archive|package|linux|macos|targz; custom: ~ or extension') + parser.add_argument('-p', '--install-path', metavar='path', help='Custom installation directory') + parser.add_argument('-o', '--output-directory', metavar='path', + help='Only download selected asset to the specified directory') + parser.add_argument('-j', '--releases-json', action='store_true', help='Download latest release JSON only') + parser.add_argument('--system', metavar='name', help='Override detected system for asset matching') + parser.add_argument('--arch', metavar='name', help='Override detected architecture for asset matching') + parser.add_argument('-k', '--token', metavar='token', help='GitHub token') + parser.add_argument('-d', '--debug', action='store_true', help='Enable debug logging') + args = parser.parse_args() + + try: + raw_file_type = args.file_type + file_type = raw_file_type.strip() if raw_file_type is not None else None + if raw_file_type is not None and not file_type: + raise RuntimeError(f'Unknown asset type: {raw_file_type}') + + list_only = args.list is not None + list_repo = args.list if isinstance(args.list, str) else None + + repository = list_repo or args.repository + if not repository: + parser.print_help() + sys.exit(1) + + token = args.token + + if list_only: + release = fetch_latest_release(repository, token) + for asset in release['assets']: + print(f"- {asset['browser_download_url']}") + sys.exit(0) + + tool_name = repository.split('/')[-1] or repository + app_name = args.app_name or (tool_name[0].upper() + tool_name[1:] if tool_name else tool_name) + + binary_option = args.binary_name or tool_name + binary_parts = binary_option.split(':') + if len(binary_parts) > 1: + binary_source, binary_destination = binary_parts[0], binary_parts[1] + else: + binary_source = binary_destination = binary_option + + if args.releases_json: + url = f'https://api.github.com/repos/{repository}/releases/latest' + request = urllib.request.Request(url, headers=get_github_api_headers(token)) + try: + with urllib.request.urlopen(request) as response: + raw_release = response.read().decode('utf-8') + except urllib.error.HTTPError as error: + body = error.read().decode('utf-8', errors='replace') + raise RuntimeError(f'Failed to fetch latest release for {repository}: {error.reason}. {body}') from error + + output_base = binary_destination or tool_name + output_name = f'{output_base}.releases.json' + if args.output_directory: + output_path = os.path.join(validate_output_directory(args.output_directory), output_name) + else: + output_path = output_name + with open(output_path, 'w', encoding='utf-8') as f: + f.write(raw_release) + print(f'Downloaded GitHub releases to {output_path}.') + sys.exit(0) + + system = (args.system or sys.platform).lower() + raw_machine = args.arch or (os.uname().machine if hasattr(os, 'uname') else os.environ.get('PROCESSOR_ARCHITECTURE', '')) + machine = raw_machine.lower() + arch = MACHINE_TO_ARCH.get(machine, machine) + platform_info = PlatformInfo(system, arch) + + print(f'Fetching latest release for {repository}...') + release = fetch_latest_release(repository, token) + assets = release['assets'] + file_name = args.file_name + + if file_name and not file_name.startswith('~'): + exact_matches = [a for a in assets if a['name'] == file_name] + if len(exact_matches) != 1: + raise RuntimeError(f'Expected exactly one asset to match the provided filename, matched: {len(exact_matches)}') + asset = exact_matches[0] + else: + file_type_filtered = assets + if file_type: + if file_type in KNOWN_FILE_TYPES: + suffixes = KNOWN_FILE_TYPES[file_type] + file_type_filtered = [a for a in assets if a['name'].lower().endswith(suffixes)] + elif file_type.startswith('~'): + type_regex = re.compile(file_type[1:] + '$', re.IGNORECASE) + file_type_filtered = [a for a in assets if type_regex.search(a['name'])] + else: + extension = file_type.lstrip('.') + file_type_filtered = [a for a in assets if a['name'].lower().endswith(f'.{extension}'.lower())] + + if file_name and file_name.startswith('~'): + name_pattern = replace_platform_placeholders(file_name[1:], platform_info) + name_regex = re.compile(name_pattern, re.IGNORECASE) + matched = [a for a in file_type_filtered if name_regex.search(a['name'])] + if len(matched) != 1: + raise RuntimeError(f'Expected exactly one asset to match the filename regex, matched: {len(matched)}') + asset = matched[0] + else: + default_pattern = replace_platform_placeholders('{{SYSTEM}}[_-]{{ARCH}}', platform_info) + default_regex = re.compile(default_pattern, re.IGNORECASE) + matched = [a for a in file_type_filtered if default_regex.search(a['name'])] + if len(matched) != 1: + if len(matched) == 0: + raise RuntimeError(f'No assets matched the default criteria: {default_pattern}') + names = ', '.join(a['name'] for a in matched) + raise RuntimeError(f'Multiple assets matched the default criteria: {names}') + asset = matched[0] + + version = re.sub(r'^v', '', release['tag_name'], flags=re.IGNORECASE) + download_url = asset['browser_download_url'] + print(f"Will download '{app_name}' version: {version}") + print(f'Download URL: "{download_url}".') + + if args.dry_run: + sys.exit(0) + + if args.output_directory: + output_dir = validate_output_directory(args.output_directory) + output_path = os.path.join(output_dir, os.path.basename(download_url)) + print(f"Downloading '{app_name}' version {version} to '{output_path}'...") + download_asset(download_url, output_path, token) + sys.exit(0) + + temp_dir = tempfile.mkdtemp(prefix='setup-gh-release-') + try: + download_path = os.path.join(temp_dir, asset['name']) + download_asset(download_url, download_path, token) + asset_name_lower = asset['name'].lower() + + if re.search(r'\.(deb|pkg|rpm)$', asset_name_lower): + if asset_name_lower.endswith('.deb'): + pkg_binary, pkg_args = 'dpkg', ['-i', download_path] + elif asset_name_lower.endswith('.pkg'): + pkg_binary, pkg_args = 'installer', ['-pkg', download_path, '-target', '/'] + else: + pkg_binary, pkg_args = 'rpm', ['-i', download_path] + + is_root = hasattr(os, 'getuid') and os.getuid() == 0 + command = [pkg_binary, *pkg_args] if is_root else ['sudo', pkg_binary, *pkg_args] + result = subprocess.run(command) + if result.returncode != 0: + raise RuntimeError(f'Failed to install package using {" ".join(command)}.') + + print('Installation successful!') + sys.exit(0) + + extract_dir = os.path.join(temp_dir, 'extract') + print(f"Extracting {asset['name']}...") + os.makedirs(extract_dir, exist_ok=True) + + if asset_name_lower.endswith('.tar.gz') or asset_name_lower.endswith('.tgz') or asset_name_lower.endswith('.tar'): + with tarfile.open(download_path, 'r:*') as archive: + archive.extractall(extract_dir, filter='data') + elif asset_name_lower.endswith('.zip'): + with zipfile.ZipFile(download_path) as archive: + archive.extractall(extract_dir) + elif asset_name_lower.endswith('.7z'): + result = subprocess.run(['7z', 'x', download_path, f'-o{extract_dir}', '-y']) + if result.returncode != 0: + raise RuntimeError('7z failed. Make sure 7z is installed.') + else: + shutil.copy2(download_path, os.path.join(extract_dir, os.path.basename(download_path))) + + if binary_source.startswith('~'): + binary_regex_source = replace_platform_placeholders(binary_source[1:], platform_info) + binary_pattern = f'~{binary_regex_source}' + else: + binary_pattern = binary_source.replace('{{SYSTEM}}', platform_info.system).replace('{{ARCH}}', platform_info.arch) + + binary_regex = re.compile(binary_pattern[1:], re.IGNORECASE) if binary_pattern.startswith('~') else None + + binary_path = None + for dirpath, dirnames, filenames in os.walk(extract_dir): + dirnames.sort() + filenames.sort() + if args.debug: + print(f'Searching for binary in {dirpath}...') + for item in dirnames + filenames: + print(f' - {item}') + + for item in filenames: + if binary_regex: + is_match = bool(binary_regex.search(item)) + else: + is_match = item == binary_pattern + if not is_match and sys.platform == 'win32' and not binary_pattern.lower().endswith('.exe'): + is_match = item.lower() == f'{binary_pattern.lower()}.exe' + if is_match: + binary_path = os.path.join(dirpath, item) + break + if binary_path: + break + + if not binary_path: + raise RuntimeError(f'Could not find binary "{binary_source}" in the extracted asset.') + + if args.install_path: + install_dir = str(Path(args.install_path).resolve()) + elif sys.platform == 'win32': + local_app_data = os.environ.get('LOCALAPPDATA') or str(Path.home() / 'AppData' / 'Local') + install_dir = os.path.join(local_app_data, 'bin') + elif hasattr(os, 'getuid') and os.getuid() == 0: + install_dir = '/usr/local/bin' + else: + home_bin = str(Path.home() / 'bin') + install_dir = home_bin if os.path.isdir(home_bin) else '/usr/local/bin' + + os.makedirs(install_dir, exist_ok=True) + + final_name = binary_destination or os.path.basename(binary_path) + dest_path = os.path.join(install_dir, final_name) + + print(f'Installing {final_name} to {dest_path}...') + shutil.copy2(binary_path, dest_path) + if sys.platform != 'win32': + os.chmod(dest_path, 0o755) + + print('Installation successful!') + sys.exit(0) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + except SystemExit: + raise + except Exception as error: + print(f'Error: {error}', file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main()