Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Releasing

Copy-pasteable runbook for cutting a braid release. The design rationale (why the release branch is the channel, why the x86_64-linux build runs in CI, why no-follows is the consumer default, the public-repo trust model) lives in ADR 029.

Prerequisites (one-time)

  • The public Cachix cache braid exists; you have captured its public key (braid.cachix.org-1:...).
  • CACHIX_AUTH_TOKEN (a push token for that cache) is set as a GitHub Actions repo secret.
  • The release branch is not branch-protected against the Actions token – CI fast-forwards it with GITHUB_TOKEN.
  • The releaser can push directly to master. cargo release commits the bump and pushes it to master (not via a PR), so any required-PR ruleset on master must exempt the releaser, or just release fails mid-run after the local commit/tag.
  • Run from nix develop .#release (provides cargo-release, cargo, gh, just on the Mac; the default devShell is Linux-only and has no cargo).

Before releasing

braid does not run the NixOS VM suite in CI, and neither just release nor release.yml requires a VM result. VM coverage is a manual, per-release choice: when a release warrants it, run the suite outside the release automation – either locally:

just test-vm

or by triggering test.yml manually via workflow_dispatch (its only active trigger). Do not re-enable test.yml’s push/pull_request triggers, and do not wire just release to depend on it.

just test-rust (fast, no VM) does gate the release automatically: release.yml re-runs it on the tag, and just release runs a local compile gate (nix build braid-cli-unwrapped) before tagging.

Normal release

From nix develop .#release:

just release <patch|minor|major>

This bumps cli/Cargo.toml + the braid-cli entry in Cargo.lock, commits chore(release): vX.Y.Z, tags vX.Y.Z, and pushes master + the tag. The tag triggers release.yml. Follow CI:

gh run list --workflow release.yml
gh run watch <run-id>

release.yml builds the x86_64-linux binary, pushes it to the braid cache, creates the GitHub release, and – last – fast-forwards the release branch (the consumer channel). Because the FF is last, consumers see the new rev only after the cache is warm and the release object exists.

Pre-1.0 bumps are plain semver:

LevelFromTo
patch0.0.10.0.2
minor0.0.10.1.0
major0.0.11.0.0

So minor jumps to 0.1.0, not 0.0.x – expected, not a surprise.

Consumers upgrade by bumping the lock to the new release tip:

nix flake update braid   # then nixos-rebuild switch

(A consumer may wrap this in a shortcut, e.g. a braid:upgrade shell function.)

One active release tag at a time. release.yml sets queue: max, so a burst of tags all queue (up to 100, FIFO by the time each starts waiting on the concurrency group) and none is dropped. But that order is wait-start time, not dispatch time, so pushing the next tag before the prior release.yml run finishes risks two tags starting out of dispatch order – the older one’s release fast-forward then fails as a non-fast-forward. That outcome is benign for consumers (release only ever moves forward) but shows a red run. So push (or just release) one tag at a time.

Release notes

The GitHub release body is generated by git-cliff from commit subjects, grouped by conventional-commit type (config in cliff.toml). Named types render into stable sections such as Features, Bug Fixes, Documentation, Tests, CI, Build, and Chores; anything unmatched lands in Other. The first release (v0.0.1) is a one-time exception and intentionally has a blank release body; later genuinely empty rendered ranges get a _No notable changes._ placeholder.

Preview the next release’s notes before tagging:

just changelog

(renders commits since the last tag). Before the first v* tag exists, this prints nothing to match the blank v0.0.1 release body. Editing a release body never affects consumers – the release branch fast-forward is what publishes.

The first release

The first release is not special: it is just release patch, the same flow as every later release. The in-tree version is the pre-release 0.0.0, so the first just release patch cuts v0.0.1 (0.0.0 -> 0.0.1); all later runs bump from 0.0.1.

Two first-run-only things happen for free, with no extra steps:

  • release.yml’s final git push origin <commit>:refs/heads/release creates the release branch (the ref does not exist yet, so the first push makes it), and gh release create cuts the first GitHub release (no pre-existing release required). The v0.0.1 release body is intentionally blank instead of a whole-history changelog; git-cliff notes begin with later releases.

Because CI has no VM gate, run the behavioral suite locally before this first cut:

just test-vm
just test-rust

If release CI fails

First rule: never re-run just release after a tag exists – that would bump again.

  • Transient or config-only failure – re-run the existing workflow:

    gh run rerun <run-id>
    gh run watch <run-id>
    
  • Bad tagged code – fix master, then move the same version tag to the fixed commit:

    git push origin :refs/tags/vX.Y.Z
    git tag -d vX.Y.Z
    git tag -a vX.Y.Z -m vX.Y.Z
    git push origin vX.Y.Z
    

Why this is safe: the release fast-forward is the last step, so until it runs release has not advanced and consumers cannot nix flake update to the new rev – a failure at any earlier step (test, build, cache, or gh release create) leaves consumers untouched. Re-running converges: the cache push and gh release create are idempotent, and the FF re-pushes the same commit.