DEV Community

niuniu
niuniu

Posted on

Auto-Generate CHANGELOG.md with a Free Python AI Webhook

You can automate CHANGELOG.md generation with a free AI webhook in about 100 lines of Python. I built a Flask endpoint that listens for GitHub push events, summarizes commit messages with MonkeyCode's free AI, and commits the result back to the repository.

Last Friday I was minutes away from tagging v1.4.0 when I realized CHANGELOG.md still ended at v1.3.0. I spent the next half hour digging through forty commits, trying to remember which one fixed the login bug and which one introduced the CSS regression. The result was a bland list of bullet points nobody would ever read. I closed the file and told myself this was the last time I would write release notes by hand.

That weekend I built a small service that does the job automatically. It extracts commit messages from GitHub push events and sends them to MonkeyCode's free model access to generate a human-friendly CHANGELOG entry. The whole thing runs on MonkeyCode's free server option, which includes a 10-million-token allowance at the time of writing.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

How the Flask webhook architecture works

The design is deliberately boring. A Flask app exposes a single /webhook endpoint. GitHub sends a POST request every time someone pushes to main. The app verifies the request signature, filters out non-push events, collects the commit messages, calls the AI model, and appends the result to CHANGELOG.md. Then it commits and pushes the change back to the repository.

That boredom is the point. I needed this to run unattended on a free server: no extra database server, no message broker, no Kubernetes. One Python process and a few environment variables were enough.

The live path looks like this:

  1. GitHub POSTs a push payload to /webhook.
  2. The app verifies X-Hub-Signature-256 with HMAC-SHA256.
  3. Non-push events and non-main refs return 200 and stop.
  4. Commit messages are collected from the payload.
  5. The model returns one CHANGELOG paragraph.
  6. The app updates CHANGELOG.md, then commits and pushes.
import os
import hashlib
import hmac
from flask import Flask, request, jsonify

app = Flask(__name__)
GITHUB_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]

def verify_signature(payload_body, signature_header):
    if not signature_header:
        return False
    digest = hmac.new(
        GITHUB_SECRET.encode(), payload_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(f"sha256={digest}", signature_header)

@app.route("/webhook", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Hub-Signature-256", "")
    if not verify_signature(request.data, signature):
        return jsonify({"error": "Invalid signature"}), 403
    if request.headers.get("X-GitHub-Event") != "push":
        return jsonify({"status": "ignored"}), 200
    payload = request.get_json()
    if payload.get("ref", "").split("/")[-1] != "main":
        return jsonify({"status": "ignored"}), 200
    commits = [
        c["message"] for c in payload.get("commits", []) if c.get("message")
    ]
    if not commits:
        return jsonify({"status": "no commits"}), 200
    entry = generate_summary(commits)
    update_changelog(entry)
    return jsonify({"status": "ok"}), 200
Enter fullscreen mode Exit fullscreen mode

Compared with a GitHub Action that only dumps raw messages, this service sits outside the repo and can rewrite messy commits into something a human will actually read. Compared with a full release bot, it has no queue and no workers, which is why it fits a single free process.

Verify GitHub webhook signatures before anything else

The first version of this app had a glaring bug: I checked X-Hub-Signature instead of X-Hub-Signature-256. GitHub has been sending the SHA-256 version for years, and my code silently rejected every legitimate request while accepting nothing. The fix was one line, but the lesson stuck. Webhook endpoints are public by definition, so signature verification is not optional. The GitHub webhook documentation explains the header format in detail.

Two checks I treat as required, not nice-to-have:

  • Read X-Hub-Signature-256, not the older SHA-1 header.
  • Compare digests with hmac.compare_digest. A naive == leaks timing information, and on a shared free server you have no idea who else is watching the process.

Even if the endpoint only handles one webhook per day, keep the constant-time comparison. The cost is one function call. The alternative is a public write path into your changelog.

Turn messy commits into one CHANGELOG paragraph

The core of the service is a function that turns raw commit messages into a coherent paragraph. I initially tried to write rules for this, but commit messages are too inconsistent. Some follow Conventional Commits, some are one-liners, and some are three paragraphs about a typo fix. A language model handles that variety better than a pile of regex.

import os
import requests

def generate_summary(commit_messages):
    prompt = (
        "Rewrite these git commit messages into one concise, professional "
        "CHANGELOG entry. Group related changes. Avoid jargon.\n\n"
        + "\n".join(commit_messages)
    )
    resp = requests.post(
        os.environ["MONKEYCODE_API_URL"],
        headers={
            "Authorization": f"Bearer {os.environ['MONKEYCODE_API_KEY']}"
        },
        json={"prompt": prompt, "max_tokens": 250},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()["text"].strip()
Enter fullscreen mode Exit fullscreen mode

What I changed after the first ugly drafts:

  • Ask for one CHANGELOG entry, not a bullet per commit.
  • Explicitly tell the model to group related changes.
  • Cap max_tokens at 250 so a rambling reply cannot blow the free-tier budget.
  • Keep a 30-second timeout so GitHub does not sit on an open delivery.

The exact request format depends on the current MonkeyCode API, so I read the repository's README before wiring this up. Without the "group related changes" instruction, the model listed every commit verbatim, which defeated the purpose. A 40-commit dump that used to become forty bullets now becomes a short paragraph I would actually paste into a release.

Stop duplicate entries and the changelog infinite loop

GitHub retries webhook deliveries when your endpoint returns a non-2xx response or times out. If the first attempt succeeds but the response is lost, the second attempt generates a duplicate CHANGELOG entry. My first deployment hit this within an hour.

The fix is a SQLite table that records which commit SHAs have already been processed. Before calling the model, the app checks the table and skips any commit that is already there.

import sqlite3

def already_processed(sha):
    conn = sqlite3.connect("processed.db")
    try:
        row = conn.execute(
            "SELECT 1 FROM commits WHERE sha=?", (sha,)
        ).fetchone()
        return row is not None
    finally:
        conn.close()

def mark_processed(sha):
    conn = sqlite3.connect("processed.db")
    try:
        conn.execute("INSERT INTO commits (sha) VALUES (?)", (sha,))
        conn.commit()
    finally:
        conn.close()
Enter fullscreen mode Exit fullscreen mode

Practical guards that kept me from babysitting it:

  • Seed the table with the SHA of the last release tag so the first webhook only processes new commits.
  • Ignore commits whose message starts with docs: update CHANGELOG. The changelog push would otherwise fire another webhook, generate another entry, and loop forever.
  • Return 200 for ignored events so GitHub does not retry noise.

This is the kind of detail that separates a weekend demo from something you can run for weeks without supervision.

Free-tier limits, then a clear decision

Running this on MonkeyCode's free server taught me to respect shared infrastructure. Model calls are fast, but they are not instant, and the free tier has rate limits I did not notice until I pushed a branch with thirty commits. The first webhook burst through the limit and returned a 429, which GitHub treated as a failure and retried later. The retry worked because the rate window had reset, but the experience reminded me to keep the prompt short and the retry logic sane.

Token math from my own traffic:

  • A typical push is about a 200-token prompt and a 100-token response.
  • A busy month still stays under a few hundred thousand tokens.
  • The 10-million-token allowance is plenty for this workload at the time of writing.

The free server is not a production SLA. If the process crashes, it stays down until someone restarts it. I added a cron job that pings a health endpoint every five minutes and restarts the service if it is unresponsive. That is a band-aid, not a guarantee.

Should you copy this? If your team treats CHANGELOG.md as an afterthought, the pattern is worth stealing: it costs nothing on the free option, and the entries are actually readable. If your commit messages are already perfect, the AI summary adds little. If you need approvals and an audit trail, a bot that pushes directly to main is a liability.

Point a GitHub webhook at MonkeyCode's free server on a personal repository today and run this three-step check:

  1. Create the webhook on a repo you own and aim it at the free server.
  2. Set GITHUB_WEBHOOK_SECRET, MONKEYCODE_API_URL, and MONKEYCODE_API_KEY.
  3. Push to main, then open CHANGELOG.md and decide whether you like the writing style.

Let it run for a week. You will either keep the automatic entries or rewrite the prompt. Either way, you will have practiced webhooks, idempotency, and free-tier limits more than another tutorial could teach you. The code is simple enough to modify, and MonkeyCode's open-source repository shows exactly how the free tier is implemented. Bring your own secret key, then note what you would change first.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Top comments (0)