Files
github-release-installer/github-release-installer

493 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# An installer script for GitHub releases
# Usage: github-release-installer [--test|-t|--debug|-d|--dry-run|-n] "App Name" "binary_name" "repo/name"
# Globals
GITHUB_API_BASE_URL="https://api.github.com"
GITHUB_TOKEN="${GITHUB_TOKEN:-}"
GITHUB_TOKEN_FILE="${GITHUB_TOKEN_FILE:-$HOME/.github_public_token.env}"
CURL_OPTS=("-s" "-L")
# User authentication for GitHub API requests
if [[ -z $GITHUB_TOKEN ]]; then
[[ -f $GITHUB_TOKEN_FILE ]] && source $GITHUB_TOKEN_FILE
fi
# If we have a GitHub token, use it for authentication
[[ -n $GITHUB_TOKEN ]] && CURL_OPTS+=("--header" "Authorization: token $GITHUB_TOKEN")
# Load github-specific curl options, if present
[[ -f $HOME/.curlrc-github ]] && CURL_OPTS+=("--config" "$HOME/.curlrc-github")
function discover_system_and_architecture() {
# Detect system and architecture, unless overridden
SYSTEM="${SYSTEM:-$(uname -s)}"
ARCH="${ARCH:-$(uname -m)}"
case "${SYSTEM,,}" in
linux)
SYSTEM=Linux
SYSTEM_RE="linux"
ROOT_USER=root
ROOT_GROUP=root
case "${ARCH,,}" in
x86_64 | amd64)
ARCH="Intel 64-bit"
ARCH_RE="(x64|amd64)"
;;
aarch64 | arm64)
ARCH="ARM 64-bit"
ARCH_RE="arm64"
;;
*)
echo "This script only supports x86_64 and arm64 architectures." >&2
return 1
;;
esac
;;
darwin|osx|macos)
SYSTEM=MacOS
SYSTEM_RE="(darwin|osx)"
ROOT_USER=root
ROOT_GROUP=wheel
case "${ARCH,,}" in
x86_64)
ARCH="Intel 64-bit"
ARCH_RE="(x86_64|x64|amd64|universal)"
;;
arm64)
ARCH="ARM 64-bit"
ARCH_RE="(arm64|universal)"
;;
*)
echo "This script only supports x86_64 and arm64 architectures." >&2
return 1
;;
esac
;;
*)
echo "This script only supports Linux and macOS." >&2
return 1
;;
esac
return 0
}
function github_assets() {
if [[ -z $REPO_NAME ]]; then
echo "ERROR: Repository name is required to list GitHub assets." >&2
return 1
fi
# Expand combined asset types
if [[ -z $ASSET_TYPE ]]; then
# Configure asset selector to match operating system packages and archives
case $SYSTEM in
Linux)
ASSET_TYPE='\.(deb|rpm|zip|tar\.gz|tgz)$'
;;
MacOS)
ASSET_TYPE='\.(pkg|zip|tar\.gz|tgz)$'
;;
*)
ASSET_TYPE='\.(zip|tar\.gz|tgz)$'
;;
esac
elif [[ $ASSET_TYPE == "archive" ]]; then
ASSET_TYPE='\.(zip|tar\.gz|tgz)$'
elif [[ $ASSET_TYPE == "package" ]]; then
ASSET_TYPE='\.(deb|pkg|rpm)$'
fi
# Check, if the asset type has a '$' at the end
if [[ ! $ASSET_TYPE =~ \$\$?$ ]]; then
ASSET_TYPE="${ASSET_TYPE}$"
fi
# Asset type should start with a dot
if [[ ! $ASSET_TYPE =~ ^\\\. ]]; then
ASSET_TYPE="\.$ASSET_TYPE"
fi
# Preprocess GitHub releases API response
if [[ -z $ASSET_NAME || $ASSET_NAME =~ ^~ ]]; then
# No asset name provided, or asset name is a regex pattern
if [[ -z $ASSET_NAME ]]; then
PATTERN=".*"
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Filtering assets for system regex: '$SYSTEM_RE' and architecture regex: '$ARCH_RE'." >&2
else
PATTERN="${ASSET_NAME:1}"
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Filtering assets for system regex: '$SYSTEM_RE', architecture regex: '$ARCH_RE', asset type regex: '$ASSET_TYPE'." >&2
fi
curl "${CURL_OPTS[@]}" "$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest" |
jq -r \
--arg PATTERN "$PATTERN" \
--arg SYSTEM "$SYSTEM_RE" \
--arg ARCH "$ARCH_RE" \
--arg ASSET_TYPE "$ASSET_TYPE" \
'{version: .tag_name | sub("^v"; ""; "i"), assets: [.assets[] | {name, browser_download_url} | select(.name | test("^" + $PATTERN + ".*" + $SYSTEM + "[_-]" + $ARCH + ".*" + $ASSET_TYPE; "i"))]}'
else
# Specific asset name provided
curl "${CURL_OPTS[@]}" "$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest" |
jq -r \
--arg ASSET_NAME "$ASSET_NAME" \
'{version: .tag_name | sub("^v"; ""; "i"), assets: [.assets[] | {name, browser_download_url} | select(.name == $ASSET_NAME)]}'
fi
}
function define_sudo_cmd() {
if [[ $(id -u) -ne 0 ]]; then
# Not running as root, need sudo for installation
SUDOCMD="sudo"
if ! command -v sudo &> /dev/null; then
echo "ERROR: sudo is not installed."
return 1
fi
else
SUDOCMD=""
fi
}
function github_release_installer() {
if ! command -v jq &> /dev/null || ! command -v curl &> /dev/null; then
echo "ERROR: jq or curl is not installed."
exit 1
fi
# Check, if required variables are set
if [[ -z $APP_NAME || -z $BINARY_NAME || -z $REPO_NAME ]]; then
# Check, if the values were provided as function arguments
if [[ $# -ne 3 ]]; then
echo "ERROR: APP_NAME, BINARY_NAME, and REPO_NAME must be set before calling github_release_installer." >&2
return 1
fi
# Set from function arguments
APP_NAME="$1"
BINARY_NAME="$2"
REPO_NAME="$3"
fi
# Detect system and architecture
discover_system_and_architecture || return 1
echo "Detected system: $SYSTEM, architecture: $ARCH"
# Download GitHub releases API response
GITHUB_API_RESPONSE="$(github_assets)"
if [[ -z $GITHUB_API_RESPONSE ]]; then
echo "ERROR: Could not download GitHub releases for '$APP_NAME'." >&2
return 1
fi
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: GitHub API response for '$APP_NAME' is:" && cat <<< "$GITHUB_API_RESPONSE" >&2
if [[ $TEST_MODE -gt 1 ]]; then
echo "The filtered GitHub API response for '$APP_NAME' is:"
echo "$GITHUB_API_RESPONSE"
fi
VERSION=$(jq -r .version <<< "$GITHUB_API_RESPONSE")
if [[ -z $VERSION ]]; then
echo "ERROR: Could not determine the latest version of '$APP_NAME'." >&2
return 1
fi
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Latest version of '$APP_NAME' is $VERSION."
[[ $TEST_MODE -gt 1 ]] && echo "[DEBUG]: Asset name to download: ${ASSET_NAME:-<not specified>}."
NUM_ASSETS=$(jq -r '.assets | length' <<< "$GITHUB_API_RESPONSE")
if [[ $NUM_ASSETS -eq 0 ]]; then
echo "ERROR: No assets found for '$APP_NAME' version $VERSION for $SYSTEM $ARCH." >&2
return 1
elif [[ $NUM_ASSETS -gt 1 ]]; then
echo "WARNING: Multiple assets found for '$APP_NAME' version $VERSION for $SYSTEM $ARCH."
echo -e "Please specify an asset name from the list below:\n"
jq -r '.assets[] | "- \(.name)"' <<< "$GITHUB_API_RESPONSE"
echo ""
return 0
fi
if [[ -z $ASSET_NAME ]]; then
# No asset name provided, check how many assets are available
DOWNLOAD_URL=$(jq -r '.assets[].browser_download_url' <<< "$GITHUB_API_RESPONSE")
elif [[ $ASSET_NAME =~ ^~ ]]; then
# Asset name is a regex pattern
PATTERN="${ASSET_NAME:1}"
[[ $TEST_MODE -gt 0 ]] && echo "[DEBUG]: Using regex pattern to find asset: $PATTERN" >&2
DOWNLOAD_URL=$(jq -r --arg PATTERN "$PATTERN" '.assets[] | select(.name | test($PATTERN; "i")) | .browser_download_url' <<< "$GITHUB_API_RESPONSE")
else
DOWNLOAD_URL=$(jq -r --arg ASSET_NAME "$ASSET_NAME" '.assets[] | select(.name == $ASSET_NAME) | .browser_download_url' <<< "$GITHUB_API_RESPONSE")
fi
if [[ -z $DOWNLOAD_URL ]]; then
echo "ERROR: Could not find a suitable download URL for '$APP_NAME' version $VERSION for $SYSTEM $ARCH." >&2
return 1
fi
# If in test mode, exit here
[[ $TEST_MODE -gt 0 ]] && return 0
# if output directory is specified, just download the file there
if [[ -n $OUTPUT_DIR ]]; then
if [[ ! -d $OUTPUT_DIR ]]; then
echo "ERROR: Output directory '$OUTPUT_DIR' does not exist." >&2
return 1
fi
# Remove "-s" from CURL_OPTS to show download progress
CURL_OPTS=("${CURL_OPTS[@]:1}")
OUTPUT_NAME=$(basename "$DOWNLOAD_URL")
OUTPUT_PATH="$OUTPUT_DIR/$OUTPUT_NAME"
echo "Downloading '$APP_NAME' version $VERSION to '$OUTPUT_PATH'..."
# Execute curl to download the file and exit
exec curl "${CURL_OPTS[@]}" -o "$OUTPUT_PATH" "$DOWNLOAD_URL"
fi
echo -e "Will download '$APP_NAME' version: $VERSION\nDownload URL: \"$DOWNLOAD_URL\"."
define_sudo_cmd || return 1
TMP=$(mktemp -d -t github-release-installer-XXXXXX)
# Ensure temporary artifacts are cleaned up on exit
trap "test -d $TMP && rm -rf $TMP" EXIT
# Change to temporary directory
cd "$TMP" || return 1
# Create extraction directory
mkdir -p $TMP/extracted || return 1
echo "Downloading '$APP_NAME' version $VERSION..."
# Check, if the binary name is different from the original
if grep -q ":" <<< "$BINARY_NAME"; then
ORIGINAL_NAME="${BINARY_NAME%%:*}"
DESTINATION_NAME="${BINARY_NAME#*:}"
else
ORIGINAL_NAME="$BINARY_NAME"
DESTINATION_NAME="$BINARY_NAME"
fi
# Check, the file type
if [[ $DOWNLOAD_URL == *.zip ]]; then
curl "${CURL_OPTS[@]}" -o $TMP/archive.zip $DOWNLOAD_URL || return 1
echo "Uncompressing ZIP archive of '$APP_NAME'..."
unzip -q $TMP/archive.zip -d $TMP/extracted || return 1
elif [[ $DOWNLOAD_URL =~ \.(tar\.gz|tgz)$ ]]; then
curl "${CURL_OPTS[@]}" -o $TMP/archive.tgz $DOWNLOAD_URL || return 1
echo "Uncompressing GZIPped TAR archive of '$APP_NAME'..."
tar -x -z -C $TMP/extracted -f $TMP/archive.tgz || return 1
elif [[ $DOWNLOAD_URL =~ deb$ ]]; then
echo "Downloading Debian package of '$APP_NAME'..."
curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL || return 1
# Install the deb package to extract its contents
echo "Installing Debian package of '$APP_NAME'..."
$SUDOCMD dpkg -i $(basename $DOWNLOAD_URL)
return $?
elif [[ $DOWNLOAD_URL =~ pkg$ ]]; then
echo "Downloading macOS package of '$APP_NAME'..."
curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL || return 1
# Install the pkg package to extract its contents
echo "Installing macOS package of '$APP_NAME'..."
$SUDOCMD installer -pkg $(basename $DOWNLOAD_URL) -target /
return $?
elif [[ $DOWNLOAD_URL =~ rpm$ ]]; then
echo "Downloading RPM package of '$APP_NAME'..."
curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL || return 1
# Install the rpm package to extract its contents
echo "Installing RPM package of '$APP_NAME'..."
$SUDOCMD rpm -i $(basename $DOWNLOAD_URL)
return $?
else
echo "Downloading binary release of '$APP_NAME'..."
(cd $TMP/extracted && curl "${CURL_OPTS[@]}" -LO $DOWNLOAD_URL) || return 1
return 0
fi
# Find the binary file by looking for a file with the exact name
BINARY_PATH=$(find $TMP/extracted -name "$ORIGINAL_NAME" -type f)
if [[ -z $BINARY_PATH ]]; then
# Try with system and architecture suffix
BINARY_PATH=$(find $TMP/extracted -name "${ORIGINAL_NAME}-${SYSTEM}-${ARCH}" -type f)
fi
if [[ -z $BINARY_PATH ]]; then
echo "ERROR: Could not find binary file '$ORIGINAL_NAME' to install. Specify the correct binary name with the -b option." >&2
echo ""
echo "Available files in the extracted archive:"
find $TMP/extracted -type f | sed "s|$TMP/extracted/||"
echo ""
return 1
fi
echo "Installing '$APP_NAME' binary "$ORIGINAL_NAME" to /usr/local/bin/$DESTINATION_NAME..."
$SUDOCMD install -m 755 $BINARY_PATH /usr/local/bin/$DESTINATION_NAME
}
function usage() {
cat << EOF
Usage: github-release-installer [options] "repo/name"
Options:
--dry-run Run in test mode. Optional level (default: 9).
Or set TEST_MODE environment variable to a number > 0.
-l --list "repo/name" List available assets for the given repository.
-a, --app-name "App Name" Name of the application (optional).
-b, --binary-name "binary_name[:destination_name]" Name of the binary to install and optional destination name (optional).
-j, --releases-json Only download the releases JSON file.
-o, --output-directory Only download the file to the specified directory.
-f, --file-name "file_name" Name of the file to download (optional).
-t, --type "type" Type of the asset to download: archive (zip, tar.gz, tgz) or package (deb, pkg, rpm) (optional).
Example:
github-release-installer -a "GitHub CLI" -b gh cli/cli
EOF
}
# Default values
APP_NAME=""
BINARY_NAME=""
REPO_NAME=""
ASSET_NAME=""
ASSET_TYPE=""
OUTPUT_DIR=""
RELEASES_ONLY=0
SYSTEM=""
ARCH=""
# Use value from environment variable or default to 0
TEST_MODE=${TEST_MODE:-0}
# Check, if we have been executed or sourced.
if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
return 0
fi
shopt -s nocasematch
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--dry-run)
# Check, if the next argument is present and is a number
if [[ ${2:-} =~ ^[0-9]+$ ]]; then
TEST_MODE="$2"
shift 2
else
TEST_MODE=1
shift
fi
;;
-l|--list)
REPO_NAME="$2"
shift 2
curl "${CURL_OPTS[@]}" "$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest" |
jq -r '"- \(.assets[].browser_download_url)"'
exit $?
;;
-a|--app-name)
APP_NAME="$2"
shift 2
;;
-b|--binary-name)
BINARY_NAME="$2"
shift 2
;;
-o|--output-directory)
OUTPUT_DIR="$2"
shift 2
# That trick mimicks realpath
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
;;
-j|--releases-json)
RELEASES_ONLY=1
shift
;;
-f|--file-name)
ASSET_NAME="$2"
shift 2
;;
-t|--type)
if [[ $2 =~ ^(zip|gzip|gz|tar|tar\.gz|tgz|deb|pkg|rpm)$ ]]; then
ASSET_TYPE="$2$"
elif [[ $2 =~ ^(archive|package)$ ]]; then
ASSET_TYPE="$2"
else
echo "ERROR: Unknown asset type: $2" >&2
usage
exit 1
fi
shift 2
;;
# Override system and architecture detection
--system)
SYSTEM="$2"
shift 2
;;
--arch)
ARCH="$2"
shift 2
;;
-*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
*)
break
;;
esac
done
# First positional argument is REPO_NAME
REPO_NAME="${1:-}"
shift
if [[ -z $REPO_NAME ]]; then
echo "ERROR: Repository name is required." >&2
usage
exit 1
fi
# If APP_NAME or BINARY_NAME are missing, try to infer them
# Use last part of REPO_NAME as BINARY_NAME if not provided
if [[ -z $BINARY_NAME ]]; then
BINARY_NAME="${REPO_NAME##*/}"
fi
# Use capitalized last part of REPO_NAME as APP_NAME if not provided
if [[ -z $APP_NAME ]]; then
APP_NAME="${REPO_NAME##*/}"
APP_NAME="${APP_NAME^[a-z]}"
fi
if [[ $RELEASES_ONLY -ne 0 ]]; then
URL="$GITHUB_API_BASE_URL/repos/$REPO_NAME/releases/latest"
echo "Downloading GitHub releases for '$APP_NAME' from $URL..."
OUTPUT_PATH="${BINARY_NAME}.releases.json"
if [[ -n $OUTPUT_DIR ]]; then
OUTPUT_PATH="$OUTPUT_DIR/$OUTPUT_PATH"
fi
if ! curl "${CURL_OPTS[@]}" -o "$OUTPUT_PATH" "$URL"; then
echo "ERROR: Could not download GitHub releases for '$APP_NAME' from $URL." >&2
exit 1
fi
echo "Downloaded GitHub releases to $OUTPUT_PATH."
exit 0
fi
github_release_installer
exit $?