Files
dotfiles/local/bin/x-gh-get-latest-version

609 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Get latest release version, branch tag, or latest commit from GitHub
# Usage: x-gh-get-latest-version <repo> [options]
# Author: Ismo Vuorinen <https://github.com/ivuorinen> 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 <repo> [options]
Fetches the latest release version, latest branch tag, or latest commit SHA from GitHub.
Arguments:
<repo> 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 <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=<branch> Same as --branch <branch>
- LATEST_COMMIT=1 Same as --commit
- LATEST_TAG=1 Same as --tag
- OUTPUT=json Same as --json
- GITHUB_API_URL=<url> Override GitHub API URL (useful for GitHub Enterprise)
- GITHUB_TOKEN=<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: