My Software Release Process

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 specs
  • just: A modern command runner
  • semver-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:

    @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
        echo "Please install git-cliff:"

    git-cliff --unreleased --tag {{version}} --prepend
    git commit -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:
    import os
    import subprocess
    import sys
    import tempfile

    if["git", "branch", "--show-current"], stdout=subprocess.PIPE).stdout.decode().strip() != "main":
        print("WARNING! Not on the main branch.")["git", "pull", "--rebase"])
    p =["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
        [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
        new_version = "{{target}}"

    if new_version is None:
        if len(old_version) == 2 and patch == 0:
            new_version = f"v{major}.{minor}"
            new_version = f"v{major}.{minor}.{patch}"

    p =["just", "_tlog", describe, new_version],
    shortlog = p.stdout.decode()

    p =["just", "_incr_version", new_version])
    if p and p.returncode != 0:
        print("Error: _incr_version returned nonzero exit code")

    with tempfile.NamedTemporaryFile() as f:
        basename = os.path.basename(os.getcwd())
        f.write(f"{basename} {new_version}\n\n".encode())
        f.flush()["git", "tag", "-e", "-F",, "-a", new_version])

# Publish a new version on
    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.,, 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.

    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

- 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

- 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

- (README) Fix grammar error
- (README) Suggest using `--locked` on install

- 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

Posts from blogs I follow

My BDFL guiding principles

The thing about me being a BDFL for curl is that it has the D in there. I have the means and ability to push for or veto just about anything I like or don’t like in the project, should I decide to. In my public presentations about curl I emphasize that I t…

via May 27, 2024

How To Sell To Developers

One of the hardest challenges I know is how to sell to developers. This is NOT an article for developers. Today, I want to write for non-developers whose job it is to sell to developers. My goal is to help you understand how they think. …

via Matthias Endler May 27, 2024

Instead of "auth", we should say "permissions" and "login"

Most computer systems we interact with have an auth system of some kind. The problem is, that sentence is at best unclear and at worst nonsense. "Auth" can mean at least two things: authentication or authorization1. Which do we mean for an "auth system"? I…

via blog May 27, 2024

Generated by openring-rs from my blogroll.