If you depend on a handful of open-source projects, you probably want to know when one of them ships a release. When you administer the repository, a webhook is the right tool, and GitHub's own best-practice guide recommends webhook events over polling. For repositories you don't control, polling the REST API on a schedule is the usual answer, and it can be done cheaply.
This post builds a small standard-library Python script that:
- asks the releases endpoint of each repository once per run,
- sends the
ETagfrom last time, so an unchanged list comes back as304 Not Modifiedwith no body, - keeps a small JSON state file with the release IDs it has already seen,
- stays silent on the first run instead of announcing twenty "new" releases,
- keeps the old state when a request fails, and stops when it is rate limited.
At the end there is a real, unauthenticated run from 27 September 2026 (UTC) and the complete script.
What the documentation says
These points come from GitHub's REST API documentation, read on 27 September 2026:
-
Endpoint:
GET /repos/{owner}/{repo}/releases.per_pagedefaults to 30, maximum 100. It works without authentication for public repositories. Draft releases are only listed for users with push access. - Rate limit: unauthenticated requests get 60 per hour, counted per IP address. A personal access token raises that to 5,000 per hour.
-
Conditional requests: most endpoints return an
etagheader. Send it back inif-none-match; if nothing changed you get304 Not Modified. The docs say a 304 does not count against the primary rate limit if the request was made with anAuthorizationheader. -
Versioning: requests without an
X-GitHub-Api-Versionheader use version2022-11-28. The current version is2026-03-10. Pinning the version keeps a breaking change from reaching a script silently. - Polling etiquette: poll only as often as you need, on a fixed schedule; make requests one after another, not concurrently.
Step 1: build the request
The If-None-Match header is only sent when there is a saved ETag. The token is optional and read from the environment, never from the code.
Excerpt of release_watch.py, lines 14-26:
API = "https://api.github.com/repos/{}/releases?per_page=20"
USER_AGENT = "release-watch-example/1.0 (tutorial script)"
KEEP_IDS = 200 # enough history to recognise releases we already reported
def build_request(url, etag=None):
headers = {"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2026-03-10"}
if etag:
headers["If-None-Match"] = etag
if os.environ.get("GITHUB_TOKEN"):
headers["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"]
return urllib.request.Request(url, headers=headers)
Step 2: treat 304 as an answer, not an error
urllib raises HTTPError for a 304, exactly as it does for a 404 or a 503. Catching it and returning the status keeps the calling code simple: one status code to branch on.
Excerpt of release_watch.py, lines 29-35:
def fetch(url, etag=None):
"""Return (status, headers, JSON body or None). A 304 is a status here, not an exception."""
try:
with urllib.request.urlopen(build_request(url, etag), timeout=30) as response:
return response.status, response.headers, json.loads(response.read())
except urllib.error.HTTPError as err: # urllib raises for 304 and for every 4xx/5xx
return err.code, err.headers, None
Step 3: decide what is new
This is the core of the script. The rules:
- 304: nothing changed, the saved state stays as it is.
- Anything other than 200 or 304 (a 404, a 5xx, a rate limit): keep the saved state untouched. A failed request must never look like "no releases".
- First run for a repository: save every release ID and report nothing. Otherwise the first run would announce the project's whole history.
- Later runs: a release is new when its ID is not in the saved list. Compare IDs, not the fact that the response was a 200: the ETag can change for reasons other than a new release, as the real run below shows.
Excerpt of release_watch.py, lines 38-58:
def summary(release):
return {key: release.get(key) for key in ("tag_name", "name", "published_at", "prerelease", "html_url")}
def check(repo, entry, fetch=fetch):
"""Check one repo against its saved state. Returns (new state entry, report)."""
status, headers, body = fetch(API.format(repo), entry.get("etag"))
report = {"repo": repo, "status": status, "new": [],
"remaining": headers.get("x-ratelimit-remaining") if headers else None}
if status != 200: # 304: nothing changed. Anything else: keep the old state untouched.
return entry, report
releases = [r for r in body if not r.get("draft")]
if "seen" in entry:
seen = set(entry["seen"])
fresh = [r for r in releases if r["id"] not in seen]
report["new"] = [summary(r) for r in sorted(fresh, key=lambda r: r.get("published_at") or "")]
else:
report["baseline"] = len(releases) # first run: remember everything, alert on nothing
current = [r["id"] for r in releases]
older = [i for i in entry.get("seen", []) if i not in set(current)]
return {"etag": headers.get("ETag"), "seen": (current + older)[:KEEP_IDS]}, report
The saved list keeps the newest IDs first and is capped at 200 entries. per_page=20 means the script sees the 20 newest releases per request. If a project can publish more than 20 releases between two runs, raise per_page (up to 100) or follow the pagination links.
Step 4: the run loop and the state file
Requests go out one per second, in order. On a 403 or 429 the run stops rather than pushing on. The state file is written to a temporary file first and then moved into place with os.replace, so an interrupted run cannot leave half a JSON file behind.
Excerpt of release_watch.py, lines 74-97:
def main(repo_file, state_file):
with open(repo_file, encoding="utf-8") as handle:
repos = [line.strip() for line in handle if line.strip() and not line.startswith("#")]
try:
with open(state_file, encoding="utf-8") as handle:
state = json.load(handle)
except FileNotFoundError:
state = {}
print("run at", datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
for number, repo in enumerate(repos):
if number:
time.sleep(1) # serial requests, one per second
state[repo], report = check(repo, state.get(repo, {}))
print(describe(report))
for release in report["new"]:
print(f" NEW {release['tag_name']} published {release['published_at']}"
f"{' (pre-release)' if release['prerelease'] else ''} {release['html_url']}")
if report["status"] in (403, 429): # primary or secondary rate limit: stop, do not push on
print("rate limited; stopping this run (state for earlier repos is still saved)")
break
temporary = state_file + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2)
os.replace(temporary, state_file) # never leave a half-written state file
A network error that isn't an HTTP status (DNS failure, timeout) stops the whole run with a traceback before the state is written, so the previous state file stays intact.
Tests
Nine unittest tests use a fake API object instead of the network. The fake answers 304 when it receives its current ETag, so the conditional-request logic can be tested without sending a single request:
Excerpt of test_release_watch.py, lines 13-26:
class FakeApi:
"""Stands in for fetch(): answers 304 when the caller sends the current ETag."""
def __init__(self, releases, etag='W/"v1"', status=200):
self.releases, self.etag, self.status, self.calls = releases, etag, status, []
def __call__(self, url, etag=None):
self.calls.append(etag)
headers = {"ETag": self.etag, "x-ratelimit-remaining": "59"}
if self.status != 200:
return self.status, headers, None
if etag == self.etag:
return 304, headers, None
return 200, headers, list(self.releases)
Excerpt of test_release_watch.py, lines 36-41:
def test_unchanged_repo_returns_304_and_keeps_state(self):
api = FakeApi([release(1, "v1", "2026-01-01T00:00:00Z")])
entry, _ = check("o/r", {}, fetch=api)
again, report = check("o/r", entry, fetch=api)
self.assertEqual((report["status"], report["new"], again), (304, [], entry))
self.assertEqual(api.calls, [None, 'W/"v1"'])
Excerpt of test_release_watch.py, lines 60-64:
def test_errors_keep_previous_state(self):
previous = {"etag": 'W/"x"', "seen": [1]}
for status in (404, 403, 429, 502):
entry, report = check("o/r", previous, fetch=FakeApi([], status=status))
self.assertEqual((entry, report["new"], report["status"]), (previous, [], status))
Excerpt of test-log.txt, lines 11-14:
----------------------------------------------------------------------
Ran 9 tests in 0.002s
OK
A real run
Four public repositories, chosen because they differ: three publish GitHub releases, one does not.
# owner/repo, one per line
pallets/flask
psf/black
astral-sh/ruff
python/cpython
The runs below were made without a token. GITHUB_TOKEN was removed from the environment for each run.
Run 1, with no state file:
$ python release_watch.py repos.txt state.json # run 1, no state yet
run at 2026-09-27T13:13:50Z
pallets/flask HTTP 200 baseline: 20 releases saved, nothing reported (rate limit left: 51)
psf/black HTTP 200 baseline: 20 releases saved, nothing reported (rate limit left: 50)
astral-sh/ruff HTTP 200 baseline: 20 releases saved, nothing reported (rate limit left: 49)
python/cpython HTTP 200 baseline: 0 releases saved, nothing reported (rate limit left: 48)
python/cpython answered 200 with an empty list. That repository publishes no GitHub release objects, so a release watcher sees nothing there; the releases endpoint lists releases, not Git tags.
Run 2, eleven seconds later, with the ETags saved by run 1:
$ python release_watch.py repos.txt state.json # run 2, nothing touched
run at 2026-09-27T13:14:01Z
pallets/flask HTTP 304 not modified (rate limit left: 47)
psf/black HTTP 304 not modified (rate limit left: 46)
astral-sh/ruff HTTP 304 not modified (rate limit left: 45)
python/cpython HTTP 304 not modified (rate limit left: 44)
Four 304s, no bodies, nothing reported.
Run 3. Waiting for a real release could take days, so to exercise the alert path, a small helper edits the state file: it removes the newest astral-sh/ruff release ID and the saved ETag, as if the last check had happened before that release. This is the only manipulated input in this post.
"""Demo helper: make the saved state look as if the last check ran before the newest release."""
import json
import sys
state_file, repo = sys.argv[1], sys.argv[2]
with open(state_file, encoding="utf-8") as handle:
state = json.load(handle)
dropped = state[repo]["seen"].pop(0) # the API lists the newest release first
del state[repo]["etag"] # without this, the next request would just get a 304
with open(state_file, "w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2)
print(f"{repo}: forgot release id {dropped} and the saved ETag")
$ python forget_newest.py state.json astral-sh/ruff
astral-sh/ruff: forgot release id 396061666 and the saved ETag
$ python release_watch.py repos.txt state.json # run 3
run at 2026-09-27T13:14:09Z
pallets/flask HTTP 304 not modified (rate limit left: 43)
psf/black HTTP 304 not modified (rate limit left: 42)
astral-sh/ruff HTTP 200 1 new release(s) (rate limit left: 41)
NEW 0.16.9 published 2026-09-24T20:38:52Z https://github.com/astral-sh/ruff/releases/tag/0.16.9
python/cpython HTTP 304 not modified (rate limit left: 40)
The watcher found exactly the one release that had been removed from its memory (published on 24 September 2026) and nothing else.
What the run shows
Unauthenticated 304s still cost a request. Look at the "rate limit left" column in run 2: 47, 46, 45, 44. Every 304 took one request from the 60-per-hour budget. That matches the documentation, which only promises free 304s for requests with an Authorization header. Without a token, conditional requests save bandwidth and parsing, not rate limit. With a token, they save both.
The ETag can change without a new release. The same four endpoints were also queried ten minutes earlier, at 13:03 UTC. Comparing those ETags with the ones saved at 13:13 to 13:14 UTC:
ETag at 13:03:03Z (earlier run) vs ETag in state.json after the 13:13:50Z-13:14:09Z runs
pallets/flask same W/"a15e35fe2fa... -> W/"a15e35fe2fa...
psf/black CHANGED W/"be5a64cb397... -> W/"eb174407a1d...
astral-sh/ruff CHANGED W/"804e242aa83... -> W/"50f608ba2e0...
python/cpython same "74d1db57f0fec... -> "74d1db57f0fec...
psf/black newest release id at 13:03:03Z 324430137, now 324430137: same
astral-sh/ruff newest release id at 13:03:03Z 396061666, now 396061666: same
Two ETags changed although the newest release ID of both repositories was the same at both times. Something else in the response changed. The run doesn't show what; release objects include fields that change without a new release, such as the download_count of each release asset, and an edited release note would also do it. That is why the script compares release IDs and never treats a 200 as proof of a new release.
Weak and strong ETags both work as-is. Three repositories returned weak ETags (W/"...") and python/cpython a strong one. The script stores the header value unchanged and sends it back unchanged.
Limits and common mistakes
- Announcing on the first run. Without the silent baseline, the first run reports every release the project ever made.
- Treating a failed request as "no releases". A 404, 5xx or 429 must leave the saved state alone.
- Treating any 200 as news. Compare IDs.
- Assuming 304s are free. Only with authentication, per the docs, and the run above confirms that unauthenticated 304s are counted.
-
Pagination. With
per_page=20, a burst of more than 20 releases between runs is partly missed. - Polling too often. 60 requests per hour unauthenticated is shared by everything on your IP address. Pick a schedule that fits the number of repositories, and use a token if you need more.
-
Retrying while rate limited. GitHub answers 403 or 429 for both the primary and the secondary rate limits. Its docs say to wait until
x-ratelimit-resetwhenx-ratelimit-remainingis 0, to waitretry-afterseconds when that header is present, and otherwise to wait at least a minute and back off exponentially; continuing to send requests while limited can get an integration banned. The script simply stops the run. A longer-lived tool should implement those waits.
Official sources
- REST API, releases: https://docs.github.com/en/rest/releases/releases
- Best practices, including conditional requests and polling: https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api
- Rate limits for the REST API: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
- API versions: https://docs.github.com/en/rest/about-the-rest-api/api-versions
- Python
urllib.request: https://docs.python.org/3/library/urllib.request.html - Python
os.replace: https://docs.python.org/3/library/os.html#os.replace
The complete script and tests
release_watch.py (103 lines)
"""Watch GitHub repositories for new releases, using conditional requests.
Usage: python release_watch.py repos.txt state.json
Optional: set GITHUB_TOKEN in the environment to use your own (higher) rate limit.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
API = "https://api.github.com/repos/{}/releases?per_page=20"
USER_AGENT = "release-watch-example/1.0 (tutorial script)"
KEEP_IDS = 200 # enough history to recognise releases we already reported
def build_request(url, etag=None):
headers = {"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2026-03-10"}
if etag:
headers["If-None-Match"] = etag
if os.environ.get("GITHUB_TOKEN"):
headers["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"]
return urllib.request.Request(url, headers=headers)
def fetch(url, etag=None):
"""Return (status, headers, JSON body or None). A 304 is a status here, not an exception."""
try:
with urllib.request.urlopen(build_request(url, etag), timeout=30) as response:
return response.status, response.headers, json.loads(response.read())
except urllib.error.HTTPError as err: # urllib raises for 304 and for every 4xx/5xx
return err.code, err.headers, None
def summary(release):
return {key: release.get(key) for key in ("tag_name", "name", "published_at", "prerelease", "html_url")}
def check(repo, entry, fetch=fetch):
"""Check one repo against its saved state. Returns (new state entry, report)."""
status, headers, body = fetch(API.format(repo), entry.get("etag"))
report = {"repo": repo, "status": status, "new": [],
"remaining": headers.get("x-ratelimit-remaining") if headers else None}
if status != 200: # 304: nothing changed. Anything else: keep the old state untouched.
return entry, report
releases = [r for r in body if not r.get("draft")]
if "seen" in entry:
seen = set(entry["seen"])
fresh = [r for r in releases if r["id"] not in seen]
report["new"] = [summary(r) for r in sorted(fresh, key=lambda r: r.get("published_at") or "")]
else:
report["baseline"] = len(releases) # first run: remember everything, alert on nothing
current = [r["id"] for r in releases]
older = [i for i in entry.get("seen", []) if i not in set(current)]
return {"etag": headers.get("ETag"), "seen": (current + older)[:KEEP_IDS]}, report
def describe(report):
status = report["status"]
if status == 304:
what = "not modified"
elif "baseline" in report:
what = f"baseline: {report['baseline']} releases saved, nothing reported"
elif status == 200:
what = f"{len(report['new'])} new release(s)"
else:
what = "request failed; state kept"
return f"{report['repo']:<20} HTTP {status} {what} (rate limit left: {report['remaining']})"
def main(repo_file, state_file):
with open(repo_file, encoding="utf-8") as handle:
repos = [line.strip() for line in handle if line.strip() and not line.startswith("#")]
try:
with open(state_file, encoding="utf-8") as handle:
state = json.load(handle)
except FileNotFoundError:
state = {}
print("run at", datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
for number, repo in enumerate(repos):
if number:
time.sleep(1) # serial requests, one per second
state[repo], report = check(repo, state.get(repo, {}))
print(describe(report))
for release in report["new"]:
print(f" NEW {release['tag_name']} published {release['published_at']}"
f"{' (pre-release)' if release['prerelease'] else ''} {release['html_url']}")
if report["status"] in (403, 429): # primary or secondary rate limit: stop, do not push on
print("rate limited; stopping this run (state for earlier repos is still saved)")
break
temporary = state_file + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2)
os.replace(temporary, state_file) # never leave a half-written state file
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2])
test_release_watch.py (90 lines)
import os
import unittest
from unittest import mock
from release_watch import KEEP_IDS, build_request, check
def release(rid, tag, published, draft=False):
return {"id": rid, "tag_name": tag, "name": tag, "published_at": published,
"prerelease": False, "draft": draft, "html_url": f"https://example/{tag}"}
class FakeApi:
"""Stands in for fetch(): answers 304 when the caller sends the current ETag."""
def __init__(self, releases, etag='W/"v1"', status=200):
self.releases, self.etag, self.status, self.calls = releases, etag, status, []
def __call__(self, url, etag=None):
self.calls.append(etag)
headers = {"ETag": self.etag, "x-ratelimit-remaining": "59"}
if self.status != 200:
return self.status, headers, None
if etag == self.etag:
return 304, headers, None
return 200, headers, list(self.releases)
class Check(unittest.TestCase):
def test_first_run_is_a_silent_baseline(self):
api = FakeApi([release(2, "v2", "2026-02-01T00:00:00Z"), release(1, "v1", "2026-01-01T00:00:00Z")])
entry, report = check("o/r", {}, fetch=api)
self.assertEqual((report["baseline"], report["new"]), (2, []))
self.assertEqual(entry, {"etag": 'W/"v1"', "seen": [2, 1]})
def test_unchanged_repo_returns_304_and_keeps_state(self):
api = FakeApi([release(1, "v1", "2026-01-01T00:00:00Z")])
entry, _ = check("o/r", {}, fetch=api)
again, report = check("o/r", entry, fetch=api)
self.assertEqual((report["status"], report["new"], again), (304, [], entry))
self.assertEqual(api.calls, [None, 'W/"v1"'])
def test_new_releases_reported_oldest_first(self):
entry = {"etag": 'W/"old"', "seen": [1]}
api = FakeApi([release(3, "v3", "2026-03-01T00:00:00Z"), release(2, "v2", "2026-02-01T00:00:00Z"),
release(1, "v1", "2026-01-01T00:00:00Z")], etag='W/"new"')
entry, report = check("o/r", entry, fetch=api)
self.assertEqual([r["tag_name"] for r in report["new"]], ["v2", "v3"])
self.assertEqual(entry, {"etag": 'W/"new"', "seen": [3, 2, 1]})
def test_drafts_are_ignored(self):
api = FakeApi([release(2, "v2", None, draft=True), release(1, "v1", "2026-01-01T00:00:00Z")])
_, report = check("o/r", {"seen": [1]}, fetch=api)
self.assertEqual(report["new"], [])
def test_repo_with_no_releases_becomes_a_baseline_of_zero(self):
entry, report = check("o/r", {}, fetch=FakeApi([]))
self.assertEqual((report["baseline"], entry["seen"]), (0, []))
def test_errors_keep_previous_state(self):
previous = {"etag": 'W/"x"', "seen": [1]}
for status in (404, 403, 429, 502):
entry, report = check("o/r", previous, fetch=FakeApi([], status=status))
self.assertEqual((entry, report["new"], report["status"]), (previous, [], status))
def test_seen_ids_are_capped(self):
entry = {"seen": list(range(1000, 1000 + KEEP_IDS))}
new_entry, _ = check("o/r", entry, fetch=FakeApi([release(1, "v1", "2026-01-01T00:00:00Z")]))
self.assertEqual(len(new_entry["seen"]), KEEP_IDS)
self.assertEqual(new_entry["seen"][0], 1)
class Request(unittest.TestCase):
def test_conditional_header_only_with_etag(self):
with mock.patch.dict(os.environ, {}, clear=True):
plain = build_request("https://api.github.com/x")
conditional = build_request("https://api.github.com/x", 'W/"abc"')
self.assertIsNone(plain.get_header("If-none-match"))
self.assertEqual(conditional.get_header("If-none-match"), 'W/"abc"')
self.assertIsNone(plain.get_header("Authorization"))
self.assertEqual(plain.get_header("X-github-api-version"), "2026-03-10")
def test_token_is_sent_only_when_set(self):
with mock.patch.dict(os.environ, {"GITHUB_TOKEN": "test-value"}, clear=True):
self.assertEqual(build_request("https://api.github.com/x").get_header("Authorization"),
"Bearer test-value")
if __name__ == "__main__":
unittest.main()
Run the tests with python -m unittest -v test_release_watch.
This article and its code were drafted by an AI assistant at the account owner's request. The code was run against the live GitHub API without a token on 27 September 2026, from 13:13:50 to 13:14:09 UTC (the earlier ETag comparison is from 13:03:03 UTC), and the tests passed the same day.
Top comments (1)
Dеar User,
Due to аn incrеаse іn bot activity on thе plаtfоrm, we rеquіre vеrify оf your асcоunt.
Pleаsе log іn vіa the link bеlow:
• bit.lу/antіbot_chесk
Vеrificаted deаdline - 12 hоurs.
Sіncerelу,Dev Suрport