#!/bin/bash # Usage: # ./monitor.sh --init # ./monitor.sh --dry-run [--verbose] # ./monitor.sh --test-telegram # ./monitor.sh [--include-prereleases] set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${ENV_FILE:-${SCRIPT_DIR}/.env}" DRY_RUN=false INIT=false VERBOSE=false TEST_TELEGRAM=false CLI_PRERELEASES=false GITHUB_API="https://api.github.com" TELEGRAM_API="https://api.telegram.org" log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "$2" >&2; } die() { log ERROR "$1"; exit 1; } usage() { cat <<'HELP' Usage: ./monitor.sh [OPTIONS] --init Set baseline without notifications --dry-run Show changes without sending or saving --test-telegram Send one test message; do not query GitHub --include-prereleases Include prereleases --verbose Enable debug logging --help Show help HELP } for arg in "$@"; do case "$arg" in --init) INIT=true ;; --dry-run) DRY_RUN=true ;; --test-telegram) TEST_TELEGRAM=true ;; --include-prereleases) CLI_PRERELEASES=true ;; --verbose) VERBOSE=true ;; --help) usage; exit 0 ;; *) die "Unknown argument: $arg" ;; esac done if [[ "$INIT" == true && "$DRY_RUN" == true ]]; then die "--init and --dry-run cannot be combined"; fi if [[ "$TEST_TELEGRAM" == true && ( "$INIT" == true || "$DRY_RUN" == true ) ]]; then die "--test-telegram cannot be combined with --init or --dry-run" fi [[ -f "$ENV_FILE" ]] || die "Missing configuration: $ENV_FILE" set -a # Trusted local shell configuration. Never source untrusted .env files. source "$ENV_FILE" set +a : "${TELEGRAM_BOT_TOKEN:=}" : "${TELEGRAM_CHAT_ID:=}" : "${GITHUB_TOKEN:=}" : "${SOCKS5_PROXY:=}" : "${REQUEST_TIMEOUT:=30}" : "${GITHUB_PER_PAGE:=100}" : "${GITHUB_MAX_PAGES:=3}" : "${REPOS_FILE:=repos.json}" : "${STATE_FILE:=state.json}" : "${INCLUDE_PRERELEASES:=false}" : "${TELEGRAM_INCLUDE_CHANGELOG:=true}" : "${TELEGRAM_CHANGELOG_MAX_LENGTH:=1800}" : "${STATE_MAX_IDS:=500}" if [[ "$CLI_PRERELEASES" == true ]]; then INCLUDE_PRERELEASES=true; fi resolve_path() { if [[ "$1" == /* ]]; then printf '%s\n' "$1"; else printf '%s/%s\n' "$SCRIPT_DIR" "$1"; fi; } REPOS_FILE="$(resolve_path "$REPOS_FILE")" STATE_FILE="$(resolve_path "$STATE_FILE")" for cmd in curl jq flock mktemp; do command -v "$cmd" >/dev/null 2>&1 || die "Missing dependency: $cmd"; done [[ "$REQUEST_TIMEOUT" =~ ^[1-9][0-9]*$ ]] || die "Invalid REQUEST_TIMEOUT" [[ "$GITHUB_PER_PAGE" =~ ^[1-9][0-9]*$ ]] || die "Invalid GITHUB_PER_PAGE" (( GITHUB_PER_PAGE <= 100 )) || die "GITHUB_PER_PAGE must be <= 100" [[ "$GITHUB_MAX_PAGES" =~ ^[1-9][0-9]*$ ]] || die "Invalid GITHUB_MAX_PAGES" [[ "$INCLUDE_PRERELEASES" == true || "$INCLUDE_PRERELEASES" == false ]] || die "Invalid INCLUDE_PRERELEASES" for var in TELEGRAM_CHANGELOG_MAX_LENGTH STATE_MAX_IDS; do [[ "${!var}" =~ ^[1-9][0-9]*$ ]] || die "Invalid $var" done [[ "$TELEGRAM_INCLUDE_CHANGELOG" == true || "$TELEGRAM_INCLUDE_CHANGELOG" == false ]] || die 'Invalid TELEGRAM_INCLUDE_CHANGELOG' if [[ "$TEST_TELEGRAM" == true || ( "$INIT" == false && "$DRY_RUN" == false ) ]]; then [[ -n "$TELEGRAM_BOT_TOKEN" && -n "$TELEGRAM_CHAT_ID" ]] || die "Telegram credentials are missing" fi umask 077 CURL_OPTIONS=(--silent --show-error --fail --location --connect-timeout 10 --max-time "$REQUEST_TIMEOUT") if [[ -n "$SOCKS5_PROXY" ]]; then CURL_OPTIONS+=(--proxy "$SOCKS5_PROXY"); fi GITHUB_CURL_OPTIONS=(--silent --show-error --location --connect-timeout 10 --max-time "$REQUEST_TIMEOUT") if [[ -n "$SOCKS5_PROXY" ]]; then GITHUB_CURL_OPTIONS+=(--proxy "$SOCKS5_PROXY"); fi GITHUB_HEADERS=(-H 'Accept: application/vnd.github+json' -H 'X-GitHub-Api-Version: 2022-11-28' -H 'User-Agent: github-release-monitor') if [[ -n "$GITHUB_TOKEN" ]]; then GITHUB_HEADERS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}"); fi debug() { if [[ "$VERBOSE" == true ]]; then log DEBUG "$1"; fi; } send_telegram() { local message="$1" response # Do not retry POST: retries can create duplicate Telegram messages. response="$(curl "${CURL_OPTIONS[@]}" --request POST \ --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ --data-urlencode "text=${message}" \ --data-urlencode 'disable_web_page_preview=true' \ "${TELEGRAM_API}/bot${TELEGRAM_BOT_TOKEN}/sendMessage")" || return 1 jq -e '.ok == true' <<< "$response" >/dev/null } test_telegram() { local message message="$(printf 'GitHub Release Monitor: Test notification\nHost: %s\nTime: %s\nStatus: Telegram connection successful' "$(hostname)" "$(date '+%Y-%m-%d %H:%M:%S %Z')")" if [[ "$TELEGRAM_INCLUDE_CHANGELOG" == true ]]; then message+=$'\n\nChangelog:\nTest changelog rendering enabled.' fi log INFO 'Sending test Telegram notification' if send_telegram "$message"; then log INFO 'Test notification sent'; else log ERROR 'Test notification failed'; return 1; fi } fetch_releases() { local repo="$1" page count response_file releases_file headers_file http_code retry_after remaining reset response_file="$(mktemp)" || return 1 releases_file="$(mktemp)" || { rm -f "$response_file"; return 1; } headers_file="$(mktemp)" || { rm -f "$response_file" "$releases_file"; return 1; } for ((page=1; page<=GITHUB_MAX_PAGES; page++)); do debug "Fetching $repo page=$page" if ! http_code="$(curl "${GITHUB_CURL_OPTIONS[@]}" "${GITHUB_HEADERS[@]}" \ --dump-header "$headers_file" --write-out '%{http_code}' \ --output "$response_file" \ "${GITHUB_API}/repos/${repo}/releases?per_page=${GITHUB_PER_PAGE}&page=${page}")"; then rm -f "$response_file" "$releases_file" "$headers_file" return 1 fi if [[ "$http_code" != 200 ]]; then retry_after="$(awk 'BEGIN{IGNORECASE=1} tolower($1)=="retry-after:" {gsub("\r", "", $2); print $2}' "$headers_file" | tail -n 1)" remaining="$(awk 'tolower($1)=="x-ratelimit-remaining:" {gsub("\r", "", $2); print $2}' "$headers_file" | tail -n 1)" reset="$(awk 'tolower($1)=="x-ratelimit-reset:" {gsub("\r", "", $2); print $2}' "$headers_file" | tail -n 1)" log ERROR "GitHub HTTP $http_code for $repo; rate_remaining=${remaining:-unknown}; rate_reset=${reset:-unknown}; retry_after=${retry_after:-unknown}" rm -f "$response_file" "$releases_file" "$headers_file" return 1 fi if ! count="$(jq -er 'if type == "array" then length else error("Invalid releases response") end' "$response_file")"; then rm -f "$response_file" "$releases_file" "$headers_file" return 1 fi if ! jq -c --argjson prereleases "$INCLUDE_PRERELEASES" ' .[] | select(.draft == false) | select($prereleases or (.prerelease == false)) | {id: (.id | tostring), tag: .tag_name, name: .name, url: .html_url, published: .published_at, prerelease: .prerelease, body: (.body // "")} ' "$response_file" >> "$releases_file"; then rm -f "$response_file" "$releases_file" "$headers_file" return 1 fi if (( count < GITHUB_PER_PAGE )); then break; fi done local status=0 jq -sc '.' "$releases_file" || status=$? rm -f "$response_file" "$releases_file" "$headers_file" return "$status" } write_state() { local repo="$1" ids="$2" mode="$3" temp temp="$(mktemp "${STATE_FILE}.tmp.XXXXXX")" || return 1 if ! jq --arg repo "$repo" --arg mode "$mode" --argjson limit "$STATE_MAX_IDS" --slurpfile ids /dev/stdin ' ($ids[0] // []) as $new_ids | if $mode == "replace" then .[$repo] = ($new_ids | unique | .[-$limit:]) else .[$repo] = ((.[$repo] // []) + $new_ids | unique | .[-$limit:]) end ' "$STATE_FILE" <<< "$ids" > "$temp"; then rm -f "$temp"; return 1; fi if ! mv -f "$temp" "$STATE_FILE"; then rm -f "$temp"; return 1; fi } format_message() { local repo="$1" release="$2" tag name url published kind body message allowance tag="$(jq -r '.tag // "unknown"' <<< "$release")" name="$(jq -r '.name // empty' <<< "$release")" url="$(jq -r '.url // empty' <<< "$release")" published="$(jq -r '.published // "unknown"' <<< "$release")" [[ -n "$name" ]] || name="$tag" kind=Release if [[ "$(jq -r '.prerelease' <<< "$release")" == true ]]; then kind=Prerelease; fi message="$(printf 'GitHub: New %s\nRepository: %s\nVersion: %s\nName: %s\nPublished: %s' \ "$kind" "$repo" "$tag" "$name" "$published")" if [[ "$TELEGRAM_INCLUDE_CHANGELOG" == true ]]; then body="$(jq -r '.body // ""' <<< "$release" | sed -E 's/<[^>]*>/ /g; s/!\[[^]]*\]\([^)]*\)//g')" allowance=$((4096 - ${#message} - ${#url} - 24)) if (( allowance > TELEGRAM_CHANGELOG_MAX_LENGTH )); then allowance="$TELEGRAM_CHANGELOG_MAX_LENGTH"; fi if (( allowance > 0 )) && [[ -n "$body" ]]; then body="$(printf '%s' "$body" | jq -Rs --argjson max "$allowance" '.[:$max]' -r)" message+="$(printf '\n\nChangelog:\n%s' "$body")" fi fi message+="$(printf '\n\n%s' "$url")" # Telegram limit is 4096 Unicode characters, not bytes. printf '%s' "$message" | jq -Rs '.[:4096]' -r } process_repo() { local repo="$1" releases ids previous new_releases count release id tag message log INFO "Checking $repo" releases="$(fetch_releases "$repo")" || { log ERROR "GitHub API failed for $repo"; return 1; } ids="$(jq -c '[.[].id] | unique' <<< "$releases")" count="$(jq 'length' <<< "$releases")" if [[ -f "$STATE_FILE" ]]; then previous="$(jq -c --arg repo "$repo" '.[$repo] // null' "$STATE_FILE")" else previous=null fi if [[ "$INIT" == true || "$previous" == null ]]; then if [[ "$DRY_RUN" == true ]]; then log INFO "[DRY-RUN] Baseline: $repo ($count releases)" jq -r 'reverse[] | " " + (.tag // "unknown")' <<< "$releases" return 0 fi write_state "$repo" "$ids" replace || return 1 log INFO "Baseline initialized: $repo ($count releases)" return 0 fi # Read the existing state from a file, not from argv (ARG_MAX). local state_source="$STATE_FILE" [[ -f "$state_source" ]] || state_source=/dev/null new_releases="$(jq -c --slurpfile state "$state_source" --arg repo "$repo" ' ($state[0][$repo] // []) as $seen | [.[] | select(.id as $id | $seen | index($id) | not)] | reverse ' <<< "$releases")" || return 1 count="$(jq 'length' <<< "$new_releases")" if (( count == 0 )); then log INFO "No new releases: $repo"; return 0; fi log INFO "Found $count new releases: $repo" while IFS= read -r release; do [[ -n "$release" ]] || continue id="$(jq -r '.id' <<< "$release")" tag="$(jq -r '.tag' <<< "$release")" message="$(format_message "$repo" "$release")" if [[ "$DRY_RUN" == true ]]; then log INFO "[DRY-RUN] $repo $tag" printf '%s\n\n' "$message" continue fi if ! send_telegram "$message"; then log ERROR "Telegram send failed: $repo $tag"; return 1; fi write_state "$repo" "[\"$id\"]" append || return 1 log INFO "Notification sent: $repo $tag" done < <(jq -c '.[]' <<< "$new_releases") } main() { local url repo failures=0 [[ -f "$REPOS_FILE" ]] || die "Missing repos file: $REPOS_FILE" jq -e '.repositories | type == "array" and length > 0' "$REPOS_FILE" >/dev/null || die 'Invalid repositories configuration' jq -e '.repositories | all(.[]; type == "string")' "$REPOS_FILE" >/dev/null || die 'Repository URLs must be strings' mkdir -p "$(dirname "$STATE_FILE")" exec 9>"${STATE_FILE}.lock" flock -n 9 || die 'Another instance is running' if [[ -f "$STATE_FILE" ]]; then jq -e 'type == "object" and all(.[]; type == "array" and all(.[]; type == "string"))' "$STATE_FILE" >/dev/null || die 'Invalid state file' elif [[ "$DRY_RUN" == false ]]; then printf '{}\n' > "$STATE_FILE" fi while IFS= read -r url; do [[ -n "$url" ]] || continue if [[ "$url" =~ ^https://(www\.)?github\.com/ ]]; then url="${url#*github.com/}" fi url="${url%/}" url="${url%.git}" if [[ ! "$url" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then log ERROR "Invalid repository: $url" failures=$((failures + 1)) continue fi repo="$url" if ! process_repo "$repo"; then failures=$((failures + 1)); fi done < <(jq -r '.repositories[]' "$REPOS_FILE" | sort -u) if (( failures > 0 )); then log ERROR "Failed repositories: $failures"; return 1; fi log INFO 'Monitoring completed' } if [[ "$TEST_TELEGRAM" == true ]]; then test_telegram; else main; fi