Add validation workflow and main installer script for GitHub releases

This commit is contained in:
2026-03-26 14:40:08 +01:00
commit b161511776
3 changed files with 583 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
name: Validate Script
on:
push:
branches:
- main
paths:
- 'github-release-installer'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Validate Script
run: |
# Check if the script is executable
if [ ! -x github-release-installer ]; then
echo "Error: github-release-installer is not executable."
exit 1
fi
- name: Syntax Check
run: |
# Check the syntax of the script
if ! bash -n github-release-installer; then
echo "Error: Syntax errors found in github-release-installer."
exit 1
fi
- name: Help Option Check
run: |
# Run the script with --help to check for basic functionality
if ! ./github-release-installer --help > /dev/null; then
echo "Error: github-release-installer did not run successfully."
exit 1
fi
- name: Github CLI Release List Check
run: |
./github-release-installer -l cli/cli
- name: Github CLI Install Check
run: |
./github-release-installer cli/cli

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Github Release Installer Bash Script
The `github-release-installer` is a bash script that allows you to easily download and install assets from GitHub releases. It supports various options for customizing the installation process, such as specifying the release version, filtering assets by name, and performing dry runs.
## Usage
Run the script with a GitHub repository in `owner/repo` format:
```bash
./github-release-installer [options] "owner/repo"
```
Examples:
```bash
# Install latest matching release asset for the current OS/arch
./github-release-installer cli/cli
# Preview what would be selected without downloading/installing
./github-release-installer --dry-run cli/cli
# List downloadable assets from the latest release
./github-release-installer --list cli/cli
# Install using explicit app and binary names
./github-release-installer --app-name "GitHub CLI" --binary-name gh cli/cli
# Download a specific asset file to a directory (no install)
./github-release-installer --file-name gh_2.70.0_macOS_arm64.zip --output-directory /tmp cli/cli
```
Useful options:
- `--dry-run [level]`: test mode, print what would happen.
- `--list`: print release asset URLs.
- `--file-name <name>`: choose an exact asset name.
- `--type <archive|package|zip|tar.gz|tgz|deb|pkg|rpm>`: filter by asset type.
- `--binary-name <src[:dest]>`: install binary using optional destination name.
- `--output-directory <dir>`: download only, do not install.
- `--releases-json`: download latest release metadata as JSON.
For full help:
```bash
./github-release-installer --help
```

492
github-release-installer Executable file
View File

@@ -0,0 +1,492 @@
#!/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 $?