commit aa4c241f9965eb7135661ec0d61606f1caa71a6e Author: latypov Date: Sun Oct 11 20:53:40 2026 +0700 Init files diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3adddef --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +GITHUB_TOKEN= +SOCKS5_PROXY=socks5h://127.0.0.1:1080 +REQUEST_TIMEOUT=30 +GITHUB_PER_PAGE=100 +GITHUB_MAX_PAGES=3 +REPOS_FILE=repos.json +STATE_FILE=state.json +INCLUDE_PRERELEASES=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a33585 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +state.json +state.json.lock +state.json.tmp.* +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e695b1a --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# GitHub Release Monitor + +Bash monitor for GitHub releases, with Telegram notifications and SOCKS5 proxy support. + +## Requirements + +- Bash 4+ +- curl with SOCKS5 support +- jq +- flock (util-linux) +- mktemp (coreutils) + +Debian: + +```bash +sudo apt-get install curl jq ca-certificates util-linux +``` + +## Setup + +```bash +cp .env.example .env +chmod 600 .env +chmod +x monitor.sh +``` + +Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` in `.env`. `GITHUB_TOKEN` is optional for public repositories. `SOCKS5_PROXY` can be set to `socks5h://127.0.0.1:1080`; `socks5h` resolves DNS through the proxy. The `.env` file is sourced as Bash: use only trusted local configuration. + +Edit `repos.json` to select GitHub repositories. Initial test set: Lychee, Firefly III and Memos. + +## Commands + +```bash +./monitor.sh --test-telegram +./monitor.sh --dry-run --verbose +./monitor.sh --init +./monitor.sh +./monitor.sh --include-prereleases +``` + +`--test-telegram` sends one Telegram test message without querying GitHub or accessing `state.json`/`repos.json`. + +`--init` records current releases without notifications; it **replaces** existing baselines. Use only for first initialization or an intentional reset. Normal first run also initializes missing repositories silently. + +`--dry-run` queries GitHub and displays pending notifications without sending or changing state. On a repository without baseline it prints its current release tags. + +`--include-prereleases` overrides `INCLUDE_PRERELEASES=false`. To track prereleases continuously, set `INCLUDE_PRERELEASES=true` in `.env`. + +State is written atomically after each successful Telegram notification. If Telegram accepted a message but its response was lost, a duplicate may be sent next time. The state contains release IDs and grows over time. A bounded GitHub pagination window (`GITHUB_PER_PAGE * GITHUB_MAX_PAGES`) may miss releases if more are published between checks. For small projects and daily checks this is generally sufficient. + +## Daily cron + +At 09:00 server-local time: + +```cron +0 9 * * * cd /opt/github-release-monitor && ./monitor.sh >> monitor.log 2>&1 +``` + +Run the monitor under a dedicated user where possible. Keep `.env` private and do not commit it. Use `./monitor.sh --test-telegram` to verify Telegram and proxy connectivity. diff --git a/monitor.sh b/monitor.sh new file mode 100644 index 0000000..cfc5f4e --- /dev/null +++ b/monitor.sh @@ -0,0 +1,200 @@ +#!/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 diff --git a/repos.json b/repos.json new file mode 100644 index 0000000..45e4e48 --- /dev/null +++ b/repos.json @@ -0,0 +1,7 @@ +{ + "repositories": [ + "https://github.com/LycheeOrg/Lychee", + "https://github.com/firefly-iii/firefly-iii", + "https://github.com/usememos/memos" + ] +}