Your AI pair programmer just shipped a flurry of commits. Three features, two bug fixes, and one refactor that changes everything. Now someone has to turn that into release notes your users actually understand.
Spoiler: it's still you. Unless you build a bot that does it for you — with a free AI model and a free server.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
In this guide, you'll build a GitHub webhook handler that listens for new releases, fetches the commit list, asks a free model to summarize it, and posts the summary back as a release comment. You'll run and verify each step locally before deploying the whole thing to MonkeyCode's free server. $0 infra. No hidden credit card fields.
What you need
- A GitHub repo you own.
- A MonkeyCode account — the open-source project's free tier includes model access and a free server option.
- Node.js 18+ installed locally.
- A GitHub personal access token (fine-grained, with
contents:readandissues:writeto comment on releases). - A GitHub webhook secret (any long random string).
We're going to keep the logic in one server.js file and test it piece by piece.
Step 1: Stand up a basic webhook receiver
Create a new directory, run npm init -y, then install Express:
npm install express
Create server.js with an Express app that starts and exposes a health endpoint:
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
app.get('/health', (req, res) => {
res.send('ok');
});
const server = app.listen(process.env.PORT || 3000, () => {
console.log(`Listening on ${server.address().port}`);
});
Run it:
node server.js
Then hit the health check:
curl localhost:3000/health
You should see ok. The app is alive. Now let's make it actually do something.
Step 2: Talk to a free AI model through an OpenAI-compatible endpoint
MonkeyCode's free tier exposes an OpenAI-compatible chat completions API. That means you can use the official OpenAI SDK and just point it at MonkeyCode's base URL.
Install the SDK:
npm install openai
Now set your environment variables (don't commit these):
export MONKEYCODE_API_BASE="https://api.monkeycode.example.com/v1" # get the real URL from your dashboard
export MONKEYCODE_API_KEY="your_key"
export MONKEYCODE_MODEL="your_model" # e.g., a free-tier model listed in your account
Create a quick test script, test-model.js:
const OpenAI = require('openai');
const client = new OpenAI({
baseURL: process.env.MONKEYCODE_API_BASE,
apiKey: process.env.MONKEYCODE_API_KEY,
});
const response = await client.chat.completions.create({
model: process.env.MONKEYCODE_MODEL,
messages: [
{ role: 'system', content: 'You are a release note writer. Be concise, use imperative mood, group by type.' },
{ role: 'user', content: 'Summarize: "fix: correct login redirect" and "feat: add dark mode"' }
]
});
console.log(response.choices[0].message.content);
Run it:
node test-model.js
You should see something like:
Features
- Added dark mode
Fixes
- Corrected login redirect
That's the model working. Every webhook call will now use the same pattern.
Step 3: Fetch commits between tags
When GitHub sends a release event, your bot receives a payload with repository.full_name and release.tag_name. To build good notes, you need the commits that landed since the previous tag.
We'll add two helper functions: one to find the previous tag, and one to fetch the commits between two tags.
async function getPreviousTag(owner, repo, currentTag, token) {
const url = `https://api.github.com/repos/${owner}/${repo}/tags`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'release-bot' }
});
const tags = await res.json();
const idx = tags.findIndex(t => t.name === currentTag);
return idx > -1 && idx + 1 < tags.length ? tags[idx + 1].name : null;
}
async function getCommitsBetween(owner, repo, baseTag, headTag, token) {
const url = `https://api.github.com/repos/${owner}/${repo}/compare/${baseTag}...${headTag}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'release-bot' }
});
const data = await res.json();
return data.commits.map(c => c.commit.message.split('\n')[0]); // subject line only
}
These call the GitHub REST API. With a fine-grained token, you get higher rate limits and access to private repos.
Let's verify this part in isolation with a small script. Save this as test-github.js and replace owner/repo with your own:
// test-github.js
const token = process.env.GITHUB_TOKEN;
const owner = 'your-github-name';
const repo = 'your-repo';
const currentTag = 'v1.1.0'; // change to an existing tag
const prev = await getPreviousTag(owner, repo, currentTag, token);
console.log('Previous tag:', prev);
const commits = await getCommitsBetween(owner, repo, prev, currentTag, token);
console.log(commits);
Run it and confirm it prints a list of commit subjects.
Step 4: Wire it all together in the webhook handler
Now the fun part. Replace the placeholder handleRelease in server.js with a function that:
- Reads the repo and tag from the payload.
- Finds the previous tag.
- Fetches commits.
- Asks the model for a summary.
- Posts the summary as a comment on the release using the GitHub API.
async function handleRelease(payload, githubToken) {
const owner = payload.repository.owner.login;
const repo = payload.repository.name;
const currentTag = payload.release.tag_name;
const prevTag = await getPreviousTag(owner, repo, currentTag, githubToken);
const commits = await getCommitsBetween(owner, repo, prevTag, currentTag, githubToken);
const prompt = `Write release notes for these commits. Group by Features, Fixes, and Chores:\n${commits.join('\n')}`;
const summary = await summarize(prompt);
const commentUrl = payload.release.url + '/comments';
const res = await fetch(commentUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${githubToken}`,
'User-Agent': 'release-bot',
'Content-Type': 'application/json'
},
body: JSON.stringify({ body: summary })
});
return { ok: res.ok, summary };
}
Add the summarize helper:
async function summarize(promptText) {
const client = new OpenAI({
baseURL: process.env.MONKEYCODE_API_BASE,
apiKey: process.env.MONKEYCODE_API_KEY,
});
const response = await client.chat.completions.create({
model: process.env.MONKEYCODE_MODEL,
messages: [
{ role: 'system', content: 'You are an expert release note writer. Use precise, user-friendly language.' },
{ role: 'user', content: promptText }
]
});
return response.choices[0].message.content.trim();
}
Update the webhook route to call handleRelease and verify the GitHub signature so no one else can trigger it:
app.post('/webhook', async (req, res) => {
if (process.env.ALLOW_UNSIGNED !== 'true' && !verifySignature(req.get('x-hub-signature-256'), req.rawBody)) {
return res.sendStatus(401);
}
const event = req.get('x-github-event');
if (event === 'release') {
try {
const result = await handleRelease(req.body, process.env.GITHUB_TOKEN);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
} else {
res.json({ received: event });
}
});
function verifySignature(signature, rawBody) {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const expected = `sha256=${hmac}`;
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Now test locally without setting up GitHub. Start the server with ALLOW_UNSIGNED=true:
export GITHUB_WEBHOOK_SECRET="super-secret"
export GITHUB_TOKEN="your_fine_grained_token"
export ALLOW_UNSIGNED=true
node server.js
Send a simulated release payload with curl:
curl -X POST localhost:3000/webhook \
-H "Content-Type: application/json" \
-d '{"repository":{"owner":{"login":"your-github-name"},"name":"your-repo"},"release":{"tag_name":"v1.1.0","url":"https://api.github.com/repos/your-github-name/your-repo/releases/1"}}'
Check the terminal logs. If everything works, you'll see { ok: true, summary: "..." }, and the comment should appear on your actual release.
Step 5: Deploy to MonkeyCode's free server
Once it works locally, deploying is just a matter of moving this Express app to MonkeyCode's serverless environment. The official platform gives you a free server for small apps like this.
The exact deployment process is documented in the MonkeyCode README. You'll typically connect your Git repo or push from a CLI, and your app will be assigned a public HTTPS URL. Don't trust a blog post for that part—check the docs for the current command.
The app will receive a public URL. In your GitHub repo settings, add a webhook:
- Payload URL:
https://your-app.monkeycode-host.com/webhook - Content type:
application/json - Secret: the same one you used locally
- Events: select "Releases" only
Now publish a new release. Wait a second. Then open the release page — you should see a comment generated by your AI release bot.
Limitations
Free model calls are slower and rate-limited compared to paid tiers. You'll feel it on long commit lists. Also, GitHub's API has strict rate limits if you use a token with too narrow scopes. This bot is not a great fit for monorepos with hundreds of commits per release, or for projects that require compliant, formally reviewed release documentation.
Who should skip this? If every release note must be reviewed by a human and archived in a compliance system, don't auto-post directly. Instead, have the bot write to a draft PR or an issue, so you get a human gate before anything becomes public.
Final thought
Your AI copilot wrote the code. Your AI release bot can explain what it did. The pipeline is fully free, and the code is small enough to adapt in an afternoon.
Have you tried something similar? I'd love to hear what your release bot got wrong so I can avoid the same mistake.
Top comments (0)