Generic RSS → Discord worker for GitHub Actions. No server, no database — commits are the state, the GitHub API is the read interface.
- One reusable workflow (
.github/workflows/rss.yml) your consumer repo calls withuses:. - A Python worker (
src/rss.py) that diffs an RSS feed against a committedstate.jsonand posts new items to Discord webhooks. - Per-feed dedup via stable feed GUIDs. State is committed to your consumer repo, not this one.
This repo is a GitHub Template Repository. Hit "Use this template" to create your own consumer repo, then add this workflow call to it:
# your-feeds/.github/workflows/rss.yml
name: RSS → Discord
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
poll:
uses: echohello-dev/feedhub/.github/workflows/rss.yml@main
with:
feeds-path: feeds.json
state-path: state.json
secrets:
discord-webhook: ${{ secrets.DISCORD_WEBHOOK }}Drop a feeds.json next to it (see examples/feeds.example.json), set the DISCORD_WEBHOOK repo secret, push, done.
The git-as-infra pattern (Upptime, stargazers-action) substitutes four GitHub primitives for a server:
| Primitive | Here it is |
|---|---|
on: schedule workflow |
The poller (cron in consumer repo) |
| Commits in the consumer repo | state.json — seen GUIDs, bounded history |
Reusable workflow (workflow_call) |
The job definition, versioned by tag |
| The GitHub API | Implicit read interface for the state file |
Total cost: $0 for public consumer repos, free within Actions minutes for private repos at ≥ 15-min cadence.
feeds.json is a JSON array of feed objects. Each entry:
webhook_secret overrides the workflow-level secrets.discord-webhook if set, which lets one consumer repo post to multiple Discord channels with different webhooks.
When the worker first runs against a feed, every item in the feed's max_items window is "new". On a busy feed like OpenRouter that's ~338 items.
To suppress the flood on first run:
- Add the workflow to your consumer repo.
- Trigger it once with
webhook_secretset to a dummy name (or no secret set). Items get marked as seen instate.json, nothing gets posted. - Set the real
DISCORD_WEBHOOK_*secret. - Trigger again. Only new items from this point forward get posted.
Alternatively, the simpler path: set the real webhook secret before the first run and let it spam your channel once. Delete the messages, move on.
| Input | Default | Description |
|---|---|---|
feeds-path |
feeds.json |
Path to feed config (relative to consumer repo root) |
state-path |
state.json |
Path to state file (relative to consumer repo root) |
| Secret | Description |
|---|---|
discord-webhook |
Fallback Discord webhook URL when a feed entry doesn't specify its own |
uses: echohello-dev/feedhub/.github/workflows/rss.yml@main is fine for tinkering. For anything that matters, pin to a tag or commit SHA so updates are deliberate, not surprising.
See examples/feeds.example.json for the full shape with comments-as-placeholders.
Working consumer: echohello-dev/feeds (private — used to monitor OpenRouter's new models feed).
MIT. See LICENSE.
{ "name": "OpenRouter - New Models", // required, label in Discord "url": "https://...rss", // required, feed URL "webhook_secret": "DISCORD_WEBHOOK_X", // optional, env var name "username": "OpenRouter", // optional, webhook username override "avatar_url": "https://...favicon.ico", // optional "color": 6143855, // optional, embed color decimal "enabled": true, // optional, default true "max_items": 50, // optional, items per poll "history_cap": 5000, // optional, GUIDs retained "description_limit": 350 // optional, embed desc char cap }