diff --git a/python/install_github_release.py b/python/install_github_release.py index 768ac98..849ee0e 100755 --- a/python/install_github_release.py +++ b/python/install_github_release.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import json import os import re import shutil @@ -41,6 +42,8 @@ KNOWN_FILE_TYPES = { class PlatformInfo: + """Holds the detected OS/architecture and their regex patterns for asset matching.""" + def __init__(self, system, arch): self.system = system self.arch = arch @@ -49,13 +52,15 @@ class PlatformInfo: def replace_platform_placeholders(pattern, platform): + """Substitute {{SYSTEM}} and {{ARCH}} in a pattern with the platform's regex fragments.""" return pattern.replace('{{SYSTEM}}', platform.system_pattern).replace('{{ARCH}}', platform.arch_pattern) def get_github_api_headers(token=None): + """Build the request headers for the GitHub REST API, adding an Authorization header if a token is given.""" headers = { 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'setup-github-release-action', + 'User-Agent': 'install-github-release-cli', } if token: headers['Authorization'] = f'token {token}' @@ -63,11 +68,11 @@ def get_github_api_headers(token=None): def fetch_latest_release(repository, token=None): + """Fetch and parse the latest release metadata for a GitHub repository.""" 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') @@ -75,7 +80,8 @@ def fetch_latest_release(repository, token=None): def download_asset(url, dest_path, token=None): - headers = {'User-Agent': 'setup-github-release-action'} + """Download a release asset from url and write it to dest_path.""" + headers = {'User-Agent': 'install-github-release-cli'} if token: headers['Authorization'] = f'token {token}' request = urllib.request.Request(url, headers=headers) @@ -87,13 +93,242 @@ def download_asset(url, dest_path, token=None): def validate_output_directory(output_directory): + """Resolve output_directory to an absolute path and confirm it already exists.""" resolved = Path(output_directory).resolve() if not resolved.is_dir(): raise RuntimeError(f'Output directory "{resolved}" does not exist.') return str(resolved) +def install_github_release(repository, *, list_only=False, app_name=None, file_name=None, binary_name=None, + file_type=None, install_path=None, output_directory=None, releases_json=False, + system=None, arch=None, token=None, debug=False, dry_run=False): + """Download, extract, and install a binary from a repository's latest GitHub release.""" + + # Normalize and validate the file-type selector. + raw_file_type = 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 mode: print and return the release's assets without downloading anything. + if list_only: + release = fetch_latest_release(repository, token) + for asset in release['assets']: + print(f"- {asset['browser_download_url']}") + return release['assets'] + + # Derive the display name and the binary's source/destination names from the repository or overrides. + tool_name = repository.split('/')[-1] or repository + app_name = app_name or (tool_name[0].upper() + tool_name[1:] if tool_name else tool_name) + + binary_option = 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 + + # Releases-JSON mode: save the raw release API response to a file and return its path. + if 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 output_directory: + output_path = os.path.join(validate_output_directory(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}.') + return output_path + + # Detect (or apply overrides for) the target OS and architecture. + system = (system or sys.platform).lower() + raw_machine = 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) + + # Fetch the latest release and select the one asset matching the file-name/file-type criteria. + print(f'Fetching latest release for {repository}...') + release = fetch_latest_release(repository, token) + assets = release['assets'] + + # A literal (non-regex) file-name is matched verbatim, bypassing file-type filtering entirely. + 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: + # Narrow the candidates by file-type: a known type keyword, a trailing regex, or a plain extension. + 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())] + + # A regex file-name (with platform placeholders expanded) is matched against the type-filtered candidates. + 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: + # With no file-name given, fall back to matching the detected system and architecture. + 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}".') + + # Dry-run mode: stop after reporting the selected asset. + if dry_run: + return {'version': version, 'download_url': download_url, 'asset': asset} + + # Output-directory mode: download the asset without extracting or installing it. + if output_directory: + output_dir = validate_output_directory(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) + return output_path + + # Full install: download the asset into a temporary directory that is removed on exit. + 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() + + # System-package asset: hand it to the OS package manager and stop, no binary search or copy needed. + 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!') + return None + + # Archive asset: extract it into a subdirectory using the format implied by its extension. + 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))) + + # Build the binary's search pattern, expanding platform placeholders in a regex or literal name. + 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 + + # Walk the extracted tree in sorted order and stop at the first file matching the pattern. + binary_path = None + for dirpath, dirnames, filenames in os.walk(extract_dir): + dirnames.sort() + filenames.sort() + if 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.') + + # Resolve the install directory: an explicit override, or an OS-appropriate default. + if install_path: + install_dir = str(Path(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) + + # Copy the binary into place and make it executable. + 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!') + return dest_path + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def main(): + """Parse command-line arguments and run install_github_release, reporting errors via the exit code.""" parser = argparse.ArgumentParser( prog='install-github-release', usage='%(prog)s [options] ', @@ -118,225 +353,35 @@ def main(): parser.add_argument('-d', '--debug', action='store_true', help='Enable debug logging') args = parser.parse_args() + 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) + 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 + install_github_release( + repository, + list_only=args.list is not None, + app_name=args.app_name, + file_name=args.file_name, + binary_name=args.binary_name, + file_type=args.file_type, + install_path=args.install_path, + output_directory=args.output_directory, + releases_json=args.releases_json, + system=args.system, + arch=args.arch, + token=args.token, + debug=args.debug, + dry_run=args.dry_run, + ) except Exception as error: print(f'Error: {error}', file=sys.stderr) sys.exit(1) + sys.exit(0) + if __name__ == '__main__': main()