Skip to main content

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"