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

The IPv6 situation on Docker is good now!

Good news, everyone! Doing IPv6 networking stuff on Docker is actually good now! I’ve recently started reworking my home server setup to be more IPv6 compatible, and as part of that I learned that during the summer of 2024 Docker shipped an update that eli…

via ./techtipsy December 20, 2024

Good Reasons for Alts

I originally wrote this a year ago, but just now found it in my drafts. Not sure why I didn't post it then. One flavor of response I got with my post on deanonymizing accounts was roughly: Why not just go ahead and post the list of alts? It'…

via Jeff Kaufman's Writing December 20, 2024

Scaling Bluesky with Paul Frazee

Paul Frazee joins Bryan, Adam, and the Oxide Friends to talk about the inner workings of Bluesky and the AT Protocol. Paul and the Bluesky team have been working on decentralized systems for years and years--very cool to see both the next evolutionary step…

via Oxide and Friends December 19, 2024

Generated by openring-rs from my blogroll.