How to automate proper CHANGELOGs in software projects with many contributors.
Includes suggested practices and tools for JavaScript or TypeScript development, but only requires npm or similar to execute.
Changelogs are an excellent way to communicate updates between versions of a project. However, they can also be messy, overly technical, verbose, or generally unhelpful. For large projects with regular releases, it can be time-consuming to track down every change and document it properly.
In my case, I have a GitHub project with approximately 30 contributors, 5 maintainers (to decide milestones and review pull requests), and 100s of consuming projects whose operators each require one of two different levels of technical explanation.
The task of creating a proper release process - which prioritized documentation without drowning developers in extra work - seemed daunting at first. I'm here to say it was much more straightforward than I imagined. This blog post will walk you through how I automated the creation of ✨quality✨ changelogs in this project from our commits.
The Task
Taking inspiration from Gene Kim's "3 Ways of DevOps", I set up the project concentrated on a goal of
"Never pass a known defect to downstream work centers".
This meant advocating for and enabling practices like Test Driven Development and putting those checks in early in the product lifecycle.
Coming back to changelogs, I wanted to similarly shift our documentation work left by setting up an understandable contract with my fellow contributors and keeping the onus to wrangle commits and document changes off of the maintainers.
This meant enabling developers in 3 stages of the SDLC by introducing:
- Clean git Process
- Tools for Formatting Commits
- Workflows for Generating Changelogs
Surprise, An Elephant Appears!
To achieve our end goal of "No manual changelogs", we have to first address a potential elephant in the room: Not everyone agrees you should automate changelogs the way I describe here. One of the fastest ways to discover this is to visit https://keepachangelog.com which uses the clever tagline:
"Don’t let your friends dump git logs into changelogs."
The site goes on to describe some guiding principles, which I would encourage you to check out. The TL;DR is:
- Follow Semantic Versioning (Or mention your strategy)
- Log every version
- Group your changes by type
- Write Changelogs for Humans and NOT Machines
There is clearly a gap between git logs and what we want consumers to read for our changes. The next section will tackle what processes we want in place to make our commits resemble human-readable changes, and the following section will discuss tooling to make that process obtainable by your team.
As for the elephant, only you can determine your trade-offs. Make your decision of what to automate a deliberate one, that reflects the long-term goals for your project.
Clean git Process
In order to enable our automation with specialized tools, we will need to meet a convention with our commit messages and version tags. The standards I'm referring to here are helpfully called Conventional Commits and Semantic Versioning.
Semantic Versioning
This is a very common versioning standard met by most npm packages (in the case of JS). The important part of our versioning strategy is that it controls when we will generate changelogs: The diff in commit logs between the previous version tag and the current version tag represents the new information entering our changelogs on a release. In the case of semver, these are 3 sets of numbers that increment based on the impact of your changes. See Semantic Versioning for more info.
Conventional Commits
This is how we can parse information about the change, such as the type, from the commit. See the full spec at Conventional Commits .
This brings with it the concept I find the most difficult in practice:
Each commit should represent a single change of a known type and optional scope.
For example: fix(core): remove bad behavior xyz
This is a big step up from the bad habits many of us start with when using git. However, it becomes much easier with some tools, a little practice, and the right merge strategies set up in GitHub.
Branch and Merge Strategies
With the commit history theoretically reflecting our change history 1:1, the cleanest results will come from simple branching strategies, like trunk-based development, where everyone cuts features from a common main branch (which requires Pull Requests to update). Requiring your feature commits to be squashed in Pull Requests by configuring the Rebase+Squash merge strategy in GitHub (instead of the default) will also keep the history clean and make it easier to avoid issues with the upcoming tools I want to talk about.
Tools for Formatting Commits
With our standards defined, we can reach into our toolbag and grab some CLI leverage to help unify the contributors and communicate what we need for success. The tools I mention here are geared for JS/TS projects that utilize package runners like npm. Keep in mind other projects could utilize them with a little extra work if you are willing to use Node.js or make some alterations.
Commitizen
Writing in the conventional commit style I mentioned above is pretty straightforward, but not everything you and your fellow contributors work on will have the same requirements. Also, accidents happen. It helps to have a way to construct these messages the same way every time.
commitizen is a CLI tool that you can introduce to create commits with your rules, which handily default to the Conventional Commit format! I installed commitizen from npm into my project's devDependencies so that the whole team has the same ruleset. To continue setting up without relying on global installs, I used npx:
npx commitizen init cz-conventional-changelog
This command updates the rest of what we need in our project's package.json
file with the standards we already set
"devDependencies": {
...
"cz-conventional-changelog": "^3.3.0"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
Once you get to this point, you can fire off an npx cz
and see what we want the developer to see when making their commit:
cz's nice command prompt makes it where we don't leave anything needed out of our commit, and problems like character count in the subject line get called out as we write our messages.
Scope, change type, issues/tickets affected, and breaking changes are all accounted for.
Husky
Now that we have a tool to help us make great commits, let's make it the default way anyone commits to the project. We can set the expectation for contributors to use the command line if they want help getting the right commits every time.
Husky is my tool of choice for setting up git hooks. With this tool, we can make sure that every step of the commit process has passed the checks we care about. Unit tests, linting, Typescript builds, and more can be called from scripts every time a user walks through their commit process to push to the GitHub repository from their local machine.
To ensure developers have access to the same hooks you do, you can install this package into your devDependencies. Besides installing using npm, yarn, etc - We also need to run an npx husky install
to set up the hooks. To ensure this happens on the other developer workspaces, we slap this bit into our prepare block of the package.json:
"scripts": {
"prepare": "is-ci || husky install",
Here, is-ci is another optional devDependency that will keep hooks from registering in your remote CI environments, where you might be adding chore commits, tags, etc.
Following cz's docs we can then tap into the git commit command prompt using:
npx husky add .husky/prepare-commit-msg "exec < /dev/tty && npx cz --hook || true"
Then BOOM, when a contributor installs your project and types "git commit", they will be met with the same CLI we saw in the previous section. Pretty snazzy 😎
Commitlint
So now all of our developers are automagically 🪄 making good commit messages, right?
If you want to ensure that what makes it into the feature branch will be usable for the last step of converting git logs to changelogs, we need one more thing: commit linting.
As you probably guessed by now, yes, there are npm packages for that.
Installing @commitlint/cli and @commitlint/config-conventional and setting up a post-commit or pre-push git hook with husky is one option. Another is logging your latest commit on your pull requests and running merge checks from, say, a GitHub Workflow. Ultimately, it depends on how and where you want to communicate problems in the incoming change.
An example of the latter can look like:
git log -n 1 --format=%B | npx commitlint
Automatically Generating Changelogs
conventional-changelog-cli
Lastly, with the work we have put into setting up our repo, we can move from sowing to reaping with one more handy tool. conventional-changelog-cli can be used whenever and wherever you are handling a version change in your project. Their documentation on the linked npm page walks you through how to configure it to the "npm version" command, but if you need to simply run the package after bumping your version(s), you can use:
npx conventional-changelog-cli -i 'CHANGELOG.md' -s -t v -p angular
from an environment that has access to all the previous git tags. Using the conventional-changelog-cli repository as an example, you can see the types of logs you can expect from this CLI:
That's our goal. We asked the contributors to use the terminal for their git commits and in turn we can automatically generate changelogs based on every pull request to our repository. Obviously, this still requires understandable commit messages from developers, but that feedback is now visible on the Pull Request with the rest of the incoming work. No more hunting down a single contributor for context on release day when a feature takes longer than you both expected.
Although nothing is ever complete in terms of automation, taking the time to enable every contributor to consider the changes they commit and communicate them properly has helped our releases become less stressful. I hope the processes and tools I've mentioned show you similar results, or help you consider the options that make documentation easier and clearer.
But what do you think?
- Let me know if you Agree or Disagree with my tools and processes.
- What are you using for changelogs (or other release automation)?
- What more would you like to read about this topic (such as handling changelogs in a monorepo)?
Keaten Holley
https://github.com/Keatenh
Top comments (4)
Really awesome article. Thank you @keatenh ! I'll have to try testing some of these tools out in one of my repos I'm working on.. I've seen people use them in the past and they look awesome, I just never really knew how it was all interconnected. :P
An article on monorepos would be nice to read about as well as the importance of versioning + branching strategies.
If I have an IaC monorepo that contains modules which are ultimately consumed by many different teams. What are some ways to deal with changelogs and versioning?
Imagine a repository layout structured like the above snippet..
I can version the repo itself but if my changes for this release are not actually updating
moduleABC
and instead onlymoduleXYZ
or even some other dependency file then you've just updated metadata aboutmoduleABC
because the repository itself is versioned. Do you instead come up with some automation/process that gives each module their own versioning scheme and you tie those back to the overall repo version? Something like:my-awsome-project_v1.0.0_v0.15.0
wherev1.0.0
is the repository version andv0.15.0
is the module version?After versioning schemes are hashed out, what would a changelog like this look like? Do you leave it so that both versions from the previous example show up or do something else?
If you're doing work for a company, chances are the commits made to this example project are going to be because someone asked for them as a part of some initiative. Having a global manifest for your platform is a nice to have so that upper management (or whoever) can look at this manifest and get a compiled list of changes about to be released. In the example above, that was just specifically IaC but you could have more projects elsewhere actually containing developer code. Having a good way to bring those all together is nice.
Example of what a manifest could look like:
Thanks Jon! Good insight into problems that arise when creating a monorepo for a distributed project.
Versioning is its own beast but for npm projects specifically, I believe more modern tools like nx and turborepo try to handle these natively for monorepos beyond what the standard npm/yarn workspaces can. For the project mentioned here we keep a single semantic versions across packages, due to how they are used. This is the most likely example for a potential followup post.
Customizing your versioning would likely require testing with the tools listed by simply tagging a commit how you prefer and seeing if the logs correctly parse the incoming changes as a new version. The changelog generator allows options like "version prefix" and "config file", where you provide a config script with options of how the core changelog generator behaves.
The changelog generator also provides an option to provide --commit-path which lets you customize granularity for changes. In my case, I use this option when releasing packages in my monorepo so that the user does not see unrelated changes.
Manifests do seem powerful but I do not manage anything custom for this. The focus on human readable commits & changelogs definitely open up the door for something more suitable for automation and interface with other systems.
Your article is very detailed and well constructed. Thank you @keatenh !
However, it is aimed at a very technical audience. I recently launched changelogit.com, a tool that uses commits to generate a less technical changelog aimed at a more product-oriented audience (whether internal teams or end customers).
Don't hesitate to have a look and let me know what you think.
One word: monorepos