A small internal CLI that produces release notes with deterministic structure and constrained LLM creativity:
- Structure (sections, ordering, PR links, version headers) —
git-cliff+ghCLI. Fully deterministic. - Prose (rewritten entries + release summary) — Vercel AI SDK call whose output is zod-validated. The LLM cannot invent PR numbers, drop entries, reorder, or change links.
One tool, multiple projects, multiple LLM providers (Anthropic / OpenAI / Bedrock).
- bun — runtime
- git-cliff — conventional-commit parser
- gh CLI, authenticated — for PR title/body enrichment
- An API key for one of the supported providers
cliff-notes is published as a raw Bun source repo (no compiled binary yet). Two ways to use it:
# Ad-hoc, no install
bunx github:a2-ai/cliff-notes --help
# As a devDependency of the consuming project
bun add -d github:a2-ai/cliff-notesDrop a cliff-notes.toml at the root of the project that needs release notes. See cliff-notes.example.toml in this repo for the full schema. Minimum viable config:
[provider]
name = "anthropic"
model = "claude-sonnet-4-6"
[project]
name = "my-project"
audience = "end-users of the application"
voice = "clear, user-focused, concise, no marketing fluff"Then export the relevant API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or AWS credentials for Bedrock).
If the project doesn't already have a cliff.toml, cliff-notes uses a bundled default. Override via git_cliff.config = "path/to/cliff.toml".
By default, cliff-notes runs a curation pass before rewriting entries. Commits sharing a PR number are grouped deterministically; remaining commits can be grouped or omitted by the model when the schema validates the full partition. Omission decisions use project.audience as free-form guidance, so external-user or operator notes can skip test-only and internal-only churn while maintainer notes can keep those changes when useful. Set [curation] strategy = "by-pr-only" for deterministic PR grouping only, or "off" for one bullet per commit. Use --show-curation to print the proposed groups and omissions before rewrite.
GitHub PR enrichment reuses existing credentials: GITHUB_TOKEN, GH_TOKEN, or gh auth token. In GitHub Actions, GITHUB_TOKEN and GITHUB_REPOSITORY are enough for git-cliff PR enrichment; locally, an authenticated gh CLI is sufficient. Set [github] enabled = false to skip token/repo resolution.
cliff-notes always fetches PR data from the GitHub API when gh is reachable (git-cliff enrichment for per-commit PR numbers, plus gh pr view for title/body/url/author/labels). The API is the preferred source whenever available. The repo's merge method only changes how much PR metadata survives in local git as a fallback when the API is unreachable (offline CI, [github] enabled = false):
- Squash and merge, with the repo setting "Default commit message → Pull request title and description", is the friendliest. The squash commit subject becomes the PR title +
(#N)and the body becomes the PR description, so cliff-notes recovers title, body, and PR number from local git alone — and the curation pass short-circuits (one commit per PR, nothing to group). Best results even fully offline. - Merge commit preserves the individual commits and puts the PR title in the merge commit body, but the constituent commits need API enrichment (or the rev-list fallback) to learn their PR number.
- Rebase and merge appends
(#N)to each commit but drops the PR title/body from git entirely, so it leans hardest on API enrichment for any prose richer than the raw commit subjects.
This is guidance for graceful degradation, not a reason to skip fetching — when the API is reachable it's always used.
# Preview release notes for everything since the last tag, no disk write
cliff-notes --unreleased --dry-run
# Same, but splice into CHANGELOG.md as an [Unreleased] section
cliff-notes --unreleased
# Tagged release — splice a new ## [v1.2.3] block before existing releases
cliff-notes --tag v1.2.3
# Write to a standalone file instead of CHANGELOG.md
cliff-notes --tag v1.2.3 --out release-notes.md
# Skip the confirmation prompt
cliff-notes --tag v1.2.3 --yes
# Print grouping and omission decisions before rewrite
cliff-notes --unreleased --dry-run --show-curationEither --tag <version> or --unreleased is required — cliff-notes does not infer version numbers.
Each generated section ends with an HTML comment containing the raw git-cliff entries:
## [v1.2.3] - 2026-05-13
<summary prose>
### Features
- Added foo endpoint ([#123](https://github.com/...))
<!-- cliff-notes:raw v1
- Features(api): add foo endpoint (PR #123)
-->Grouped entries list every member commit in the audit block. Omitted commits do not appear as rendered bullets, but they are still listed in the audit block with the model's reason. The block makes drift between raw commits and LLM rewrites diffable in code review. The marker version (v1) lets future cliff-notes re-render from the raw input without re-querying git.
cliff-notes does not configure goreleaser. Below are two opt-in patterns you can wire into your own .goreleaser.yaml.
Keep cliff-notes out of CI entirely. Generate CHANGELOG.md locally, commit it, and let CI extract the relevant section at release time:
# Local, before tagging:
cliff-notes --tag v1.2.3
git add CHANGELOG.md && git commit -m "chore: release notes for v1.2.3"
git tag v1.2.3 && git push --follow-tags# .goreleaser.yaml
version: 2
changelog:
disable: true # turn off goreleaser's auto-changelog
before:
hooks:
- bunx github:a2-ai/cliff-notes --extract {{ .Tag }} --out .release-notes.mdThen invoke goreleaser release --release-notes .release-notes.md. The extracted file is the exact prose from your CHANGELOG.md section, with the audit comment block stripped.
If you'd rather not pre-hook in goreleaser, do the extract inline in your release workflow:
# .github/workflows/release.yaml
- run: bunx github:a2-ai/cliff-notes --extract ${{ github.ref_name }} --out release-notes.md
- run: goreleaser release --clean --release-notes release-notes.md| Provider | Config name |
Default env var | Notes |
|---|---|---|---|
| Anthropic | anthropic |
ANTHROPIC_API_KEY |
System prompt is cached via cacheControl: ephemeral — re-runs during iteration only pay for the entries payload. |
| OpenAI | openai |
OPENAI_API_KEY |
OpenAI applies prompt caching automatically when the prefix exceeds 1024 tokens. |
| Bedrock | bedrock |
(AWS standard chain) | Set aws_profile in [provider] and AWS_REGION in env. Cache control mirrors Anthropic. |
Override at the command line: --provider openai --model gpt-4.1.
bun testUnit tests cover render, merge, extract, and schema-validation logic. The LLM call is mocked at the SDK boundary; provider switching is exercised by running the same input with two configs and asserting structural parity (only prose differs).
- No GitHub Release creation — goreleaser owns that.
- No tag creation — dev does that manually after reviewing the diff.
- No semver bumping / version inference —
--tagis required for tagged releases. - No streaming output.
- No compiled-binary distribution yet (deferred follow-up).