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

In Response To Google

Google has chosen to send a response to my article to Barry Schwartz of Search Engine Roundtable. Here is my response. (1) On the March 2019 core update claim in the piece: This is baseless speculation. The March 2019 core update was designed to improve th…

via Ed Zitron's Where's Your Ed At April 25, 2024

Day 11: Visual improvements to the order view

Yesterday, a new release of Dashify went live! The update included improved styles to the order page. The action buttons in the top right corner are more consistent with each other, and the products, taxes, and other line items are separated instead of all…

via John Jago April 25, 2024

All we have to fear is FUD itself

The Oxide Friends have talked about the Hashicorp license change, the emergence of an open source fork of Terraform in OpenTofu, and other topics in open source. A few weeks ago both InfoWorld and Hashicorp (independently?) accused OpenTofu of stealing Ter…

via Oxide and Friends April 25, 2024

Generated by openring-rs from my blogroll.