201 lines
9.4 KiB
Bash
201 lines
9.4 KiB
Bash
#!/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}"
|
|
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"
|
|
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_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')")"
|
|
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 response count result='[]'
|
|
for ((page=1; page<=GITHUB_MAX_PAGES; page++)); do
|
|
debug "Fetching $repo page=$page"
|
|
response="$(curl "${CURL_OPTIONS[@]}" "${GITHUB_HEADERS[@]}" \
|
|
"${GITHUB_API}/repos/${repo}/releases?per_page=${GITHUB_PER_PAGE}&page=${page}")" || return 1
|
|
jq -e 'type == "array"' <<< "$response" >/dev/null || return 1
|
|
count="$(jq 'length' <<< "$response")"
|
|
result="$(jq -cn --argjson old "$result" --argjson new "$response" '$old + $new')" || return 1
|
|
if (( count < GITHUB_PER_PAGE )); then break; fi
|
|
done
|
|
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}]
|
|
' <<< "$result"
|
|
}
|
|
write_state() {
|
|
local repo="$1" ids="$2" mode="$3" temp
|
|
temp="$(mktemp "${STATE_FILE}.tmp.XXXXXX")" || return 1
|
|
if ! jq --arg repo "$repo" --argjson ids "$ids" --arg mode "$mode" '
|
|
if $mode == "replace" then .[$repo] = $ids
|
|
else .[$repo] = ((.[$repo] // []) + $ids | unique) end
|
|
' "$STATE_FILE" > "$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
|
|
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
|
|
printf 'GitHub: New %s\nRepository: %s\nVersion: %s\nName: %s\nPublished: %s\nURL: %s' \
|
|
"$kind" "$repo" "$tag" "$name" "$published" "$url"
|
|
}
|
|
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
|
|
new_releases="$(jq -c --argjson seen "$previous" '[.[] | 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://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/?$ ]]; then
|
|
log ERROR "Invalid GitHub URL: $url"
|
|
failures=$((failures + 1))
|
|
continue
|
|
fi
|
|
repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
|
|
repo="${repo%.git}"
|
|
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
|