KB LabsDocs

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:

  1. Reads git log for a commit range (e.g. v1.2.0..HEAD).
  2. Filters to a scope (one package, one path).
  3. Sends the commits to the platform LLM with a structured prompt.
  4. Writes the result to CHANGELOG.md as 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.md

For automation, a tiny workflow file references that command:

YAML
# .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:

TypeScript
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

  1. 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).
  2. Replace the hello:greet example with release-notes:generate. Drop in the handler code above.
  3. In the manifest, declare platform: { optional: ['llm'] } and grant readWrite filesystem access for CHANGELOG.md only.
  4. Run it on a known release range and compare against your existing changelog. Iterate on SUMMARIZE_PROMPT until the diff is small.
  5. (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.
Release notes from commits — KB Labs Docs