Every developer knows this moment. The sprint is over, a release just went out, and someone asks what changed. You open git log --oneline and see something like this:
a3f9c12 fix
9b1e4d8 WIP — do not merge
c7a2e11 finally
d8b3f00 more fixes
e1d5a99 hotfix for prod
f2c6b88 add stuff
04a79c1 cleanup
b5e8d77 tests
This is completely normal. Commit messages are written under time pressure, for the author's short-term memory, not for a PM, a user, or a future developer nine months from now. They're internal shorthand — useful during the sprint, useless outside it.
The result: writing a release changelog is manual, slow, and usually gets skipped. Which means stakeholders don't know what changed, users don't know what's new, and the release just quietly ships into a void.
Conventional Commits is a popular spec that adds structure to commit messages: feat: add dark mode, fix: resolve login redirect loop, chore: update deps. When your team follows it consistently, you can use tools like auto-changelog or semantic-release to mechanically generate a changelog.
The problem is "when your team follows it consistently." Most teams don't. Even teams that start with good intentions drift. And even when they do follow it, mechanical grouping of raw commit messages isn't the same as a human-readable changelog. feat: add dark mode toggle to user settings screen (closes #412) is better than "stuff" — but it's still developer-centric, not user-centric.
A user-facing changelog reads like: "You can now switch between light and dark mode in Settings → Appearance." That takes rewriting, not just grouping.
| Commit message | What it means | User-facing changelog entry |
|---|---|---|
fix: resolve race cond in auth flow |
Login was occasionally failing for users on slow connections | Fixed intermittent login failures on slow connections |
feat: add dark mode toggle |
New UI setting | New: Dark mode — toggle in Settings → Appearance |
refactor: migrate from axios to fetch |
Internal cleanup | (omit — no user impact) |
fix: CORS issue on /api/export |
CSV export was broken from certain browsers | Fixed CSV export from Safari and Firefox |
The transformation from commit message to changelog entry requires context and judgment. The developer knows what the race condition actually affected. A mechanical tool doesn't. But an LLM can make a reasonable inference, and in practice, the result is better than nothing — which is what most teams produce today.
The simplest useful implementation is a script that:
git log for a configurable window (last 7 days, or since last tag)Here's a working implementation in Node.js (~40 lines):
#!/usr/bin/env node
// generate-changelog.js
// Usage: node generate-changelog.js [--since "7 days ago"] [--output CHANGELOG.md]
const { execSync } = require('child_process');
const https = require('https');
const since = process.argv.includes('--since')
? process.argv[process.argv.indexOf('--since') + 1]
: '7 days ago';
const outputFile = process.argv.includes('--output')
? process.argv[process.argv.indexOf('--output') + 1]
: null;
// 1. Get commits
const commits = execSync(
`git log --since="${since}" --pretty=format:"- %s (%an)" --no-merges`
).toString().trim();
if (!commits) {
console.log('No commits found in the given window.');
process.exit(0);
}
// 2. Build prompt
const prompt = `You are a technical writer. Convert the following git commits into a concise, user-facing changelog.
Group entries under these headings (omit empty sections):
## New Features
## Bug Fixes
## Improvements
## Internal (for changes with no user impact)
Rules:
- Rewrite each entry in plain English for non-developers
- Omit trivial chores (dep updates, typo fixes) unless impactful
- Do not invent details — only describe what the commit message implies
- Keep entries to one sentence each
Commits:
${commits}
Output only the markdown changelog, no preamble.`;
// 3. Call OpenAI
const body = JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
max_tokens: 1000,
temperature: 0.3,
});
const req = https.request({
hostname: 'api.openai.com',
path: '/v1/chat/completions',
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
const result = JSON.parse(data);
const changelog = result.choices?.[0]?.message?.content ?? 'Error: no output';
if (outputFile) {
require('fs').writeFileSync(outputFile, changelog + '\n');
console.log(`Changelog written to ${outputFile}`);
} else {
console.log(changelog);
}
});
});
req.on('error', err => { console.error('Request failed:', err.message); process.exit(1); });
req.write(body);
req.end();
Run it with:
OPENAI_API_KEY=sk-... node generate-changelog.js --since "7 days ago"
# or since last git tag:
OPENAI_API_KEY=sk-... node generate-changelog.js --since "$(git describe --tags --abbrev=0)"
Typical output for a real sprint:
## New Features
- Users can now switch between light and dark mode in Settings → Appearance
## Bug Fixes
- Fixed intermittent login failures on slow network connections
- Resolved CSV export errors in Safari and Firefox
## Improvements
- Reduced initial page load time for dashboards with large datasets
## Internal
- Migrated HTTP client from Axios to native fetch (no user impact)
Run this on every merge to main and push the changelog to Slack or save it as a release artifact:
# .github/workflows/changelog.yml
name: Generate changelog on merge
on:
push:
branches: [main]
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history for git log
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Generate changelog
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
node generate-changelog.js --since "$(git log --merges -1 --format='%cd' --date=iso HEAD~1)" \
--output /tmp/CHANGELOG_LATEST.md
cat /tmp/CHANGELOG_LATEST.md
- name: Post to Slack (optional)
if: env.SLACK_WEBHOOK != ''
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: |
BODY=$(cat /tmp/CHANGELOG_LATEST.md | head -20)
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
--data "{\"text\": \"*Release notes:*\n\`\`\`${BODY}\`\`\`\"}"
Total: ~10 lines of workflow YAML. No external services. Runs on every merge. LLM cost per run is roughly $0.001 with gpt-4o-mini.
The script above covers 80% of the use case. What it doesn't handle:
fix JIRA-1234) need the actual ticket summary to generate meaningful output. Raw messages are often too terse.These are solvable — but they're engineering work on top of the core generation logic. At some point you're building a changelog pipeline, not a script.
generate-changelog.jsOPENAI_API_KEY=sk-... node generate-changelog.jsMost teams get a working changelog in an afternoon. The hard part isn't the LLM call — it's deciding where the output should go and who owns keeping it updated.
Paste your git log --oneline output and get a formatted changelog instantly.
Groups commits into ✨ New Features, 🐛 Bug Fixes, and 🔧 Improvements — handles both conventional commits and messy histories.
Runs entirely in the browser; nothing leaves your machine.
PushLog is building automated changelog delivery from git history — versioned, multi-repo, with Slack/email delivery on your schedule. No scripting required.
If maintaining changelogs is something you keep meaning to fix, join the waitlist:
Join the PushLog waitlist →