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: 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

Posts from blogs I follow

Cold reading an ADHD affliction

I'm sure there are truly pathological cases of ADHD out there, and maybe taking amphetamines really is a magic pill for some folks. But there clearly is also an entire cottage industry cropping up around convincing perfectly normal people that they suffer …

via David Heinemeier Hansson November 20, 2024

You can use almost anything as a key file for your encrypted storage device

Imagine that you have an unencrypted drive containing your private data and one day it starts throwing a bunch of errors. You have backups of the data so you’ve got that part covered, but would you feel comfortable sending the drive in to be warrantied? Yo…

via ./techtipsy November 20, 2024

Testing with Go and PostgreSQL: ephemeral DBs

Let’s say you created a Go program that stores data in PostgreSQL — you installed PostgreSQL, wrote the Go code, and everything works; great! But after writing a test for your code, you wonder: how do you best provide PostgreSQL to your automated tests? Do…

via Michael Stapelbergs Website November 19, 2024

Generated by openring-rs from my blogroll.