Changelogs & Release Notes on Autopilot
Conventional commits are the structured source — haiku classifies, sonnet narrates, and the changelog writes itself while the team ships.
The Structured Source Problem
A hand-written changelog accumulates three failure modes. It is written after the fact, often from memory, by whoever is releasing — who may not have authored all the commits. It is incomplete: chores and fixes slip through; breaking changes are understated. And it is inconsistent in voice and structure, which makes it harder to parse for the downstream systems that consume it.
The reason changelogs rot is not that teams do not care. It is that writing a good changelog requires discipline applied at the worst time — right before shipping, when everyone is focused on getting the release out. Conventional commits solve this by front-loading the structure. When every commit carries a prefix (feat:, fix:, docs:, chore:), the information needed to write the changelog is already in the commit history. What remains is mechanical extraction and readable synthesis — both of which are tractable automation problems.
The Two-Model Pattern
The pipeline uses two models for deliberate reasons, and the boundary between them matters.
Classification with haiku. Each commit is classified individually: type, scope, breaking flag, and a one-line human summary. The task is near-deterministic when commits follow the conventional prefix convention. A commit that starts with fix: is a bug fix. A commit ending with ! is breaking. Haiku handles this reliably with structured output, at low cost, one call per commit. The classification step is mechanical — there is no synthesis, no writing quality requirement.
Prose generation with sonnet. The grouped sections are serialized to JSON and sent to sonnet in a single call. Sonnet's job is synthesis: turn structured grouped data into readable changelog sections with consistent verb tense, appropriate emphasis on breaking changes, and clean section headers. This is a writing quality task. One call per release.
The cost structure follows from the model split: many cheap haiku calls for classification, one higher-quality sonnet call for prose. Nightly changelog generation jobs that batch all classification calls qualify for the 50% Batch API discount, because they submit asynchronously and retrieve results before the changelog is needed.
Classification Schema and Structured Output
The classification call uses a validator-legal structured output schema:
// The schema haiku must conform to
const CLASSIFY_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
type: { type: "string" },
scope: { type: "string" },
breaking: { type: "boolean" },
summary: { type: "string" },
},
required: ["type", "scope", "breaking", "summary"],
}
Every object carries additionalProperties: false. No minItems, maxItems, minLength, maxLength, minimum, or maximum constraints. The schema is stable — it does not change between releases, which means the prompt and parsing code are stable too.
const response = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 256,
messages: [{
role: "user",
content:
"Classify this git commit. type must be one of: feat fix docs chore perf refactor." +
" scope is the component name or empty string." +
" breaking is true if the commit contains BREAKING CHANGE or ends with !." +
" summary is one readable sentence.\n\n" +
"Commit subject: " + commit.subject + "\nCommit body: " + (commit.body || "(none)"),
}],
output_config: { format: { type: "json_schema", schema: CLASSIFY_SCHEMA } },
})
The classification result is then used to group commits:
function groupCommits(commits: ClassifiedCommit[]): ChangelogSections {
const sections: ChangelogSections = {
features: [], fixes: [], performance: [], docs: [], internal: [], breaking: [],
}
for (const commit of commits) {
if (commit.breaking) sections.breaking.push(commit)
switch (commit.type) {
case "feat": sections.features.push(commit); break
case "fix": sections.fixes.push(commit); break
case "perf": sections.performance.push(commit); break
case "docs": sections.docs.push(commit); break
default: sections.internal.push(commit)
}
}
return sections
}
Breaking commits appear in both breaking[] and their type-based section. The prose step leads with a Breaking Changes section when breaking.length > 0, ensuring breaking changes cannot be missed in the middle of the features list.
What chore: Commits Are Not in the Changelog
The matrix above shows chore, ci, and test commits routing to an internal bucket that is typically omitted from the published changelog. This is intentional: the changelog is a reader document, not a development audit trail. Infrastructure maintenance, dependency updates, and test additions are valuable work, but they do not tell a user what changed about the product.
The internal bucket captures them so nothing is silently lost — you can audit the classification output and confirm that a commit classified as chore was correctly skipped, rather than wondering why it disappeared. The distinction between "omitted" and "dropped" matters for debugging misclassified commits.
Integrating with the Release Process
The changelog pipeline slots into the release process at the tag step:
// .github/workflows/release.yml equivalent
async function onReleaseTag(version: string, sinceTag: string) {
const rawCommits = await gitLog(sinceTag) // commits since last tag
const changelog = await generateChangelog(rawCommits, version)
// Write to CHANGELOG.md
await prependToChangelog(changelog)
await commitAndTag(version, "Update CHANGELOG.md for " + version)
}
The generated changelog is a draft. A human skims it, adds context the commits do not carry, and publishes. The human gate is not optional — sonnet writes from the structured data it is given, and commits often carry less context than the person who wrote them remembers. The gate is lightweight: a five-minute skim before publish, not a rewrite.
Scope and the Reader Experience
The scope component of a conventional commit — feat(auth): add OAuth callback — gives the grouping step additional structure to work with. Within the features section, commits can be sub-grouped by scope, so the reader sees all auth changes together rather than interleaved with unrelated feature additions.
Scope sub-grouping is optional and depends on team size and commit volume. For a small codebase where ten features ship per release, flat grouping by type is clear enough. For a platform team shipping dozens of changes across many components, scope-based sub-groups let a reader of the authentication service skip to auth: entries without reading the full release.
The classification schema includes scope as an explicit field — an empty string when absent, a component name when present. The grouping step can use it immediately: sort by scope within each section, then render scope headers in the prose. Sonnet receives the pre-grouped JSON and writes a section intro that calls out the most significant scope changes.
When the Pipeline Is Not Appropriate
Not every project benefits from automated changelog generation. The pipeline assumes two preconditions:
Commit discipline. If the team does not use conventional prefixes consistently, classification is inference rather than extraction. Haiku will classify a free-form commit like "fixed stuff" as fix with low confidence. The resulting changelog will be correct in structure but wrong in many entries, and the human gate becomes a rewrite rather than a skim.
Meaningful commit granularity. A team that squash-merges every PR into a single commit loses the granularity that makes conventional commits useful. The classification runs once per commit; if each commit represents a week of work, the summary field will be too compressed to be useful. The pipeline assumes commits are small, well-scoped, and consistently prefixed — the preconditions that make the mechanical work tractable.
For teams that do not meet these preconditions, the highest-leverage intervention is improving commit discipline first, then adopting the pipeline. The pipeline is a force multiplier on an existing good practice, not a substitute for one.
What's Next
The next lesson builds the doc registry — the index that makes all other automation possible. Without a registry, a doc-bot has no map of what exists, who owns it, or when it was last verified. The changelog pipeline from this lesson writes its output as a DocRecord with doc_type: "changelog" and generated: true. The registry scanner reconciles that record against the actual file on disk. The self-maintaining stack is assembled piece by piece.