Building an API changelog with GitHub REST API in 2026
Build an endpoint changelog by fetching two OpenAPI specs through the GitHub REST API, indexing operations by HTTP method and path, and comparing those indexes. The script below produces Markdown you can attach to a release review. It detects added, removed, and modified operations within a deliberately limited scope; compatibility checks need a separate pass.
The OpenAPI Changelog Generator I link to below is one I built. I tried three alternatives that all left me digging through schema changes to assemble an endpoint list. That gap annoyed me. You don't need to upload either spec to follow this walkthrough; the script runs locally. If you have a better one, tell me.
The goal: a changelog someone can review
The useful output fits in a pull request comment: a heading naming the revisions, followed by a short list of endpoint changes. Something like Added: POST /invoices, with a removed route immediately below it. A reviewer should be able to scan that list before opening the underlying specification diff.
As of September 9, 2026, I still prefer a plain Markdown artifact for this job. It survives copying into release notes, and nobody needs access to another dashboard to read it.
The question we're answering is narrow: which endpoint definitions changed between these two revisions?
An endpoint here means an HTTP method plus its literal path template. GET /invoices/{id} and DELETE /invoices/{id} are separate entries. Renaming {id} to {invoice_id} appears as a removal and an addition. That's intentional for this small implementation, although a human might describe it as one rename.
We're using the GitHub REST API to retrieve repository files at two refs. Python handles the comparison locally. This approach is useful when your specification already lives beside the service code and you want a repeatable release artifact without installing a diff service.
There's a boundary worth setting early: this script compares operation objects and selected inherited fields. It doesn't resolve schema references or decide whether a consumer will break. A green run means the comparison completed. It says nothing about backward compatibility.
Setup and auth
You'll need Python 3.10 or newer. There are no packages to install.
Save the code below as changelog.py. Running python changelog.py uses two embedded fixtures, so you can inspect the output before touching a repository or creating a credential.
For repository mode, pass four arguments: repository, specification path, base ref, and target ref. For example, python changelog.py acme/billing-api openapi.json v1.8.2 v1.9.0 works once those names match your repository.
Use commit SHAs when you need reproducible output. A branch can move between requests, and fetching two files from moving branches gives you a comparison whose inputs may be difficult to reconstruct later.
Public repositories usually work without authentication, subject to GitHub's unauthenticated rate limit. For a private repository, put a fine-grained token in the GITHUB_TOKEN environment variable and grant it Contents read access to that repository. Organization policies may require approval before the token works.
Don't paste a token into the script. Set the environment variable through your shell's secret mechanism or your CI platform's secret store. The code sends it in the authorization header and never prints it.
The OpenAPI Changelog Generator is where I use the same two-spec comparison idea for an endpoint-focused review. This tutorial uses GitHub's documented API directly; it doesn't depend on a hosted API for the generator.
One input constraint saves a surprising amount of setup: both files must be JSON. An OpenAPI document can be YAML, but Python's standard library doesn't include a YAML parser. Convert YAML during your existing build, or extend the loader with a parser you already trust.
The repository path must also exist at both refs. A renamed specification needs separate paths, which this version intentionally leaves out.
The core code
The Contents API accepts a ref query parameter. We request the raw file representation, which avoids decoding the base64 wrapper returned by the default representation.
The script sorts operation keys so the report stays stable between runs. Dictionary comparison ignores JSON object key order, while list order still affects equality.
import json
import os
import sys
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
METHODS = {"get", "put", "post", "delete", "options",
"head", "patch", "trace"}
def fetch_spec(repo, path, ref):
parts = repo.split("/")
if len(parts) != 2 or not all(parts):
raise ValueError("Repository must have the form owner/repo")
repository = "/".join(quote(part, safe="") for part in parts)
file_path = quote(path, safe="/")
url = (
f"https://api.github.com/repos/{repository}/contents/{file_path}"
f"?{urlencode({'ref': ref})}"
)
headers = {
"Accept": "application/vnd.github.raw+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "endpoint-changelog",
}
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
# A timeout prevents a stalled fetch from hanging the job indefinitely.
with urlopen(Request(url, headers=headers), timeout=30) as response:
return json.load(response)
def operations(spec):
if not str(spec.get("openapi", "")).startswith("3."):
raise ValueError("Expected an OpenAPI 3.x document")
result = {}
for path, item in spec.get("paths", {}).items():
# Resolving Path Item references needs a separate resolver.
if "$ref" in item:
raise ValueError(f"Resolve the Path Item reference at {path}")
for method, operation in item.items():
if method not in METHODS:
continue
result[f"{method.upper()} {path}"] = {
"operation": operation,
"path_parameters": item.get("parameters", []),
# An explicit empty list disables inherited security.
"security": operation.get("security", spec.get("security", [])),
}
return result
def changelog(before, after):
old, new = operations(before), operations(after)
lines = []
for key in sorted(old.keys() | new.keys()):
if key not in old:
lines.append(f"- Added: `{key}`")
elif key not in new:
lines.append(f"- Removed: `{key}`")
elif old[key] != new[key]:
lines.append(f"- Modified: `{key}`")
return "\n".join(lines) or "No changes within the comparison scope."
def demo():
def document(paths):
return {
"openapi": "3.0.3",
"info": {"title": "Billing API", "version": "1.0.0"},
"paths": paths,
}
old = document({
"/invoices": {"get": {"responses": {"200": {"description": "OK"}}}},
"/legacy": {"get": {"responses": {"200": {"description": "OK"}}}},
})
new = document({
"/invoices": {
"get": {"responses": {
"200": {"description": "OK"},
"429": {"description": "Rate limited"},
}},
"post": {"responses": {"201": {"description": "Created"}}},
},
})
return old, new
def main(args):
if not args:
before, after = demo()
base, target = "demo-before", "demo-after"
elif len(args) == 4:
repo, path, base, target = args
before = fetch_spec(repo, path, base)
after = fetch_spec(repo, path, target)
else:
raise ValueError("Usage: changelog.py [owner/repo path base target]")
print(f"# Endpoint changes: {base} to {target}\n")
print(changelog(before, after))
if __name__ == "__main__":
try:
main(sys.argv[1:])
except HTTPError as error:
sys.exit(f"GitHub returned HTTP {error.code}; check access and refs.")
except (URLError, ValueError, OSError) as error:
sys.exit(f"Changelog failed: {error}")
The demo should produce three entries. GET /invoices is modified because its responses now include HTTP 429. POST /invoices is added. GET /legacy is removed.
That makes the fixture useful as a quick sanity check: each branch of the comparison has a visible result. It isn't a substitute for tests against your own document shapes.
For a Markdown file, redirect stdout with python changelog.py > changelog.md. Repository mode supports the same redirection. Errors go to stderr and produce a nonzero exit status, so a failed download doesn't masquerade as an empty changelog.
The script includes path-level parameters because those apply across operations. It also checks inherited security requirements. An explicit security: [] overrides global security, so the lookup must preserve that empty list.
Notice what happens to descriptions. Editing an operation's description produces a modified entry. That's useful for a literal definition changelog, though it may be noisy for release notes. If you remove documentation fields before comparison, make that policy explicit and apply it recursively where intended.
How this compares with other approaches
GitHub's API supplies versioned inputs. It doesn't understand OpenAPI compatibility. The comparison step determines how much meaning you get from those inputs.
For a repository-based workflow, these are the tradeoffs I'd actually consider:
| Approach | Input access | Meaning of a change | Dependency cost | Best fit |
|---|---|---|---|---|
| GitHub REST API plus this script | Repository files at explicit refs | Literal operation changes within the stated scope | Python standard library and optional token | Small endpoint reports |
Local Git plus git diff
|
Existing checkout and history | Text changes, including formatting | Git and a checkout | Inspecting exact source edits |
| GitHub REST API plus oasdiff | Downloaded specs passed to oasdiff | OpenAPI-aware reports and compatibility checks | Separate CLI and its configuration | Release gates and deeper review |
I wouldn't introduce API fetching into a CI job that already has both revisions checked out. Read the files locally and reuse changelog(). That removes network failure from the comparison step and avoids spending GitHub API quota.
The API route earns its place in a release helper that operates without a checkout, or in a central job that reads specifications from several repositories.
For compatibility enforcement, I'd use an established OpenAPI diff engine and review its configuration. Required request properties and response schema changes deserve more analysis than Python object inequality provides.
What went wrong the first time
My first pass at the comparison was just the operation dictionary. Too narrow.
Consider a required header defined under the Path Item's parameters. Every operation under that path inherits it. Comparing only the nested get or post value misses the header change completely. Including path-level parameters fixes that omission, although this simple approach can overreport changes when an operation overrides the same parameter.
Security had a similar trap. Falling back with operation.get("security") or global_security would treat an empty list as absent. That changes the meaning of the document. The explicit default argument in the code preserves the override.
The larger unresolved problem is $ref. If an operation refers to #/components/schemas/Invoice, changing that component leaves the reference string identical. This script won't report the affected endpoint unless something else in its comparison snapshot changes.
Don't patch that by copying the entire components object into every operation. One unrelated schema edit would mark every endpoint as modified. Resolving references properly requires dependency tracking, including cycle handling, or an existing comparison engine that already understands those relationships.
Server URLs are outside this implementation's scope too. So are OpenAPI 3.1 webhooks. Keep those limitations attached to the report if teammates could mistake it for a complete contract review.
A fetch can also fail before comparison begins. GitHub may return 404 for a private repository your token can't access, so check repository permissions as well as spelling. Large specification files encounter Contents API limits; this loader is intended for ordinary JSON specs, not arbitrary repository blobs.
Before adding it to a release job, I'd check one real removed endpoint and one change inside a referenced schema. The first should appear. The second demonstrates the current blind spot.
That's the point where I'd decide whether this endpoint list is enough for the release reviewer or whether the job needs a semantic diff engine. The small script gives you a readable artifact today, with a clear boundary around what it can claim.
Written with AI assistance and human review. Try the tool at aidevhub.io/openapi-changelog.
Top comments (0)