Changelogs and Release Notes: From Conventional Commits to an Automated CHANGELOG, and a Deprecation Policy
Generate the CHANGELOG from Conventional Commits and write release notes by hand: the tool derives the version and the list of changes mechanically, but knows nothing about deprecations, migration steps or support dates. We take Bookstore.Contracts to 1.1.0 and 2.0.0 with commit-and-tag-version, warn one release ahead with [Obsolete] and DiagnosticId, write the 2.0 migration notes, and compare the PR-based alternative with release-please manifest mode.
Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), Node.js 24, commit-and-tag-version 13.3, MinVer 8.0.0 and actionlint 1.7.12. The release-please config was validated against its schema and the workflows with actionlint; they were not run on GitHub.
Guess First
The Bookstore.Contracts package of Bookstore, the online bookstore used as the shared example across this site's series, is at 1.0.0. Since that tag, these commits went into the repository:
$ git log --oneline contracts/v1.0.0..HEAD
bea8659 docs: add VERSIONING.md
d42aa12 fix(contracts): show currency in Money.ToString
ff112f5 feat(api): filter books by maxPrice
766285f feat(contracts): add Money and BookDto.ListPrice
024a0f1 build: skip empty contracts releases
070f58b build: add commit-and-tag-version for Contracts
f31d2a2 ci: comment placement
The commit messages follow Conventional Commits, and the release tool is configured to look only at commits that touch the src/Bookstore.Contracts folder. The body of the first feat commit has one more note: "BookDto.Price is deprecated (BOOK001) and will be removed in 2.0.0." What will the tool make the next version, and which lines will the generated CHANGELOG have? Think about two questions in particular: does the feat(api) commit show up, and is the deprecation note visible?
$ npx commit-and-tag-version --dry-run
✔ created src/Bookstore.Contracts/CHANGELOG.md
✔ outputting changes to src/Bookstore.Contracts/CHANGELOG.md
---
## [1.1.0](https://github.com/example/bookstore/compare/contracts/v1.0.0...contracts/v1.1.0) (2026-11-14)
### Features
* **contracts:** add Money and BookDto.ListPrice ([766285f](https://github.com/example/bookstore/commit/766285f989e99a17c2e9d0d12592d0e6d5b321de))
### Bug Fixes
* **contracts:** show currency in Money.ToString ([d42aa12](https://github.com/example/bookstore/commit/d42aa125eb9b9607d6cef343db72f7f762e97c04))
---
✔ committing src/Bookstore.Contracts/CHANGELOG.md
✔ tagging release contracts/v1.1.0
ℹ Run `git push --follow-tags origin main` to publish
The version is 1.1.0: a feat asks for a minor, a fix for a patch, and the higher one wins. The feat(api) commit is missing because it does not touch the Contracts folder; docs, build and ci commits are hidden in the configuration. The deprecation note is missing too: the tool takes only the commit's subject line. Someone using the package cannot learn from this CHANGELOG that Price will go away in the next major. In this post we look at which parts of changelogs, release notes and deprecation communication can be automated and which parts need a human.
Changelog, Release Notes and Commit Log
The three are often confused, but their readers differ. The commit log is for the developer changing the code: it explains what changed and why, step by step. The changelog is for the developer using the package or application: what changed from version to version, grouped and condensed. Release notes are the announcement of a specific version: they describe the impact of the change, migration steps and support information; their reader is sometimes a developer, sometimes a product manager or a customer.
Keep a Changelog proposes a common format for the changelog. The file is written for people; the newest version is at the top, and each version's date is in ISO 8601 form (2026-11-14). Changes are grouped under six headings: Added, Changed, Deprecated, Removed, Fixed, Security. At the top sits an Unreleased section where unreleased changes accumulate; at release time its content moves under the new version's heading. The site's list of bad practices starts with using the commit log as the changelog; not writing down deprecations is on the list as well.
In this post the split will be: we generate the CHANGELOG automatically from commit messages, because "which change is in which version" is a mechanical question. Release notes we write by hand, because migration steps and a support schedule do not fit into a commit message.
From Conventional Commits to a Version
In the "Teamwork with Git" post of the Git & GitHub series we covered the Conventional Commits format: type(scope): description, an optional body and footer. Release automation derives three rules from this structure:
fix(andperfin most tools): PATCH goes up.feat: MINOR goes up.!after the type, orBREAKING CHANGE:in the footer: MAJOR goes up.
Other types (docs, build, ci, refactor, test, chore) do not change the version. With several commits, the highest bump wins. These rules only work if commit messages are honest; a developer who labels a breaking change fix: makes the tool produce a wrong PATCH version. That is why teams enforce the message format with a commit-msg hook or a CI step that checks the PR title; in teams that squash merge, the PR title becomes the commit message, so checking it there is enough.
Version and CHANGELOG Locally: commit-and-tag-version
Three common tools apply these rules: semantic-release, release-please and commit-and-tag-version. The first two are designed to run in CI and on GitHub; the third changes only the local repository: it calculates the version, updates the CHANGELOG, creates the release commit and the tag, and leaves the push to you. It is the easiest way to see the mechanics. We added a small package.json just for release tooling at the repository root:
{
"name": "bookstore-release",
"private": true,
"description": "Release tooling for Bookstore.Contracts",
"scripts": {
"release:contracts": "commit-and-tag-version"
},
"devDependencies": {
"commit-and-tag-version": "13.3.0"
}
}
The configuration lives in .versionrc.json:
{
"tagPrefix": "contracts/v",
"path": "src/Bookstore.Contracts",
"infile": "src/Bookstore.Contracts/CHANGELOG.md",
"packageFiles": [],
"bumpFiles": [],
"releaseCommitMessageFormat": "chore(release): contracts {{currentTag}}",
"noBumpWhenEmptyChanges": true,
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "perf",
"section": "Performance"
},
{
"type": "refactor",
"hidden": true
},
{
"type": "docs",
"hidden": true
},
{
"type": "test",
"hidden": true
},
{
"type": "build",
"hidden": true
},
{
"type": "ci",
"hidden": true
},
{
"type": "chore",
"hidden": true
}
]
}
Three settings matter for a monorepo. tagPrefix is the same prefix we gave MinVer in the previous post: the tool reads the last version from contracts/v* tags and creates the new tag in the same format; MinVer calculates the package version from that tag too. path makes sure only commits that touch the Contracts folder are counted; that is why the feat(api) commit from the guess section stayed out. packageFiles and bumpFiles are empty because the version is not written in any file; the tool takes the current version from the last tag. Without noBumpWhenEmptyChanges, an empty patch release was produced even when there were no relevant commits; that is exactly what happened on the first try.
The real run, followed by the package:
$ npx commit-and-tag-version
✔ created src/Bookstore.Contracts/CHANGELOG.md
✔ outputting changes to src/Bookstore.Contracts/CHANGELOG.md
✔ committing src/Bookstore.Contracts/CHANGELOG.md
✔ tagging release contracts/v1.1.0
ℹ Run `git push --follow-tags origin main` to publish
$ git log --oneline --decorate -1
1e561ba (HEAD -> main, tag: contracts/v1.1.0) chore(release): contracts 1.1.0
$ dotnet pack src/Bookstore.Contracts -c Release -o artifacts
Successfully created package 'artifacts/Bookstore.Contracts.1.1.0.nupkg'.
The release commit changes only the CHANGELOG and carries the tag; the package version comes from MinVer. That way the only place the version is written is the tag, and only one tool creates the tag.
Announcing a Breaking Change Early: [Obsolete] and a Deprecation Schedule
The main change in 1.1.0 is carrying the price together with its currency. Turning decimal Price directly into Money would have been a breaking change; instead we added the new property and left the old one deprecated for one release:
using System.Globalization;
namespace Bookstore.Contracts;
public sealed record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount.ToString("0.00", CultureInfo.InvariantCulture)} {Currency}";
}
namespace Bookstore.Contracts;
public sealed record BookDto(string Isbn, string Title, string Author, decimal Price)
{
[Obsolete("Use ListPrice, which carries the currency. Price will be removed in 2.0.0.",
DiagnosticId = "BOOK001",
UrlFormat = "https://example.com/bookstore/contracts/deprecations#{0}")]
public decimal Price { get; init; } = Price;
public Money ListPrice { get; init; } = new(Price, "TRY");
}
DiagnosticId gives the warning its own code; users can suppress BOOK001 deliberately or turn it into an error with TreatWarningsAsErrors. UrlFormat adds the address of the migration page to the warning. Microsoft's .NET library guidance also recommends ObsoleteAttribute on members you plan to remove, with a message that says what to do instead. The Bookstore API reads the old property in its price filter, and the build shows it right away:
$ dotnet build src/Bookstore.Api
src/Bookstore.Api/Program.cs(28,45): warning BOOK001: 'BookDto.Price' is obsolete: 'Use ListPrice, which carries the currency. Price will be removed in 2.0.0.' (https://example.com/bookstore/contracts/deprecations#BOOK001)
The same transition shows up in the HTTP contract. The API built with Contracts 1.1.0 returns both the old and the new field in JSON; clients can move to listPrice at their own pace:
$ curl -s "http://localhost:5000/api/books?maxPrice=300"
[{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":185.50,"listPrice":{"amount":185.50,"currency":"TRY"}}]
A deprecation needs a schedule: in which version it was announced, and in which version or on which date it goes away. The SemVer FAQ recommends shipping the deprecation in at least one minor release before the major that removes the feature. Bookstore's rule is written in VERSIONING.md: a member carries [Obsolete] for at least one minor and only then is removed. On the HTTP API side, the standard way to communicate the same thing is the Deprecation and Sunset headers; we covered these headers from RFC 9745 and RFC 8594 in detail in the misconfiguration and inventory post of the API Security series.
2.0.0: Marking the Breaking Change in the Commit
After one minor release we remove Price. The commit carries both the ! marker and the footer:
$ git commit -m "feat(contracts)!: remove BookDto.Price" \
-m "BREAKING CHANGE: BookDto.Price is removed. Read BookDto.ListPrice.Amount instead; the positional constructor now takes a Money value."
$ npx commit-and-tag-version
✔ outputting changes to src/Bookstore.Contracts/CHANGELOG.md
✔ committing src/Bookstore.Contracts/CHANGELOG.md
✔ tagging release contracts/v2.0.0
ℹ Run `git push --follow-tags origin main` to publish
The generated CHANGELOG section:
## [2.0.0](https://github.com/example/bookstore/compare/contracts/v1.1.0...contracts/v2.0.0) (2026-11-14)
### ⚠ BREAKING CHANGES
* **contracts:** BookDto.Price is removed. Read BookDto.ListPrice.Amount instead; the positional constructor now takes a Money value.
### Features
* **contracts:** remove BookDto.Price ([4bb4dfc](https://github.com/example/bookstore/commit/4bb4dfcf2a6ff27574f8094e4313d5b03301e168))
The description from the footer moved under the BREAKING CHANGES heading; you can read what changed and what to do there. There is one oddity: the "remove BookDto.Price" line sits under "Features", because the commit's type is feat. Conventional Commits has no separate type for removing something. The automated CHANGELOG is mechanically correct but cannot produce Keep a Changelog's Removed and Deprecated headings. In the API's new JSON, the old field is gone:
[{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","listPrice":{"amount":185.50,"currency":"TRY"}}]
Release Notes: Migrating from 1.x to 2.0
Release notes fill the gap the CHANGELOG leaves. The text we put into the GitHub Release description or the package page for Bookstore.Contracts 2.0.0:
## [2.0.0] - 2026-11-14
Bookstore.Contracts 2.0 removes the deprecated `BookDto.Price`. Every price now carries a currency.
### Removed
- `BookDto.Price` (deprecated in 1.1.0 as BOOK001). Use `BookDto.ListPrice`.
### Changed
- The `BookDto` constructor takes a `Money` value instead of a `decimal`.
- JSON: the `price` field is gone; read `listPrice.amount` and `listPrice.currency`.
### Migrating from 1.x to 2.0
1. Update to 1.1.0 first and fix every BOOK001 warning.
2. Replace `book.Price` with `book.ListPrice.Amount`.
3. Replace `new BookDto(isbn, title, author, 420.00m)` with
`new BookDto(isbn, title, author, new Money(420.00m, "TRY"))`.
4. API clients: switch from `price` to `listPrice.amount` before the API moves to Contracts 2.0.
### Support
- 1.x gets security fixes only, until 2027-05-14.
Not a single line of this text could have been derived from a commit message. The migration steps are ordered and checkable: move to 1.1.0 first, clear the warnings, then upgrade to 2.0. JSON consumers are listed as a separate item because they get no warnings from the C# compiler. The support section tells 1.x users how much time they have; we will write the template for that policy in the last post of the series.
A Release PR on GitHub: release-please
commit-and-tag-version runs locally and in any CI, but a person has to run it. On GitHub, the more common approach is release-please: on every push to main it reads the commits and opens or updates a "release PR" that updates the version and the CHANGELOG. When the PR is merged, it creates the tag and the GitHub Release. Manifest mode defines each package in a monorepo by its folder:
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"include-v-in-tag": true,
"tag-separator": "/",
"packages": {
"src/Bookstore.Contracts": {
"release-type": "simple",
"component": "contracts",
"package-name": "Bookstore.Contracts"
}
}
}
Current versions live in a separate manifest file:
{
"src/Bookstore.Contracts": "2.0.0"
}
With tag-separator we put a / between the component and the version, keeping tags in the contracts/v2.0.0 format; MinVer keeps reading the same tags. The simple release type keeps a version.txt with the version in the package folder. The workflow:
name: release-please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- run: echo "Released ${{ steps.release.outputs['src/Bookstore.Contracts--tag_name'] }}"
if: ${{ steps.release.outputs['src/Bookstore.Contracts--release_created'] }}
We validated the config and manifest files against release-please's JSON schema and the workflow with actionlint; it was not run on GitHub. The documentation has an important note: PRs opened with the default GITHUB_TOKEN do not trigger other workflows, so CI checks do not run on the release PR. If you want them, you have to provide a token as a secret.
The choice between the two approaches is about process. With release-please the version becomes visible through a PR; you approve the release by merging, and you can fix the CHANGELOG by hand before the merge. The cost is the dependency on GitHub and setting up the PR flow. Tools such as commit-and-tag-version or semantic-release release in a single CI step; less ceremony, but no chance for a person to see the CHANGELOG before publishing. In Bookstore we use the second path for now, through a workflow_dispatch workflow:
name: release-contracts
on:
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- name: Configure Git
run: |
git config user.name "release-bot"
git config user.email "release-bot@example.com"
- name: Bump version, update CHANGELOG, tag
run: npx commit-and-tag-version
- run: git push --follow-tags origin main
In Production
Keep the CHANGELOG and the release notes separate. The CHANGELOG should be automated and complete; release notes should be selective and written in the user's language. Trying to merge both into one file either breaks the automation or makes the text unreadable.
Create the release commit and tag from one place. The workflow above pushes directly to main; if main is protected by a ruleset, that push is rejected. Either give the release bot a bypass permission or move to a PR-based flow (release-please). Two developers releasing locally at the same time and both creating the contracts/v1.1.0 tag ends with the second one rejected on push; leaving releases to CI prevents that.
Make security fixes visible. A change that closes a vulnerability goes into the CHANGELOG as fix: but does not show up under a Security heading. Add a separate Security section to the release notes and include the CVE number, if there is one, and the affected version range.
Measure the deprecation. An [Obsolete] warning reaches only users who compile. To see clients still using the old field of the HTTP API, look at logs or metrics; before taking Contracts 2.0 into the API, make sure no client still reads the price field.
Common Pitfalls
Publishing the commit log as release notes. A text full of "Merge branch 'feature/stock-dto'", "fix typo" and "wip" lines tells the user nothing; it is also the first item on Keep a Changelog's list of things to avoid.
Shipping a security fix silently. If a commit titled "fix(contracts): validate isbn length" actually closes an injection hole and the release notes do not say so, users do not understand how urgent the update is.
Writing the deprecation only in the commit body. As we saw in the guess section, the automated CHANGELOG does not show the body. Put the deprecation into the code with [Obsolete] and into text with VERSIONING or the release notes.
Committing a breaking change without !. The message feat(contracts): remove BookDto.Price produces a minor release; the code breaks and the version number does not say so. A rule that checks PR titles, or at least a warning asking for ! on titles containing "remove" or "rename", catches most of these mistakes.
Forgetting the path filter in a monorepo. Without the path setting, every feat in the API bumps Contracts' minor, and users download a new version of a package that did not change.
A Small Experiment
★☆☆ Remove the path line from .versionrc.json and run --dry-run. Does the feat(api) commit get into the CHANGELOG, does the version change?
★★☆ Add { "type": "deprecate", "section": "Deprecated" } to the types list and make a deprecate(contracts): ... commit. Does the new heading appear in the CHANGELOG, does the version go up? What would it cost the team to use this type?
★★★ Try managing an Unreleased section for Bookstore.Contracts 2.1.0 by hand: write the changes into CHANGELOG.md under Keep a Changelog headings and compare them at release time with the section the tool generates. Which information exists only in the hand-written text?
Contracts' version, CHANGELOG and release notes are ready; what remains is the package itself. In the next post we look at package metadata with dotnet pack, catching accidental breaking changes in the build with package validation, and publishing to nuget.org with trusted publishing instead of an API key.