Files

265 lines
13 KiB
Bash
Executable File

#!/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