GitHub Release Watcher to Discord Forum
This is a webhook script that will look at a list of repos that's housed on a container in my PM host and post release notifications to a Discord forum, and keeps them organized by thread/topic. No tags, as tags must be manually created.
Discord Webhook
Once you have the forum created in your Discord server, go it it's settings and create an integration for a webhook. Give it a cool name and copy the URL, you will need it for the stuff below.
Proxmox Host Container
I used a Debian 12 LXC for housing scripts that don't belong to specific apps (for example, I have a script running in my reverse proxy container that posts to Discord, but isnt in this container).
Update Container
apt update
apt install -y python3 python3-venv python3-pip git curl
Create Directories
mkdir -p /opt/release-watch
cd /opt/release-watch
python3 -m venv .venv
source .venv/bin/activate
pip install requests python-dotenv
Config Files & Locations
- Script folder:
/opt/release-watch/
- Repo list:
/opt/release-watch/repos.txt
- Config:
/opt/release-watch/.env
- State file:
/opt/release-watch/state.json
(auto-maintained by the script) - Systemd Service:
/etc/systemd/system/release-watch.service
- Systemd Timer:
/etc/systemd/system/release-watch.timer
- The Script:
/opt/release-watch/release-watch.py
Repo List
/opt/release-watch/repos.txt
The list of repos to monitor should be one per line, and in format owner/repo
BookStackApp/BookStack
traccar/traccar
mealie-recipes/mealie
jellyfin/jellyfin
goauthentik/authentik
nextcloud/server
umami-software/umami
qBittorrent/qBittorrent
radarr/radarr
Sonarr/Sonarr
lidarr/Lidarr
Readarr/Readarr
Prowlarr/Prowlarr
Environment File
/opt/release-watch/.env
You'll need some env variables, most notably the webhook URL of your forum
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/XXXXXXXX/XXXXXXXX
# Optional, recommended: personal GitHub token to avoid rate limits
# GITHUB_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXX
State File
/opt/release-watch/state.json
The state file is maintained by the script and used to keep track of the things, no need to alter this unless you want to wipe it and start fresh
{}
Systemd Service
/etc/systemd/system/release-watch.service
[Unit]
Description=GitHub Release Watcher -> Discord
[Service]
Type=oneshot
WorkingDirectory=/opt/release-watch
Environment="PATH=/opt/release-watch/.venv/bin:/usr/bin"
ExecStart=/opt/release-watch/release_watch.py
Systemd Timer
/etc/systemd/system/release-watch.timer
[Unit]
Description=Run release-watch weekly
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
Unit=release-watch.service
[Install]
WantedBy=timers.target
The Script
/opt/release-watch/release_watch.py
This is the actual script that will look at the repo list and post to the forum
#!/usr/bin/env python3
import json, os, time, typing, requests, traceback
from pathlib import Path
from dotenv import load_dotenv
BASE = Path(__file__).resolve().parent
STATE_FILE = BASE / "state.json"
REPO_FILE = BASE / "repos.txt"
load_dotenv(BASE / ".env")
WEBHOOK = os.environ.get("DISCORD_WEBHOOK_URL", "").strip()
TOKEN = os.environ.get("GITHUB_TOKEN", "").strip()
SESSION = requests.Session()
if TOKEN:
SESSION.headers.update({"Authorization": f"Bearer {TOKEN}"})
SESSION.headers.update({"Accept": "application/vnd.github+json",
"User-Agent": "release-watch/1.0"})
def load_state() -> dict:
if STATE_FILE.exists():
try: return json.loads(STATE_FILE.read_text())
except: pass
return {}
def save_state(data: dict):
STATE_FILE.write_text(json.dumps(data, indent=2, sort_keys=True))
def read_repos() -> list[str]:
if not REPO_FILE.exists(): return []
return [line.strip() for line in REPO_FILE.read_text().splitlines()
if line.strip() and not line.strip().startswith("#")]
def latest_release(owner_repo: str) -> typing.Optional[dict]:
owner, repo = owner_repo.split("/", 1)
r = SESSION.get(f"https://api.github.com/repos/{owner}/{repo}/releases/latest", timeout=20)
if r.status_code == 404:
r2 = SESSION.get(f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=1", timeout=20)
r2.raise_for_status()
arr = r2.json()
if not arr: return None
rel = arr[0]
else:
r.raise_for_status()
rel = r.json()
return {
"tag": rel.get("tag_name") or "",
"name": rel.get("name") or rel.get("tag_name") or "",
"url": rel.get("html_url") or "",
"published_at": rel.get("published_at") or "",
"is_prerelease": bool(rel.get("prerelease")),
"draft": bool(rel.get("draft")),
}
def ensure_thread(owner_repo: str, state: dict) -> str:
repo_state = state.setdefault(owner_repo, {})
thread_id = repo_state.get("thread_id")
if thread_id: return thread_id
payload = {
"content": f"Tracking **{owner_repo}** releases in this thread. Updates will be posted here.",
"thread_name": owner_repo,
"allowed_mentions": {"parse": []}
}
resp = requests.post(f"{WEBHOOK}?wait=true", json=payload, timeout=20)
resp.raise_for_status()
msg = resp.json()
thread_id = str(msg.get("channel_id") or "")
repo_state["thread_id"] = thread_id
save_state(state)
return thread_id
def post_to_thread(thread_id: str, content: str):
url = WEBHOOK
if thread_id: url = f"{WEBHOOK}?thread_id={thread_id}"
requests.post(url, json={
"content": content,
"allowed_mentions": {"parse": []}
}, timeout=20).raise_for_status()
def format_release_msg(owner_repo: str, rel: dict) -> str:
pre = " (pre-release)" if rel.get("is_prerelease") else ""
if rel.get("draft"): pre += " (draft)"
published = rel.get("published_at") or "unknown date"
return (f"**{owner_repo}** — **{rel.get('name')}**{pre}\n"
f"Tag: `{rel.get('tag')}` • Published: {published}\n"
f"{rel.get('url')}")
def main():
if not WEBHOOK: raise SystemExit("DISCORD_WEBHOOK_URL missing in .env")
state = load_state()
repos = read_repos()
if not repos: print("No repos in repos.txt"); return
for owner_repo in repos:
try:
rel = latest_release(owner_repo)
if not rel: continue
repo_state = state.setdefault(owner_repo, {})
last_tag = repo_state.get("last_tag")
if rel["tag"] and rel["tag"] != last_tag:
thread_id = ensure_thread(owner_repo, state)
msg = format_release_msg(owner_repo, rel)
post_to_thread(thread_id, msg)
repo_state.update({"last_tag": rel["tag"],
"last_published": rel.get("published_at"),
"last_url": rel.get("url")})
save_state(state)
time.sleep(0.4)
except Exception as e:
print(f"[WARN] {owner_repo}: {e}")
traceback.print_exc(limit=1)
time.sleep(1)
if __name__ == "__main__":
main()
Make it Executable
chmod +x /opt/release-watch/release_watch.py
First Run
This will create one thread per repo in the forum, and posts the current latest release in each, while storing the progress in the state.json
file. Depending on the amount of repos in the list, this might take a couple of minutes - let it do it's thing.
cd /opt/release-watch
source .venv/bin/activate
./release_watch.py
Enable Start and Timer
systemctl daemon-reload
systemctl enable --now release-watch.timer
systemctl list-timers | grep release-watch
Pro Tips
If you ever delete the state.json file, the script will re-post the current release for each one (great for a fresh start or restart)
You can manually test the webhook with this:
curl -H "Content-Type: application/json" \
-d '{"content":"Webhook OK"}' \
"$DISCORD_WEBHOOK_URL"