Release notes from commits
Last updated April 7, 2026
A small plugin that turns a commit range into a draft CHANGELOG entry, optionally triggered from a workflow on tag push.
The problem
Release notes are the kind of task where everyone agrees they're important, nobody wants to write them, and the eventual artifact is either copy-pasted commit subjects or marketing fluff that omits half the actual changes.
What you want is something in the middle: an honest summary of what shipped, grouped by category, with the boring stuff filtered out — generated from the source of truth (commits) but rewritten for humans.
The solution
Build a small plugin with one command — release-notes:generate — that:
- Reads
git logfor a commit range (e.g.v1.2.0..HEAD). - Filters to a scope (one package, one path).
- Sends the commits to the platform LLM with a structured prompt.
- Writes the result to
CHANGELOG.mdas a draft entry.
Then, optionally, wrap that command in a YAML workflow that triggers on tag push (tags: ['v*']) and opens a PR with the diff. This is the standard KB Labs pattern: plugins do the work, workflows orchestrate when and how plugins run.
Architecture sketch
release-notes:generate --from=v1.2.0 --to=HEAD --scope=@kb-labs/cli
│
├─ git log v1.2.0..HEAD -- packages/cli
├─ filter: drop merge commits, version bumps, formatting
├─ summarize: useLLM().chat([system, user]) → JSON {added, changed, fixed}
└─ write: prepend rendered markdown to CHANGELOG.mdFor automation, a tiny workflow file references that command:
# .kb/workflows/release-notes.yml
name: release-notes
version: 1.0.0
on:
manual: true
jobs:
generate:
runsOn: local
steps:
- name: Generate CHANGELOG entry
uses: "plugin:@example/release-notes#generate"
with:
from: "${{ env.FROM }}"
to: "HEAD"
scope: "${{ env.SCOPE }}"The workflow only orchestrates — plugin:@example/release-notes#generate is the actual work. This is how KB Labs workflows compose plugins: each step is a plugin:<id>#<command> reference, not arbitrary code.
Why a plugin (not arbitrary code in a workflow)
A useful rule of thumb when you're deciding where logic lives:
- Plugin = the code that does something. Has a manifest, declared permissions, scoped LLM/cache/storage, runs the same way locally and in CI.
- Workflow = a YAML pipeline that orchestrates plugins in steps, triggered manually, by cron, or by another workflow.
Workflows are not a place to hide TypeScript — they're a place to wire plugins together. So the LLM call, the git scan, the file write all live in the plugin. The workflow just decides "when".
The code that matters
A simplified command handler — drop this in packages/release-notes-cli/src/commands/generate.ts:
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { promises as fs } from 'node:fs';
import {
defineCommand,
useLLM,
type PluginContextV3,
} from '@kb-labs/sdk';
const exec = promisify(execFile);
interface GenerateInput {
from: string;
to?: string;
scope: string;
}
interface GenerateResult {
written: boolean;
commits: number;
}
export default defineCommand({
id: 'release-notes:generate',
description: 'Generate a CHANGELOG entry from a commit range.',
handler: {
async execute(ctx: PluginContextV3, input: GenerateInput): Promise<GenerateResult> {
const flags = (input as any).flags ?? input;
const from = flags.from;
const to = flags.to ?? 'HEAD';
const scope = flags.scope;
// 1. Read commits
const pathFilter = scope.startsWith('@')
? `packages/${scope.split('/')[1]}`
: scope;
const { stdout } = await exec('git', [
'log',
'--format=%H%x00%s%x00%b%x00END',
`${from}..${to}`,
'--',
pathFilter,
]);
const commits = parseLog(stdout).filter(c =>
// drop merges, version bumps, format-only commits
!c.subject.startsWith('Merge') &&
!c.subject.startsWith('chore(release):') &&
!/^(format|style)\b/i.test(c.subject)
);
if (commits.length === 0) {
return { written: false, commits: 0 };
}
// 2. Summarize via the platform LLM
const llm = useLLM();
if (!llm) {
throw new Error('release-notes:generate requires an LLM adapter to be configured.');
}
const responseText = await llm.chat([
{ role: 'system', content: SUMMARIZE_PROMPT },
{ role: 'user', content: JSON.stringify(commits) },
]);
// The prompt instructs the model to emit JSON; parse defensively.
const grouped = JSON.parse(responseText) as {
added: string[];
changed: string[];
fixed: string[];
};
// 3. Render and prepend to CHANGELOG.md
const md = renderChangelog(grouped, from, to);
const existing = await fs.readFile('CHANGELOG.md', 'utf8').catch(() => '');
await fs.writeFile('CHANGELOG.md', md + '\n\n' + existing);
return { written: true, commits: commits.length };
},
},
});
const SUMMARIZE_PROMPT = `
You are generating a changelog entry. Group commits into Added / Changed / Fixed.
Rules:
- Plain English. No marketing voice ("blazingly fast", "revolutionary").
- One bullet per change. Skip merge commits, version bumps, formatting.
- If a commit is unclear, omit it rather than guess.
- Output JSON: { "added": [...], "changed": [...], "fixed": [...] }
`;
// parseLog and renderChangelog are small helpers — see the full repo
declare function parseLog(stdout: string): Array<{ sha: string; subject: string; body: string }>;
declare function renderChangelog(g: any, from: string, to: string): string;useLLM() is the SDK helper that resolves the platform's LLM adapter — same one every other plugin uses, no API keys in your code. File access goes through node:fs, scoped by the plugin's manifest permissions (withFs({ mode: 'readWrite', allow: ['CHANGELOG.md'] })).
Variations
- Run on tag push. Wrap the command in a YAML workflow as shown above and trigger it from CI on
tags: ['v*']. Open a PR with the CHANGELOG diff so release notes become part of the release PR. - Multi-package. Loop the scope and produce one entry per package (useful for monorepos).
- Different output formats. Render to GitHub Releases, a Slack message, an email — only the last step changes.
- Cite sources. Include commit SHAs in the LLM output so each bullet links back to the commit. Trust goes up dramatically.
Reproduce this
- Read Guides → Your First Plugin end-to-end. The plugin you build there has the same shape as this one (one command, one handler, one manifest).
- Replace the
hello:greetexample withrelease-notes:generate. Drop in the handler code above. - In the manifest, declare
platform: { optional: ['llm'] }and grantreadWritefilesystem access forCHANGELOG.mdonly. - Run it on a known release range and compare against your existing changelog. Iterate on
SUMMARIZE_PROMPTuntil the diff is small. - (Optional) Wire the YAML workflow in. Read Guides → Your First Workflow for the workflow file structure and how to register it with the workflow daemon.
What goes wrong
- LLM invents commits. Always send the LLM only what it should summarize, never ask it to "fill in what's missing". The filter step is a guardrail.
- Output drifts in style. Pin the LLM model in the platform config so the changelog generator always uses the same one. Don't rely on whatever the platform default is — your changelog reads differently across releases otherwise.
- Scope is fuzzy. "Everything since last release" is rarely what you want in a monorepo. Always scope by package or path.
- Trying to put the logic in the YAML. Workflows orchestrate; they don't compute. If you find yourself wanting an inline TypeScript block in the YAML, that logic belongs in the plugin.
Related
- Showcase: Commit Plugin — same LLM pattern, different direction (commits from diff)
- Guide: Your First Plugin
- Guide: Your First Workflow