How we turned merge commits into customer-facing release notes
Raw commit logs are for engineers. Customers need something that makes sense to them. This is how we built it.
Every team needs changelogs.
The usual options are all bad.
We needed something that could bridge that gap automatically. Something that understood the intent behind the changes (from Linear tickets), the reality of what changed (from the code), and could synthesize both into something a non-technical stakeholder would actually find useful.
Every PR to qa or main gets a changelog. On merge, stakeholders get it by email.
When a PR targets a release branch, the pipeline collects three sources of truth: Linear issue details, commit messages, and the actual git diff. The LLM reads all three and produces a structured changelog grouped by project and category. The result appears as a PR comment. When the PR merges, the same changelog goes out by email.
No manual step. No one has to remember to write release notes. The changelog exists because PRs exist, containing clear commit messages and raw diffs, linked to Linear, where ideas are well documented.
The script starts by fetching every commit in the PR through the GitHub API, with pagination to handle large PRs:
const commits: GitHubCommit[] = []
let page = 1
let hasMore = true
while (hasMore) {
const { data } = await octokit.rest.pulls.listCommits({
owner,
page,
per_page: 100,
pull_number: prNumber,
repo,
})
commits.push(...data)
hasMore = data.length === 100
page++
}From those commits, it extracts Linear issue IDs using a simple regex against the commit messages. Our convention is to include the ticket ID (e.g., PROJ-123) in every commit, so the pipeline can trace each change back to its source.
The key insight is that each data source has different strengths:
The prompt makes this hierarchy explicit. The LLM is told to prefer Linear issue descriptions for wording, use commits for structure, and consult diffs only when the other two sources are ambiguous. This produces output that sounds like a product update, not a git log.
The collected data is assembled into a structured prompt and sent to the LLM via the Vercel AI SDK:
const { text: changelogContent } = await generateText({
model: anthropic('claude-sonnet-4-6'), // Replace with the latest model
prompt: prompt,
system:
'You are a changelog generator. Output ONLY the formatted changelog content...',
})The prompt specifies the exact format: an h1 title, h2 headings per project, h3 category headings (Features, Bug Fixes, UI Improvements), and each item linked back to its Linear ticket. Internal changes like refactors, config updates, and test additions are filtered out unless they have visible user impact.
Like our E2E video pipeline, the changelog uses an HTML comment marker to find and update its own PR comment:
const changelogComment = comments.find(
(comment) =>
comment.body?.includes('<!-- linear-changelog-bot -->') &&
comment.user?.type === 'Bot',
)Push a new commit, and the changelog regenerates and updates in place. Reviewers always see the latest version without comment clutter.
When a PR is merged, the markdown changelog is converted to HTML and emailed to stakeholders:
const converter = new showdown.Converter()
const htmlContent = converter.makeHtml(changelog)
await transporter.sendMail({
from: EMAIL_FROM,
to: EMAIL_TO,
cc: EMAIL_CC.join(', '),
subject: `📝 Project Changelog - ${environment} (${targetBranch}) - ${shortDateTime}`,
html: emailBody,
})The email maps the branch to an environment name (qa becomes "staging", main becomes "production"), so recipients immediately know where to look.
Getting useful output from an LLM comes down to the prompt. Ours does a few things deliberately:
It provides raw data, not a summary. The full Linear issue descriptions, all commit messages with SHAs and authors, and the actual diff patches (truncated at 500 characters per file) are included. The LLM has everything it needs to make judgment calls about what's user-facing and what's internal.
It prevents duplication. The prompt explicitly states: "If a Linear issue already describes a change, do not repeat it based on commit messages or code changes." Without this, you get the same feature mentioned three times under different headings.
It specifies format exactly. The prompt includes a complete template with headings, bullet point format, and link format. This eliminates formatting drift across runs.
It filters aggressively. The instruction to skip backend changes, config files, build scripts, and refactoring unless they have visible user impact cuts the noise dramatically. A 50-file PR might produce a five-item changelog, and that's the point.
The best changelogs are a byproduct of a good process. If your PRs have clear commit messages, link to well-documented tickets, and carry meaningful diffs, the LLM has everything it needs. The pipeline just connects the dots. No extra writing, no forgotten releases, just a changelog that stays in sync with what actually shipped.
Dites-nous ce que vous construisez. Nous vous dirons comment nous l'aborderions, ce qu'il faut, et à quelle vitesse nous pouvons avancer.
Nous vous dirons honnêtement si nous sommes le bon choix. Et si ce n'est pas le cas, nous vous orienterons vers quelqu'un qui l'est.