Build in the Open, Part 11: Releases — Cadence, Pre-Release vs. Release, SemVer & Changelogs

TL;DR: A release is just a tagged, named, downloadable snapshot of your project plus notes that tell people what changed. Use Semantic Versioning (MAJOR.MINOR.PATCH) so the number means something, release often instead of hoarding changes, ship pre-releases (alpha/beta/rc, or any 0.x while you’re still stabilizing) to set expectations, keep a human-readable changelog in the Keep a Changelog format, and automate the build so a single git tag produces signed, checksummed artifacts.

Key takeaways

  • SemVer encodes a promise: bump MAJOR for breaking changes, MINOR for new features, PATCH for fixes.
  • Anything below 1.0.0 is implicitly a pre-release — the public API may still change. Ship a 0.x or -rc build before you commit to v1.0.0.
  • Maintain a CHANGELOG.md with an ## [Unreleased] section you fill in as you go, so cutting a release is just renaming a heading.
  • Let a tag trigger the build. Publishing artifacts by hand is how you ship the wrong binary at 2 a.m.
  • Always ship checksums (SHA256SUMS) so users can verify what they downloaded.

This is Part 11 of Build in the Open, a 14-part series on taking a software project from a blank idea to a public release using GitHub and Claude Code. Each post teaches a technique you can apply to any project in any language, then shows how the open-source GopherTrunk scanner does it for real.

In this post

  • What a release actually is — and why “tag, notes, artifacts” is the whole game.
  • Semantic Versioning — what each number promises, and how to bump it.
  • Pre-releases — alpha/beta/rc, the 0.x convention, and why you want one before v1.0.0.
  • Release cadence — why shipping small and often beats the big-bang release.
  • Writing changelogs and release notes people will actually read.
  • Automating the build so a tag produces checksummed artifacts.
  • How GopherTrunk does it, end to end, with real commands.

What is a release, really?

Strip away the ceremony and a release is three things:

  1. A tag — an immutable git pointer (v0.4.5) to one exact commit.
  2. A name and notes — a human-readable summary of what changed.
  3. Artifacts — the built binaries, archives, or packages users download, plus checksums so they can verify integrity.

Everything else — version policy, changelogs, automation — exists to make those three things trustworthy and repeatable. If you get the tag right and the build is reproducible from it, you can always reconstruct exactly what a user is running.

Semantic Versioning: make the number mean something

Semantic Versioning (SemVer) is a contract written into the version number MAJOR.MINOR.PATCH:

  • MAJOR (1.x.x2.0.0): you broke backward compatibility. Users must read the notes before upgrading.
  • MINOR (1.2.x1.3.0): you added functionality, backward-compatibly. Safe to upgrade.
  • PATCH (1.2.31.2.4): you fixed a bug, no API change. Always safe.

The discipline is what makes this useful. If a “patch” quietly changes behavior, the number is lying and your users stop trusting it. When in doubt about whether something is a feature or a fix, ask: could this surprise someone who upgrades without reading? If yes, it’s at least a MINOR.

A subtle but important rule: everything below 1.0.0 is a free-for-all. SemVer §4 says the public API should not be considered stable until 1.0.0. A 0.x version is itself a signal — “I’m still figuring out the shape of this, expect change.” That’s not a cop-out; it’s an honest label.

Pre-releases: alpha, beta, rc — and the 0.x convention

A pre-release is a version you ship knowing it’s not the final cut. SemVer gives you a suffix for this (1.0.0-alpha, 1.0.0-beta.2, 1.0.0-rc.1), and the convention is a rough confidence ladder:

  • alpha — feature-incomplete, expect bugs, for early testers.
  • beta — feature-complete, hunting for bugs.
  • rc (release candidate) — believed shippable; if nothing breaks, this becomes the release.

Two reasons to bother. First, it sets expectations honestly. Second — and this is the one people skip — a pre-release exercises your release machinery on real infrastructure before it matters. The first time you push a tag and watch the build run is exactly when you find out your version-injection is broken or your artifact paths are wrong. Far better to discover that on v0.99.0 than on v1.0.0.

Release often, in small bites

The instinct to batch up “enough” changes into a big release is a trap. Large releases are riskier (more surface area to regress), harder to write notes for, and slower to get feedback on. Shipping small and often means each release is easy to reason about, easy to roll back, and keeps users in the habit of upgrading. Your cadence doesn’t have to be fixed — “whenever there’s something worth shipping” is a perfectly good policy for a side project.

Write a changelog humans will read

A CHANGELOG.md is the project’s release diary. The Keep a Changelog format is the de-facto standard and it’s dead simple:

  • Newest version at the top.
  • An ## [Unreleased] section you append to as you merge changes — so the work is already done when you cut a release.
  • Changes grouped under ### Added, ### Changed, ### Fixed, ### Removed, ### Deprecated, ### Security.

Write entries for users, not for git. “Fixed null pointer in handler” is a commit message; “Scanner no longer crashes when a talkgroup has no name” is a changelog entry. When you cut a release, you rename [Unreleased] to the new version with a date and open a fresh [Unreleased]. That’s it.

GitHub Releases can also auto-generate notes from merged PR titles, which is a great complement to a hand-written changelog: the changelog tells the story, the auto-notes give the complete list.

Automate the build off the tag

Manual release builds are where mistakes live. The pattern that scales:

  1. You push a tag matching a pattern (e.g. v*.*.*).
  2. A CI workflow triggers on that tag, builds every target, injects the version into the binary, computes checksums, and creates the GitHub Release with the artifacts attached.

Inject the version at build time rather than hard-coding it in source — that way the binary can report exactly which release and commit it came from. In Go this is -ldflags "-X pkg.Version=..."; other ecosystems have equivalents. And always publish a SHA256SUMS file so anyone can verify the download wasn’t corrupted or tampered with.

How GopherTrunk does it

GopherTrunk is on SemVer v0.4.5 — squarely in the honest 0.x “still stabilizing” range, with the public API and config schema free to evolve until v1.0.0. Here’s the full release machinery, all real:

The changelog is the source of truth. CHANGELOG.md opens by declaring it follows Keep a Changelog and Semantic Versioning, and the top of the file is always an ## [Unreleased] section. Contributors add a line there as part of every PR — CONTRIBUTING.md literally lists “Add a line to CHANGELOG.md under ## [Unreleased]” as step 3 of sending a pull request. Cutting v0.4.5 was just promoting that section to a dated heading.

A tag triggers everything. .github/workflows/release.yml is wired to on: push: tags: ["v*.*.*"] (plus a manual workflow_dispatch button). Pushing vX.Y.Z builds Windows, Linux, and macOS artifacts for both amd64 and arm64 in parallel, then a final release job flattens them and publishes the GitHub Release.

Version is injected at link time. Each build computes the version, short commit SHA, and UTC build time and bakes them in via ldflags:

-ldflags "-X github.com/MattCheramie/GopherTrunk/internal/version.Version=${VERSION} \
          -X github.com/MattCheramie/GopherTrunk/internal/version.Commit=${COMMIT} \
          -X github.com/MattCheramie/GopherTrunk/internal/version.BuildTime=${BUILD_TIME}"

So gophertrunk version always reports exactly which release and commit you’re running.

Rehearse before you tag. CONTRIBUTING.md’s “Cutting a release” section tells you to dry-run first:

make release-dry-run VERSION=v0.99.0

That builds dist/dry-run/gophertrunk with the same ldflags the real workflow uses, runs ./gophertrunk version against it, and writes a SHA256SUMS file — so packaging or ldflags breakage surfaces before a tag is cut.

Pre-release before v1.0.0. The repo’s stated policy is that “the first production release should be a prerelease (e.g. v0.99.0)” so the full workflow runs end-to-end against live GitHub Actions before v1.0.0 ships. And the release job encodes the SemVer pre-release rule in code: any v0.x tag or any tag with a hyphen suffix (v1.0.0-rc.1) is published with prerelease: true; only a clean v1.0.0+ lands as the “latest” stable release.

Checksums and tag protection. The final job runs sha256sum * > SHA256SUMS over every artifact, and every release ends with the line “All artifacts are SHA-256-checksummed in SHA256SUMS.” Tags themselves are protected: the .github/rulesets/release-tags-protection.json ruleset matches refs/tags/v* and blocks both deletion and update, so a published release tag can never be silently moved to point at different code.

You don’t need a multi-OS matrix to copy this. The portable moves are: pick SemVer, keep an [Unreleased] changelog, trigger the build off a tag, inject the version, ship checksums, and rehearse with a pre-release.

FAQ

When should I release v1.0.0? When you’re willing to promise API stability — that you won’t make breaking changes without bumping to 2.0.0. If you’re still reshaping the public interface, stay on 0.x. There’s no rush; plenty of widely-used software lives happily at 0.x for years.

Do I need a changelog if GitHub auto-generates release notes? They serve different purposes. Auto-generated notes list every merged PR — great for completeness. A hand-written CHANGELOG.md curates the user-facing story and groups changes by type. The best setup uses both, which is exactly what GopherTrunk does (generate_release_notes: true alongside CHANGELOG.md).

What’s the difference between a tag and a release? A tag is a git concept: an immutable pointer to a commit. A “release” is a GitHub feature layered on top — a tag plus a title, notes, and downloadable artifacts. You can have tags with no release, but not a release without a tag.

Why inject the version instead of writing it in a file? A version file can drift from reality — someone forgets to bump it, or a local build reports the wrong number. Injecting Version/Commit/BuildTime at link time means the binary always tells the truth about exactly what produced it.

Should pre-releases show up as the “latest” download? No. Mark them prerelease: true so users who just want the stable build aren’t handed a release candidate. GopherTrunk’s release job does this automatically for any 0.x or hyphen-suffixed tag.

Series navigation

Part 11 of 14 · ← Part 10: Websites, Support Pages & GitHub Pages · Next → Part 12: Optimizing & Securing Your Repository