265 lines
13 KiB
Bash
Executable File
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
|