Guides

Releasing

How a commit on main becomes a lttr version that consumers can resolve, how to rehearse it with a dry run, and the pre-1.0 policy.

lttr is a private Go module. A release is a Git tag and nothing else: there is no registry and no build artefact. Consumers resolve the tag directly from GitHub. .github/workflows/release.yml computes the next version, proves the tag is consumable, and writes the release notes. An untagged change is invisible to every consumer.

Write commits the release notes can read

The version bump and the release notes both come from Conventional Commits subject lines:

type(scope): description
SubjectBump
feat:Minor
fix:, perf:, refactor:Patch
docs:, chore:, test:, ci:, anything elseNone on its own
! after the type (feat!:, fix!:), or a BREAKING CHANGE: line in the bodyMarks the release as breaking. A patch becomes a minor, and it never goes major (see the pre-1.0 policy)

The scope is optional and reads best as the package: feat(comark): accept single-quoted attributes, fix(render/text): keep ordered list numbers. The notes are written by changelogithub, which groups commits by type. A subject that isn't in this format doesn't count towards the bump and is left out of the notes.

Know what starts a release

A push to main starts the workflow only when it touches a file that ships in the module:

  • **.go, go.mod and go.sum
  • examples/**.md and examples/quiet/assets/**, the example templates and logos that examples/quiet embeds
  • **.tmpl, the embedded HTML partials

A push that only changes documentation, the docs site or the Makefile doesn't start a run. Its commits aren't lost, though: the version is computed from every commit since the last tag, so a feat: that only touched Markdown still counts at the next run that does start.

Runs share one concurrency group, and a run in progress is never cancelled, so two pushes to main can't race to create the same tag.

Follow a release through the workflow

Plan the version

The plan job reads the subject lines since the latest v* tag. With no tag yet it starts from 0.0.0, so the first feat: release is v0.1.0. The highest bump present wins. If there is no feat, fix, perf or refactor commit, or the computed tag already exists, the run stops with nothing released and says why in the run summary.

Verify the source

The verify job checks that go mod tidy leaves go.mod and go.sum unchanged, then runs make check: gofmt, vet, staticcheck, the race tests, the 85% coverage floor and a fuzz smoke run. It is the same gate ci.yml runs on pushes to main and on pull requests.

Tag

The tag job creates an annotated tag on the commit that started the run and pushes it.

Consume the published tag

The consume job builds a throwaway module outside the repository, as a consumer would. It sets GOPRIVATE='github.com/sulv-io/*', points Git at the repository with the workflow's token, and runs go get github.com/sulv-io/lttr@<tag>. Its program imports every package (comark, data, ir, layout, mapbox, render/html, render/text, theme and examples/quiet), builds a Renderer from quiet.Config, and renders all nine examples to HTML, text and MIME:

consumer OK: 9 templates

Before running it, the job asserts that go list -m still resolves lttr to the tag under test. When a tag can't satisfy an import, go mod tidy doesn't fail. It quietly moves to a newer tag that can, and the check would pass against the wrong version. The job also reports the module zip's size in the run summary.

This is the only step that exercises a consumer's real path: proxy bypass, Git authentication, tag resolution and the contents of the module zip, embedded templates included. A build inside the repository proves none of it, because a local checkout satisfies every import whatever the tag contains.

Roll back or publish

If consume fails, the rollback job deletes the tag again and no release is published. Consumers fetch lttr directly from GitHub with no proxy cache, so deleting the ref really does withdraw the version. If consume passes, publish runs changelogithub@0.13 from the previous tag to the new one and creates the GitHub release.

If you ever check a tag by hand in a scratch module, look at go list -m github.com/sulv-io/lttr after go mod tidy. If the version isn't the tag you meant to test, tidy has upgraded past it and your check proves nothing.

Rehearse with a dry run

Start the workflow by hand with dry_run set:

Terminal
gh workflow run release.yml -f dry_run=true

A dry run plans and verifies as usual. It verifies even when there is nothing to release. Then, instead of tagging, the consume-local job runs the same consumer program against the checked-out source through a replace directive, and asserts that lttr resolved to the checkout. The tag, consume, rollback and publish jobs are skipped. Nothing is tagged, pushed or published.

A dry run proves that the import paths resolve and that the example compiles and renders from a clean module. It can't prove tag resolution, proxy bypass or the module zip's contents. Only a real release's consume job can.

Force a version

To release an exact version, run the workflow with version:

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

The version is used as given, and every gate still runs, so a forced tag that can't be consumed is rolled back like any other. The path filter applies only to pushes: a manual run always proceeds. Combine version with dry_run=true to rehearse a forced version.

Stay pre-1.0 until CaribHubs ships

lttr stays below v1.0.0 until CaribHubs, its first consumer, sends its first production email through it. Until then, the API can change between minor versions:

  • A breaking change (feat!:, fix!: or a BREAKING CHANGE: footer) is released as a minor bump, and the run summary says so. Consumers on the previous version aren't broken by go get alone, only when they upgrade.
  • The workflow never goes major on its own. v1.0.0 is a deliberate act: run it by hand with version=v1.0.0.
  • Past v1, Go treats each major version as its own module path. Tagging v2.0.0 while go.mod still says module github.com/sulv-io/lttr produces a tag Go refuses to resolve, so a major bump means changing the module path to …/lttr/v2 and every import first.

CHANGELOG.md records notable changes by hand until v1.0.0. After that, the generated release notes are the record.

Consume a release

A consuming service pins a released tag:

Terminal
go env -w GOPRIVATE='github.com/sulv-io/*'
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/sulv-io/".insteadOf "https://github.com/sulv-io/"
go get github.com/sulv-io/lttr@v0.1.0

The token must be able to read sulv-io/lttr. Inside the workflow, github.token is enough because it reads its own repository. A service in another repository needs a token of its own.

CaribHubs pins a tag in its api/go.mod. A replace directive pointing at a local checkout is fine while developing against an unreleased change, but it never lands on CaribHubs' main. When CaribHubs hits a defect or a gap, it is written up in this repository as docs/superpowers/specs/YYYY-MM-DD-<version>-defects-from-caribhubs-<plan>.md, fixed here test-first, and shipped as a new tag.

lttr.Version is the constant "v0.0.0-dev" in source. The workflow tags commits but doesn't rewrite that constant, so read the version from your module graph (go list -m github.com/sulv-io/lttr, or debug.ReadBuildInfo at run time) rather than from lttr.Version.

Know what ships

The module zip is built from the repository tree at the tagged commit, so everything committed there is downloaded by every consumer, including examples/quiet with its templates and logos, the golden files and the design handoff. Test files ship too, but they are compiled only when someone runs the module's own tests.

Copyright © 2026