Files
setup-github-release/python/install_github_release.py
T
2026-08-24 10:02:15 +02:00

475 lines
21 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import json
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:
"""Holds the detected OS/architecture and their regex patterns for asset matching."""
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):
"""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': 'install-github-release-cli',
}
if token:
headers['Authorization'] = f'token {token}'
return headers
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:
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 check_github_token(token):
"""Verify a GitHub token by fetching the latest release of actions/checkout."""
print('Verifying GitHub token...')
fetch_latest_release('actions/checkout', token)
print('Success: The provided GitHub token is valid and has sufficient permissions to access public repositories.')
def download_asset(url, dest_path, token=None):
"""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)
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):
"""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)
BASH_COMPLETION = '''\
_install_github_release_complete() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
opts="--dry-run -l --list -a --app-name -f --file-name -b --binary-name -t --file-type -p --install-path -o --output-directory -j --releases-json --system --arch -k --token -d --debug --completion --check-token -h --help"
case "$prev" in
-t|--file-type)
COMPREPLY=( $(compgen -W "archive package linux macos targz" -- "$cur") )
return 0
;;
--completion)
COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") )
return 0
;;
-p|--install-path|-o|--output-directory)
COMPREPLY=( $(compgen -d -- "$cur") )
return 0
;;
esac
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
}
complete -F _install_github_release_complete install_github_release.py
'''
ZSH_COMPLETION = '''\
#compdef install_github_release.py
compdef _install_github_release install_github_release.py
_install_github_release() {
local -a options
options=(
'(-l --list)'{-l,--list}'[List available assets from latest release and exit]::repository:'
'(-a --app-name)'{-a,--app-name}'[Application name]:name:'
'(-f --file-name)'{-f,--file-name}'[Asset file name or regex pattern]:name:'
'(-b --binary-name)'{-b,--binary-name}'[Binary name]:name:'
'(-t --file-type)'{-t,--file-type}'[Asset type selector]:type:(archive package linux macos targz)'
'(-p --install-path)'{-p,--install-path}'[Custom installation directory]:path:_files -/'
'(-o --output-directory)'{-o,--output-directory}'[Download-only output directory]:path:_files -/'
'(-j --releases-json)'{-j,--releases-json}'[Download latest release JSON only]'
'--system[Override detected system]:system:'
'--arch[Override detected architecture]:arch:'
'(-k --token)'{-k,--token}'[GitHub token]:token:'
'(-d --debug)'{-d,--debug}'[Enable debug logging]'
'--dry-run[Run in test mode]'
'--completion[Print shell completion script]:shell:(bash zsh)'
'--check-token[Verify the GitHub token against actions/checkout and exit]'
'(-h --help)'{-h,--help}'[Show help message]'
'1:repository:'
)
_arguments -s $options
}
# don't run the completion function when being source-ed or eval-ed
if [ "$funcstack[1]" = "_install_github_release" ]; then
_install_github_release
fi
'''
def main():
"""Parse command-line arguments and run install_github_release, reporting errors via the exit code."""
parser = argparse.ArgumentParser(
usage='%(prog)s [options] <repository>',
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: ~<regex> 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')
parser.add_argument('--completion', choices=['bash', 'zsh'], help='Print a shell completion script and exit')
parser.add_argument('--check-token', action='store_true', help='Verify the GitHub token against actions/checkout and exit')
args = parser.parse_args()
if args.completion:
print(BASH_COMPLETION if args.completion == 'bash' else ZSH_COMPLETION, end='')
sys.exit(0)
if args.check_token:
token = args.token or os.environ.get('GITHUB_TOKEN')
if not token:
print('Error: No GitHub token provided as an argument or found in GITHUB_TOKEN environment variable.', file=sys.stderr)
sys.exit(1)
try:
check_github_token(token)
except Exception as error:
print(f'Error: {error}', file=sys.stderr)
sys.exit(1)
sys.exit(0)
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:
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()