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
braidexists; 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
releasebranch is not branch-protected against the Actions token – CI fast-forwards it withGITHUB_TOKEN. - The releaser can push directly to
master.cargo releasecommits the bump and pushes it tomaster(not via a PR), so any required-PR ruleset onmastermust exempt the releaser, orjust releasefails mid-run after the local commit/tag. - Run from
nix develop .#release(providescargo-release,cargo,gh,juston the Mac; the default devShell is Linux-only and has nocargo).
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:
| Level | From | To |
|---|---|---|
patch | 0.0.1 | 0.0.2 |
minor | 0.0.1 | 0.1.0 |
major | 0.0.1 | 1.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 finalgit push origin <commit>:refs/heads/releasecreates thereleasebranch (the ref does not exist yet, so the first push makes it), andgh release createcuts the first GitHub release (no pre-existing release required). Thev0.0.1release 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.