Guides

Releasing

Cut a version of ezz that consuming services can actually resolve, and know when a change forces a major version.

ezz is distributed as a private Go module. There is no registry to publish to and no build step β€” a release is a Git tag, and consuming services resolve it by tag name. That makes releasing cheap, and it also means an untagged change is invisible to everyone downstream.

Write commits the release notes can read

Release notes are generated by changelogithub, which reads Conventional Commits. The format is the subject line:

type(scope): description
PrefixSection in the notes
fix:🐞 Bug Fixes
feat:πŸš€ Features
perf:🏎 Performance
refactor:, chore:, docs:, test:Grouped under their own headings
Any type with !, e.g. feat!:🚨 Breaking Changes, listed first

The scope is optional and reads well as the package: feat(auth): add passkey provider, fix(binding): reject empty content-type.

A commit that is not in this format is silently dropped from the release notes β€” not listed under a fallback heading, just absent. A release whose notes say No significant changes usually means the commits were not formatted, not that nothing shipped. Mark breaking changes with !; nothing else infers them, and the major-version decision below depends on spotting them.

Releasing

There is nothing to run. Push to main and .github/workflows/release.yml does the rest:

It only starts for Go changes

The workflow is filtered to **.go, go.mod, and go.sum. A push that touches only documentation, the docs site, the Makefile, or the workflow itself does not start a run at all β€” nothing appears in the Actions tab, because only these paths can change what a consuming service downloads. go.mod and go.sum are in the list because a dependency bump is consumer-visible without touching a single .go file.

Commits from a filtered-out push are not lost. The version is computed from every commit since the last tag, not from the commits in one push, so a feat: that only edited markdown is still counted by the next run that does touch Go β€” and correctly makes it a minor bump.

It picks the version

The commits since the last tag decide the bump. A feat: makes it a minor, a fix:, perf:, or refactor: makes it a patch, and the highest one present wins.

If the push contains none of those β€” only docs:, chore:, test:, ci:, or unformatted subjects β€” nothing is released and the run stops. Comment tweaks do not burn version numbers.

It verifies before tagging

go mod tidy leaves no diff, go vet, and go test -race ./... β€” which covers both examples, since neither is a separate module. Only then is the tag created and pushed.

It proves the tag is consumable

A throwaway module outside the repository resolves the new tag and imports every documented path. This is the only check that exercises the real consumer path β€” proxy bypass, Git authentication, tag resolution, and module zip contents. Building inside the ezz repository proves none of it, because a local checkout satisfies every import regardless of what the tag contains.

If this fails, the tag is deleted again and no release is published. Consumers fetch direct from GitHub with no proxy cache, so removing the ref genuinely retracts the version.

It writes the notes

changelogithub groups the commits and creates the GitHub release. Preview what it will say before you push:

Terminal
pnpx changelogithub@0.13 --dry --from v1.1.0 --to HEAD
If you ever verify a tag by hand, do not run go mod tidy in the throwaway module. When an import is not satisfied by the tag under test, tidy does not fail β€” it quietly upgrades to a newer tag that does satisfy it, and you get a passing smoke test for a broken release. The workflow asserts the resolved version never moved off the tag, which is the only reason its check means anything.

Forcing a specific version

Automatic bumps never go major, for a reason covered below. To release an exact version β€” a v2.0.0 after changing the module path, or a rebuild of a version the planner would skip β€” trigger the workflow by hand:

Terminal
gh workflow run release.yml -f version=v2.0.0

The version you pass is used verbatim. Every verification gate still runs, so a v2.0.0 whose go.mod still says module github.com/sulv-io/ezz fails at the consume step and the tag is rolled back.

The path filter applies only to pushes. A manual run always proceeds, which is also the way to release a change that never touched a .go file β€” a corrected go:generate directive in a comment, say, or a fixture the tests depend on.

When to bump major

Go enforces semantic versioning through the import path, and it does so only at the major version. Within v1, go get -u will happily move a service from v1.1.0 to v1.9.0, so anything that breaks a caller has to be either avoided or promoted to v2.

ChangeVersion
New route helper, new middleware, new optional fieldMinor
Bug fix with no signature changePatch
Renamed or removed exported identifierMajor
Changed function signature or interface method setMajor
Package moved to a different import pathMajor
A major version is not just a different tag. Go treats v2 and above as a distinct module: the path in go.mod becomes github.com/sulv-io/ezz/v2, every internal import has to be updated to match, and every consuming service rewrites its imports too. The suffix is permanent. Given that ezz has a small and known set of consumers, absorbing a breaking change as a minor bump and updating those services directly is usually the cheaper trade β€” but it is a deliberate choice, not an accident.

This is why the workflow never bumps major on its own. A feat!: commit is released as a minor, with a note on the run summary saying so. Tagging v2.0.0 while go.mod still reads module github.com/sulv-io/ezz produces a tag Go flatly refuses to resolve β€” major version must be compatible: should be v0 or v1, not v2 β€” so an automatic major bump would publish a version nobody can install. Going major means editing go.mod and every import first, then triggering the release by hand.

That trade is what produced the current numbering. The rewrite that moved the framework from core/ to the module root was breaking by any reading of semver, and it shipped as v1.1.0 rather than v2.0.0 because the alternative was a permanent /v2 in every import path to protect consumers that did not exist. The cost is that v1.0.0 is still resolvable and still broken β€” see Installation.

What ships to consumers

The module zip is built from the repository tree, not from a manifest, so anything committed at the tagged commit is downloaded by every consuming service. Two things keep that small:

  • Test-only dependencies are pruned. miniredis is a direct requirement in go.mod, but Go's module graph pruning keeps test-only dependencies of a dependency out of the consumer's graph, so it never appears in a consuming service's go.mod.
  • Test files are included but not built. _test.go files ship in the zip and are ignored unless someone runs tests against the module directly.

Everything else in the tree β€” documentation, fixtures, the docs site β€” is downloaded. It is currently around 157 KB, which is not worth optimizing, but it is worth not committing binaries or vendored dependencies into.

Compiled example binaries are covered by .gitignore. If you add a new example that produces one, add its output path there too rather than relying on the name being caught.
Copyright Β© 2026