Just about everyone who maintains software has some opinions on how to manage releases. Some prefer fully automated release. Others prefer a slightly more manual approach, with custom automation for their preferred workflows. There are a variety of opinions on how to write release notes. Everyone has their own style. This post is sharing what my current favorite style and tooling is, which is of course, subject to change.
I’d describe my process as mostly automated, but manual enough to feel deliberate. I also like my process to be largely platform-agnostic (i.e., I ignore GitHub “Releases” entirely). I’ve taken bits and pieces, like most people do, from other software that I enjoy.
Most of my software follows conventional commits, which I enjoy both for its human readability, but also for how it lends itself to some automations. The main tools I use are:
git-cliff
: A highly customizable changelog generator that follows conventional commits specsjust
: A modern command runnersemver
-ish scripts: bump versions, make tags, embed shortlog information, etc.- whatever the language-standard package manager is (e.g.,
cargo
,poetry
, etc.)
Prior to this flow I settled on, I used to maintain a Rust rewrite of semver
.
But, it felt odd to have to still customize the process with contrib/
scripts for each repository and call out to some other tool.
Now, I just embed everything into the Justfile
for a repository.
Here are relevant bits as an example from openring-rs
.
# just manual: https://github.com/casey/just
_default:
@just --list
# Update the changelog using git-cliff
_update_changelog version:
#!/usr/bin/env bash
set -euxo pipefail
# Update changelog
if ! command -v git-cliff &> /dev/null
then
echo "Please install git-cliff: https://github.com/orhun/git-cliff#installation"
exit
fi
git-cliff --unreleased --tag {{version}} --prepend CHANGELOG.md
${EDITOR:-vi} CHANGELOG.md
git commit CHANGELOG.md -m "docs(CHANGELOG): add entry for {{version}}"
# Increment the version
_incr_version version: (_update_changelog version)
#!/usr/bin/env bash
set -euxo pipefail
# Update version
cargo set-version {{trim_start_match(version, "v")}}
cargo build --release
git commit Cargo.toml Cargo.lock -m "chore(release): bump version to {{version}}"
# Get the changelog and git stats for the release
_tlog describe version:
# Format git-cliff output friendly for the tag
@git cliff --unreleased --tag {{version}} | sd "(^## .*\n\s+|^See the.*|^\[.*|^\s*$|^###\s)" ""
@echo "$ git stats -r {{describe}}..{{version}}"
@git stats -r {{describe}}..HEAD
# Target can be ["major", "minor", "patch", or a version]
release target:
#!/usr/bin/env python3
# Inspired-by: https://git.sr.ht/~sircmpwn/dotfiles/tree/master/bin/semver
import os
import subprocess
import sys
import tempfile
if subprocess.run(["git", "branch", "--show-current"], stdout=subprocess.PIPE).stdout.decode().strip() != "main":
print("WARNING! Not on the main branch.")
subprocess.run(["git", "pull", "--rebase"])
p = subprocess.run(["git", "describe", "--abbrev=0"], stdout=subprocess.PIPE)
describe = p.stdout.decode().strip()
old_version = describe[1:].split("-")[0].split(".")
if len(old_version) == 2:
[major, minor] = old_version
[major, minor] = map(int, [major, minor])
patch = 0
else:
[major, minor, patch] = old_version
[major, minor, patch] = map(int, [major, minor, patch])
new_version = None
if "{{target}}" == "patch":
patch += 1
elif "{{target}}" == "minor":
minor += 1
patch = 0
elif "{{target}}" == "major":
major += 1
minor = patch = 0
else:
new_version = "{{target}}"
if new_version is None:
if len(old_version) == 2 and patch == 0:
new_version = f"v{major}.{minor}"
else:
new_version = f"v{major}.{minor}.{patch}"
p = subprocess.run(["just", "_tlog", describe, new_version],
stdout=subprocess.PIPE)
shortlog = p.stdout.decode()
p = subprocess.run(["just", "_incr_version", new_version])
if p and p.returncode != 0:
print("Error: _incr_version returned nonzero exit code")
sys.exit(1)
with tempfile.NamedTemporaryFile() as f:
basename = os.path.basename(os.getcwd())
f.write(f"{basename} {new_version}\n\n".encode())
f.write(shortlog.encode())
f.flush()
subprocess.run(["git", "tag", "-e", "-F", f.name, "-a", new_version])
print(new_version)
# Publish a new version on crates.io
publish:
cargo publish
Having a recipe like this means I take 2 steps for any release for a given repository.
1. just release [major|minor|patch]
.
This will open my $EDITOR
with the updated changelog, in case I want to make any tweaks (e.g., manually adding a description/overview or similar) and check for typos and whatnot.
Then, it will open my $EDITOR
with prepopulated contents for an annotated git tag, allowing me an opportunity to do the same.
Note I also embed the output of git-stats
in the tag, which I find informative and a good replacement for normal shortlog
output, since the changelog is embedded as well.
By the end of this step, I’ll have a couple of nice commits (one to update the changelog in isolation, one to bump the version), and an annotated git tag for the release.
If these all look good, I push them to main
.
2. just publish
If there is somewhere I want to publish stuff to (e.g., crates.io, PyPi, etc.), then I run this second command to take care of that.
And that’s it!
One might notice that a significant hole in this is doing something like making build artifacts/binaries for a variety of different platforms, or otherwise doing a variety of packaging.
I haven’t felt the need to do so yet, but if I ever do, I suspect I’d still like to be able to run things with just
.
We’ll see if/when I get to that point.
As a sidenote, one nice artifact of maintaining both the CHANGELOG and annotated git tags, is that I can use an alias like this in ~/.gitconfig
.
[alias]
tlog = tag --sort=-v:refname -l --format='%(color:red)%(refname:strip=2)%(color:reset) - %(color:yellow)%(contents:subject)%(color:reset) by %(taggername) on %(taggerdate:human)\n\n%(contents:body)'
And see nice output like this in a repository (but nicely colorized, which I do not reproduce here).
❯ git tlog
v0.1.13 - openring-rs v0.1.13 by Luke Hsiao on Wed Oct 11 23:39
Bug Fixes
- Make relative urls relative to origin
- Ignore "self" rel on links
Features
- Default to domain name if feed title is empty
$ git stats -r v0.1.12..v0.1.13
Author Commits Changed Files Insertions Deletions Net Δ
Luke Hsiao 3 3 +68 -48 +20
v0.1.12 - openring-rs v0.1.12 by Luke Hsiao on Wed Oct 11 22:49
Features
- Support feeds with relative URLs
$ git stats -r v0.1.11..v0.1.12
Author Commits Changed Files Insertions Deletions Net Δ
Luke Hsiao 6 7 +192 -233 -41
dependabot[bot] 1 2 +5 -5 0
v0.1.11 - openring-rs v0.1.11 by Luke Hsiao on Wed Sep 6 22:36
Bug Fixes
- Log to stderr, not stdout
Documentation
- (README) Fix grammar error
- (README) Suggest using `--locked` on install
Refactor
- Standardize and clarify logs
$ git stats -r v0.1.10..v0.1.11
Author Commits Changed Files Insertions Deletions Net Δ
Luke Hsiao 4 4 +14 -11 +3