Refactor: Enhance install_github_release functionality with improved error handling and asset downloading

This commit is contained in:
2026-08-24 08:14:49 +02:00
parent 25adff380c
commit 9c3cb2115c
+262 -217
View File
@@ -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] <repository>',
@@ -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()