#!/usr/bin/env bash # # Get latest release version, branch tag, or latest commit from GitHub # Usage: x-gh-get-latest-version [options] # Author: Ismo Vuorinen 2024 set -euo pipefail # Environment variables, can be overridden by command line arguments GITHUB_API_URL="${GITHUB_API_URL:-https://api.github.com/repos}" VERBOSE="${VERBOSE:-0}" INCLUDE_PRERELEASES="${INCLUDE_PRERELEASES:-0}" OLDEST_RELEASE="${OLDEST_RELEASE:-0}" BRANCH="" LATEST_COMMIT="${LATEST_COMMIT:-0}" LATEST_TAG="${LATEST_TAG:-0}" OUTPUT="${OUTPUT:-text}" SHOW_HELP=0 REPOSITORY="" COMBINED=0 BIN=$(basename "$0") # Prints a message if VERBOSE=1 msg() { if [[ $VERBOSE -eq 1 ]]; then echo "$1" >&2 fi } # Show usage information usage() { cat << EOF Usage: $BIN [options] Fetches the latest release version, latest branch tag, or latest commit SHA from GitHub. Arguments: Repository in format 'owner/repo' (e.g. ivuorinen/dotfiles) Options: -h, --help Show this help message and exit -v, --verbose Enable verbose output -p, --prereleases Include prerelease versions (default: only stable releases) -o, --oldest Fetch the oldest release instead of the latest -b, --branch Fetch the latest tag from a specific branch (default: main) -c, --commit Fetch the latest commit SHA from the specified branch -t, --tag Fetch the latest Git tag (any branch) -j, --json Return output as JSON (default: plain text) -a, --all Fetch all information types in a combined output Environment Variables (can be used instead of command line options): - INCLUDE_PRERELEASES=1 Same as --prereleases - OLDEST_RELEASE=1 Same as --oldest - BRANCH= Same as --branch - LATEST_COMMIT=1 Same as --commit - LATEST_TAG=1 Same as --tag - OUTPUT=json Same as --json - GITHUB_API_URL= Override GitHub API URL (useful for GitHub Enterprise) - GITHUB_TOKEN= Use GitHub API token to increase rate limits (default: unauthenticated) - VERBOSE=1 Same as --verbose Requirements: - curl - jq (for JSON processing) Examples: # Fetch the latest stable release $BIN ivuorinen/dotfiles # Fetch the latest release including prereleases $BIN ivuorinen/dotfiles --prereleases # Fetch the oldest release $BIN ivuorinen/dotfiles --oldest # Fetch the latest tag from the 'develop' branch $BIN ivuorinen/dotfiles --branch develop # Fetch the latest commit SHA from 'main' branch $BIN ivuorinen/dotfiles --commit # Fetch the latest Git tag (any branch) $BIN ivuorinen/dotfiles --tag # Fetch all information types in a combined output $BIN ivuorinen/dotfiles --all # Output result in JSON format $BIN ivuorinen/dotfiles --json # Use GitHub API token for higher rate limits GITHUB_TOKEN="your_personal_access_token" $BIN ivuorinen/dotfiles # Use GitHub Enterprise API GITHUB_API_URL="https://github.example.com/api/v3/repos" $BIN ivuorinen/dotfiles EOF exit 0 } # Check that required dependencies are installed check_dependencies() { for cmd in curl jq; do if ! command -v "$cmd" &> /dev/null; then echo "Error: '$cmd' is required but not installed." >&2 exit 1 fi done } # Check GitHub API rate limits and warn if they're getting low check_rate_limits() { local auth_status="unauthenticated" local auth_header=() if [[ -n ${GITHUB_TOKEN:-} ]]; then auth_status="authenticated" auth_header=(-H "Authorization: token $GITHUB_TOKEN") fi msg "Making $auth_status GitHub API requests" local rate_limit_info rate_limit_info=$(curl -sSL "${auth_header[@]}" "https://api.github.com/rate_limit") local remaining local reset_timestamp local reset_time remaining=$(echo "$rate_limit_info" | jq -r '.resources.core.remaining') reset_timestamp=$(echo "$rate_limit_info" | jq -r '.resources.core.reset') # Handle date command differences between Linux and macOS if date --version > /dev/null 2>&1; then # GNU date (Linux) reset_time=$(date -d "@$reset_timestamp" "+%H:%M:%S %Z" 2> /dev/null) else # BSD date (macOS) reset_time=$(date -r "$reset_timestamp" "+%H:%M:%S %Z" 2> /dev/null) fi msg "Rate limit status: $remaining requests remaining, reset at $reset_time" if [[ $remaining -le 5 ]]; then echo "Warning: GitHub API rate limit nearly reached ($remaining requests left)" >&2 echo "Rate limits will reset at: $reset_time" >&2 if [[ $auth_status == "unauthenticated" ]]; then echo "Tip: Set GITHUB_TOKEN to increase your rate limits (60 → 5000 requests/hour)" >&2 fi fi } # Make a GitHub API request with proper error handling api_request() { local url="$1" local auth_header=() if [[ -n ${GITHUB_TOKEN:-} ]]; then auth_header=(-H "Authorization: token $GITHUB_TOKEN") fi local response local status_code # Use a temporary file to capture both headers and body local tmp_file tmp_file=$(mktemp) msg "Making API request to: $url" status_code=$(curl -sSL -w "%{http_code}" -o "$tmp_file" "${auth_header[@]}" "$url") response=$(< "$tmp_file") rm -f "$tmp_file" # Check for HTTP errors if [[ $status_code -ge 400 ]]; then local error_msg error_msg=$(echo "$response" | jq -r '.message // "Unknown error"') if [[ $status_code -eq 403 && $error_msg == *"API rate limit exceeded"* ]]; then # Extract rate limit reset info local reset_timestamp reset_timestamp=$(echo "$response" | jq -r '.rate.reset // empty') local reset_time if date --version > /dev/null 2>&1; then # GNU date (Linux) reset_time=$(date -d "@$reset_timestamp" "+%H:%M:%S %Z" 2> /dev/null \ || echo "unknown time") else # BSD date (macOS) reset_time=$(date -r "$reset_timestamp" "+%H:%M:%S %Z" 2> /dev/null \ || echo "unknown time") fi echo "Error: GitHub API rate limit exceeded" >&2 echo "Rate limit will reset at: $reset_time" >&2 if [[ -z ${GITHUB_TOKEN:-} ]]; then echo "Tip: Set GITHUB_TOKEN to increase your rate limits (60 → 5000 requests/hour)" >&2 else echo "You've exceeded even authenticated rate limits (5000 requests/hour)" >&2 fi exit 3 elif [[ $status_code -eq 404 ]]; then echo "Error: Repository not found or no access permission: $url" >&2 exit 2 else echo "GitHub API error ($status_code): $error_msg" >&2 exit 1 fi fi echo "$response" } # Check if repository exists before proceeding check_repository() { local repo="$1" local api_url="${GITHUB_API_URL}/${repo}" msg "Checking if repository exists: $api_url" local response response=$(api_request "$api_url") # If we got here, the repository exists (otherwise api_request would have exited) msg "Repository found: $(echo "$response" | jq -r '.full_name')" # Get default branch if no branch is specified if [[ -z ${BRANCH} ]]; then BRANCH=$(echo "$response" | jq -r '.default_branch') msg "Using default branch: $BRANCH" fi # Return the repository full name (in case it differs from input due to redirects) echo "$response" | jq -r '.full_name' } # Fetches the latest release or the oldest if OLDEST_RELEASE=1 # $1 - GitHub repository (string) get_release_version() { local repo="$1" local include_prereleases="${INCLUDE_PRERELEASES:-0}" local oldest_release="${OLDEST_RELEASE:-0}" local api_url="${GITHUB_API_URL}/${repo}/releases" msg "Fetching release data from: $api_url " + \ "(Include prereleases: $include_prereleases, Oldest: $oldest_release)" local json_response json_response=$(api_request "$api_url") local version="" local prerelease_version="" # Get stable release version if [[ $oldest_release -eq 1 ]]; then version=$(echo "$json_response" \ | jq -r '[.[] | select(.tag_name != null and .prerelease == false)] | sort_by(.created_at) | first.tag_name // empty') else version=$(echo "$json_response" \ | jq -r '[.[] | select(.tag_name != null and .prerelease == false)] | sort_by(.created_at) | reverse | first.tag_name // empty') fi # Get prerelease version if requested if [[ $include_prereleases -eq 1 ]]; then if [[ $oldest_release -eq 1 ]]; then prerelease_version=$(echo "$json_response" \ | jq -r '[.[] | select(.tag_name != null and .prerelease == true)] | sort_by(.created_at) | first.tag_name // empty') else prerelease_version=$(echo "$json_response" \ | jq -r '[.[] | select(.tag_name != null and .prerelease == true)] | sort_by(.created_at) | reverse | first.tag_name // empty') fi fi # Error if no releases found and we're not in combined mode if [[ -z $version && -z $prerelease_version && $COMBINED -eq 0 ]]; then echo "No releases found for repository: $repo" >&2 exit 1 fi # Return both values for combined output if [[ $COMBINED -eq 1 ]]; then echo "$version" echo "$prerelease_version" else # Return prerelease if specifically requested, otherwise stable if [[ $include_prereleases -eq 1 && -n $prerelease_version ]]; then msg "Found prerelease version: $prerelease_version" echo "$prerelease_version" else msg "Found stable release version: $version" echo "$version" fi fi } # Fetches the latest tag from the specified branch get_latest_branch_tag() { local repo="$1" local branch="${BRANCH:-main}" local api_url="${GITHUB_API_URL}/${repo}/git/refs/tags" msg "Fetching latest tag for branch '$branch' from: $api_url" local json_response json_response=$(api_request "$api_url") local version version=$(echo "$json_response" \ | jq -r "[.[] | select(.ref | contains(\"refs/tags/$branch\"))] | sort_by(.ref) | reverse | first.ref | sub(\"refs/tags/\"; \"\") // empty") if [[ -z $version && $COMBINED -eq 0 ]]; then echo "No tags found for branch: $branch in repository: $repo" >&2 exit 1 fi msg "Found branch tag: $version" echo "$version" } # Fetches the latest Git tag (regardless of branch) get_latest_git_tag() { local repo="$1" local api_url="${GITHUB_API_URL}/${repo}/git/refs/tags" msg "Fetching latest Git tag from: $api_url" local json_response json_response=$(api_request "$api_url") local version version=$(echo "$json_response" \ | jq -r '[.[] | select(.ref | startswith("refs/tags/"))] | sort_by(.ref) | reverse | first.ref | sub("refs/tags/"; "") // empty') if [[ -z $version && $COMBINED -eq 0 ]]; then echo "No Git tags found in repository: $repo" >&2 exit 1 fi msg "Found Git tag: $version" echo "$version" } # Fetches the latest commit SHA from the specified branch get_latest_commit() { local repo="$1" local branch="${BRANCH:-main}" local api_url="${GITHUB_API_URL}/${repo}/commits/$branch" msg "Fetching latest commit SHA from: $api_url" local json_response json_response=$(api_request "$api_url") local sha sha=$(echo "$json_response" | jq -r '.sha // empty') if [[ -z $sha && $COMBINED -eq 0 ]]; then echo "Failed to fetch latest commit SHA for branch: $branch in repository: $repo" >&2 exit 1 fi msg "Found commit SHA: $sha" echo "$sha" } # Format combined text output format_combined_text() { local repo="$1" local branch="$2" local tag="$3" local commit="$4" local release="$5" local prerelease="$6" echo "Repository: $repo" if [[ -n $branch ]]; then echo "Branch: $branch" fi if [[ -n $tag ]]; then echo "Git Tag: $tag" fi if [[ -n $commit ]]; then echo "Commit: $commit" fi if [[ -n $prerelease ]]; then echo "Prerelease: $prerelease" fi if [[ -n $release ]]; then echo "Release: $release" fi } # Format combined JSON output format_combined_json() { local repo="$1" local branch="$2" local tag="$3" local commit="$4" local release="$5" local prerelease="$6" local json="{" json+="\"repository\":\"$repo\"" if [[ -n $branch ]]; then json+=",\"branch\":\"$branch\"" fi if [[ -n $tag ]]; then json+=",\"tag\":\"$tag\"" fi if [[ -n $commit ]]; then json+=",\"commit\":\"$commit\"" fi if [[ -n $prerelease ]]; then json+=",\"prerelease\":\"$prerelease\"" fi if [[ -n $release ]]; then json+=",\"release\":\"$release\"" fi json+="}" echo "$json" } # Parse command line arguments parse_arguments() { # If no arguments provided, show usage if [[ $# -eq 0 ]]; then usage fi # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in -h | --help) SHOW_HELP=1 shift ;; -v | --verbose) VERBOSE=1 shift ;; -p | --prereleases) INCLUDE_PRERELEASES=1 shift ;; -o | --oldest) OLDEST_RELEASE=1 shift ;; -b | --branch) if [[ $# -lt 2 ]]; then echo "Error: --branch option requires a branch name" >&2 exit 1 fi BRANCH="$2" shift 2 ;; -c | --commit) LATEST_COMMIT=1 shift ;; -t | --tag) LATEST_TAG=1 shift ;; -j | --json) OUTPUT="json" shift ;; -a | --all) COMBINED=1 shift ;; -*) echo "Error: Unknown option: $1" >&2 usage ;; *) # If repository is already set, this is an error if [[ -n $REPOSITORY ]]; then echo "Error: Unexpected argument: $1" >&2 usage fi REPOSITORY="$1" shift ;; esac done # Validate that we have a repository if [[ -z $REPOSITORY && $SHOW_HELP -eq 0 ]]; then echo "Error: Repository argument is required" >&2 usage fi } # Main function main() { # Parse command line arguments parse_arguments "$@" # Show help if requested if [[ $SHOW_HELP -eq 1 ]]; then usage fi check_dependencies # Check rate limits before making other API calls check_rate_limits # Validate repository existence and get normalized repository name local repo_fullname repo_fullname=$(check_repository "$REPOSITORY") # If --all specified, get all information types if [[ $COMBINED -eq 1 ]]; then local branch="${BRANCH:-main}" local git_tag="" local commit_sha="" local release_version="" local prerelease_version="" # Get Git tag if requested git_tag=$(get_latest_git_tag "$repo_fullname") # Get commit SHA commit_sha=$(get_latest_commit "$repo_fullname") # Get release versions (stable and prerelease) read -r release_version prerelease_version < <(get_release_version "$repo_fullname") # Format output based on selected format if [[ $OUTPUT == "json" ]]; then format_combined_json \ "$repo_fullname" \ "$branch" \ "$git_tag" \ "$commit_sha" \ "$release_version" \ "$prerelease_version" else format_combined_text \ "$repo_fullname" \ "$branch" \ "$git_tag" \ "$commit_sha" \ "$release_version" \ "$prerelease_version" fi exit 0 fi # Not combined mode - get only the requested information type local result="" if [[ $LATEST_COMMIT -eq 1 ]]; then result=$(get_latest_commit "$repo_fullname") elif [[ $LATEST_TAG -eq 1 ]]; then result=$(get_latest_git_tag "$repo_fullname") elif [[ -n $BRANCH ]]; then result=$(get_latest_branch_tag "$repo_fullname") else result=$(get_release_version "$repo_fullname") fi # Output the result in the requested format if [[ $OUTPUT == "json" ]]; then echo "{\"repository\": \"$repo_fullname\", \"result\": \"$result\"}" else echo "$result" fi } main "$@" # vim: set ts=2 sw=2 ft=sh et: