A long-running Rust daemon that polls a configurable list of GitHub repos for new stable releases and sends plain-text email notifications via SMTP when one appears. Built for homelab use; ships as a Docker/podman container.
- Polls
GET /repos/{owner}/{repo}/releases/latestfor each configured repo on a configurable schedule: a fixed interval (default 1h) or an optional cron expression for precise timing control. - Compares the latest stable tag to the last-seen tag stored in a JSON state file.
- On a new release, sends a plain-text email (repo, tag, name, published date, URL, release notes) to a configurable recipient list via SMTP.
- On first run for a repo with no stored tag, records the current latest tag without emailing (so deploying the service doesn't spam you about the current release).
- On SMTP failure, leaves state untouched so the email retries next tick.
- Optional GitHub token for higher API rate limits (60 req/hour/IP unauthenticated, 5000 req/hour with token).
- Graceful shutdown on SIGINT/SIGTERM.
Copy the sample config and edit:
cp config.example.toml config.tomlpoll_interval_seconds = 3600
state_path = "/state/state.json" # /state/state.json for Docker, ./state.json for local runs
sender = "gh-release-notify@homelab.local"
repos = ["fosrl/pangolin", "fosrl/newt"]
recipients = ["you@example.com"]
# Optional: cron expression (takes precedence over poll_interval_seconds).
# Standard 5-field: minute hour day-of-month month day-of-week.
# Timezone is UTC. Day-of-week: 1=Sunday .. 7=Saturday.
# cron_expression = "0 */6 * * *"
[smtp]
host = "smtp.example.com"
port = 587
encryption = "starttls" # "starttls" (587) | "tls" (465) | "none" (25)
username = "postmaster@example.com"
password = "changeme" # prefer SMTP_PASSWORD env var instead| Variable | Purpose | Default |
|---|---|---|
CONFIG_PATH |
Path to the config file (also settable via --config CLI arg). |
./config.toml |
GITHUB_TOKEN |
Optional GitHub PAT. If set, sent as Authorization: Bearer. |
(unset) |
SMTP_PASSWORD |
Overrides [smtp].password. Keeps the secret out of the file. |
(unset) |
RUST_LOG |
tracing filter directive (info, debug, gh_release_notify=debug). |
info |
Copy .env.example to .env and fill in secrets:
cp .env.example .envGITHUB_TOKEN=ghp_xxx
SMTP_PASSWORD=your-smtp-password
cargo run --release -- --config ./config.tomlFor a quick smoke test set poll_interval_seconds = 120 in the config and watch the logs.
Build the image (use docker if available, otherwise podman):
docker build -t gh-release-notify:latest .
# or, if docker is not available:
podman build -t gh-release-notify:latest .Run with docker-compose.yml (mounts config.toml read-only and a ./state directory for persistence). Use whichever compose implementation you have available:
docker compose up -d # docker compose plugin
# or:
podman compose up -d # podman compose
# or:
docker-compose up -d # standalone docker-composeState persists across container restarts via the ./state volume. On container recreation the state file survives, so you won't get a first-run notification burst for already-seen releases.
State directory permissions: the container runs as non-root user ghrel (uid 10001). The mounted ./state directory must be writable by that uid, or you'll see Permission denied (os error 13) on state save. Before first run:
mkdir -p ./state && sudo chown 10001:10001 ./state(If you see failed to write state tmp file ... Permission denied in the logs, this is the fix.)
For each repo, the first time the service sees it (no stored tag in state.json) it records the current latest tag without sending an email. Subsequent new releases trigger an email. Delete state.json to reset.
Structured logs via tracing. Default level info:
polling 2 repos
fetching latest for fosrl/pangolin
no change for fosrl/pangolin (still 1.19.4)
tick complete, sleeping 3600s
New release:
new release detected for fosrl/pangolin: 1.20.0
sent notification to 2 recipients for fosrl/pangolin 1.20.0
state saved to /state/state.json
Rate-limited (403):
github rate-limited, skipping remaining repos this tick: github rate-limited (403) for fosrl/pangolin: ...
rate-limited by github, skipping remaining repos this tick
Set RUST_LOG=debug for more detail.
src/
main.rs CLI, tracing, wire modules, signal handling
config.rs Config + SmtpConfig + Encryption: parse & validate config.toml
github.rs GithubClient + Release + GithubError: fetch latest stable release
state.rs StateStore: JSON-backed last-seen tags (atomic save)
notify.rs Mailer + build_body: plain-text email via SMTP (lettre async)
scheduler.rs run(): poll loop, first-run-no-email, graceful shutdown
config.example.toml sample config (with comments)
.env.example sample env file
Dockerfile multi-stage build (rust:slim -> debian:bookworm-slim)
docker-compose.yml single service, config + state volumes
Verification gate (run before committing):
cargo fmt
cargo clippy -- -D warnings
cargo testRun a focused test while iterating:
cargo test config
cargo test state
cargo test github
cargo test notifyBuild the release binary:
cargo build --release- Spec:
docs/superpowers/specs/2026-07-04-gh-release-notify-design.md - Implementation plan:
docs/superpowers/plans/2026-07-04-gh-release-notify.md - Release-workflow spec:
docs/superpowers/specs/2026-07-05-release-workflow-design.md - Release-workflow plan:
docs/superpowers/plans/2026-07-05-release-workflow.md - Project conventions:
AGENTS.md
Releases are cut manually from the GitHub Actions UI and are fully reproducible — no local tooling or tokens required.
- Go to Actions → release → Run workflow in the GitHub repo.
- Choose the SemVer bump level (
patch,minor, ormajor). - The
releaseworkflow runs the verification gate (cargo fmt,cargo clippy -- -D warnings,cargo test). - The workflow runs
cargo-release, which bumpsCargo.tomlandCargo.lock, commits aschore: release v{version}, tagsv{version}, and pushes tomain. - The workflow builds and pushes a multi-arch (amd64 + arm64) Docker image to GHCR.
- The workflow creates a GitHub Release with auto-generated notes derived from commits since the last tag.
The image is published to ghcr.io/c4mbr0nn3/gh-release-notify with
three tag flavors for each release vX.Y.Z:
:vX.Y.Z— git tag verbatim. Most explicit; use for pinning.:X.Y.Z— SemVer without thevprefix. For tooling that stripsv.:latest— points at the most recent release. Updated only on tag pushes, never onmainbranch pushes.
docker-compose.yml is configured to pull from GHCR by default:
docker compose pull
docker compose up -dTo pin a specific release, edit docker-compose.yml:
image: ghcr.io/c4mbr0nn3/gh-release-notify:v0.1.0For local development builds, use docker compose up --build (the
build: . directive is retained as a fallback).
Licensed under the MIT License.