Releasing
Go modules resolve from git tags, so the tag is the release — the workflow’s job is to refuse to publish one that shouldn’t exist, then create the GitHub release for the one that should.
Cutting a release
Section titled “Cutting a release”Between releases, Version carries a -dev suffix; a release drops it.
-
Set
Versioninversion.goto the release version and give the release itsCHANGELOG.mdsection, with today’s date and a link at the bottom. -
Commit, push, and let CI go green on
main. -
Tag and push:
Terminal window git tag -a v1.2.0 -m "catena v1.2.0"git push origin v1.2.0 -
After the release publishes, bump
Versionback to the next-devversion.
Tag only when you mean it. A pushed tag on a public Go module is
cached by the module proxy on first fetch, permanently — the checksum
database is append-only. Deleting a tag does not delete the cached
version; a tag that should not exist can only be retracted in go.mod,
never truly withdrawn. So a tag is cut once, at the end, on purpose.
The Release workflow then:
- Verifies the tag agrees with
Version— a release whose code reports a different version than the tag that produced it is worse than no release, so nothing is published until they match. - Runs the full gate on the tagged commit: gofmt, vet,
generate-idempotency (
list_gen.gomust be current), the race-enabled suite, the 100% coverage check on the library’s packages, the examples, and the linter’s own gate. - Publishes the GitHub release, with the tag’s
CHANGELOG.mdsection as its notes.
No artifacts are built: this is a library, and go get fetches the tag
directly. A failed publish can be retried without moving the tag via the
workflow’s manual dispatch (Actions → Release → Run workflow, giving the
existing tag).
The linter
Section titled “The linter”lint/ is a module of its own, so Go finds its versions under a prefix:
lint/v1.5.0, not v1.5.0. It is versioned in lockstep with the
library — every library release tags the linter with the same number, at
the same commit, whether or not the linter changed. One number to
remember, and the linter for a given catena is never a lookup.
Once the library’s release has published, tag the linter on the commit the library’s tag points at:
git tag -a lint/v1.5.0 -m "catena lint v1.5.0" 'v1.5.0^{commit}'git push origin lint/v1.5.0That tag does not start the Release workflow, which matches library
tags only; there is nothing for it to publish, and the library’s release
gate has already run the linter’s tests on that commit. The linter needs
no Version constant or changelog section of its own: its changes are
recorded in CHANGELOG.md under the release they landed with.
The same permanence applies — the proxy caches a lint/ tag exactly as
it caches any other — and so does semantic versioning. The linter is past
v1, so a minor release may add a check or change what a report offers,
and a patch may only correct one.
The website
Section titled “The website”website/ builds a documentation site from this repository:
scripts/sync.ts turns docs/, examples/ and CHANGELOG.md into its
pages, and the generated output is committed. CI fails if it is stale, so
a deploy always publishes what was reviewed rather than something
generated unseen.
The Docs workflow publishes it to the Cloudflare Pages project
catena-docs, live at https://catena.nerdmenot.in (the project also
answers on its catena-docs.pages.dev subdomain, but the custom domain is
the canonical one — see siteUrl in website/scripts/sync.ts).
Only a released tag is ever deployed. The site names the version it
documents, and its content is derived from the tree it was built from, so
publishing main would put the two in disagreement as soon as anything
lands unreleased — the site would describe operators that go get cannot
give the reader. A push to main therefore builds the site and runs every
check against it and stops; the deploy happens when a release is
published, and builds that tag. To republish the current release by hand —
after a failed deploy, or if the site drifts — run Actions → Docs → Run workflow, leaving the tag blank to mean “the latest release”.
Deploying needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as
repository secrets; without them the workflow still builds and checks the
site, then skips the deploy with a notice rather than failing — so a
fork’s pull request is not red for want of credentials it cannot have. Run
cd website && bun run dev to read the site locally, which does show the
working tree.
After changing anything the site derives from, run:
cd website && bun run syncand commit the result. Publishing a release republishes the site automatically — the version it displays comes from the newest changelog entry, and the deploy refuses to publish if that disagrees with what the generated content says.
Versioning the website
Section titled “Versioning the website”The site serves the current release at the root and each archived series
under its own prefix, with a switcher in the header. The v1.4.x, v1.3.x,
v1.2.x and v1.1.x series are archived at /1-4/, /1-3/, /1-2/ and
/1-1/; their
pages carry a notice saying so and linking to the current one.
One archive per minor series, not per tag. Three reasons:
- pkg.go.dev already archives every tag’s API — signatures and doc comments at any exact version, for free. What this site adds is the prose, and that does not change between patches.
- Under semantic versioning a patch cannot change the API, so a patch’s operator reference is its minor’s. Archiving patches would publish near-duplicates.
- A published URL is permanent. Once
/1-2/exists and someone links it, removing it breaks their link — so archive at the coarsest granularity that is still useful, because adding is cheap and removing is not.
Which gives the rule:
- Archive a minor series when the next minor ships, snapshotting the
last tag in the outgoing series. Label it for the series (
v1.2.x), not for that tag. - A patch release archives nothing: it is the current release, so it refreshes the current docs.
- Never archive a retracted version.
v1.0.0andv0.1.0–v0.3.0are retracted ingo.mod; a switcher entry for a versiongo getrefuses to select is worse than no entry. - Past about six entries the control stops being usable. Drop the oldest from the switcher then, but leave their pages served so existing links keep working.
Do not skip a series because it changed little. v1.2.0 was left out on
that reasoning and the switcher jumped from v1.3.0 to v1.1.x, which
reads as a fault — the reader cannot see the reasoning, only the gap. It
also turned out to be wrong: that series added BottomN.
To archive a series when you cut the next minor:
-
Take the snapshot from the tag, not from the working tree. The plugin will happily snapshot whatever is currently in
src/content/docs/, but that is the new release — filing it under the old version’s name would publish the new API as though it were the old one. Copy the real thing out of the tag instead:Terminal window dest=website/src/content/docs/1-2mkdir -p "$dest"git archive v1.2.0 website/src/content/docs | tar -x --strip-components=4 -C "$dest" -
Record that version’s sidebar in
website/src/content/versions/1-2.json, as{"sidebar": [...], "excluded": []}, copying thesidebarfrom that tag’sastro.config.mjs. The plugin refuses to build without it. -
List it in
astro.config.mjs, newest first:versions: [{ slug: '1-2', label: 'v1.2.x' }, { slug: '1-1', label: 'v1.1.x' }] -
Give it a landing page.
git archivebrings the tag’s pages but not an index for the archive itself, and/1-2/is a URL people will open. Copy the previous archive’sindex.mdinto the new directory and change the series and tag it names. -
Build once and check the archived page shows the outdated-version notice, the switcher offers both versions, and the archived operator reference does not mention anything added since.
Two things about the setup are worth knowing before you touch it. The
archived directories are snapshots of a tree that no longer exists, so
nothing can regenerate them — scripts/sync.ts therefore clears only the
four directories it owns rather than all of src/content/docs/. And a
component override in astro.config.mjs beats a plugin’s, so Header and
PageTitle render the plugin’s VersionSelect, VersionSearch and
VersionNotice themselves; drop those imports and the switcher and the
outdated-version notice disappear silently.
Publishing a change to the site itself
Section titled “Publishing a change to the site itself”Deploying is normally tied to releases, which leaves a gap: a change to the site’s own machinery — a new archived version, a layout fix — has nothing to do with the library and should not wait for one.
Actions → Docs → Run workflow takes a ref, and it may be main. What
keeps that honest is a guard in the workflow: the deploy fails unless the
library’s source at that ref is the released one — every non-test Go file
of the library and the linter, compared against the latest release tag.
If it matches, the site describes exactly the API go get delivers,
whatever else has changed: guides, examples, tests and the site itself
can all be published ahead of a release. If it does not match, the
library has moved, and the answer is to cut a release rather than publish
the branch.
version.go is left out of that comparison, since main carries the next
-dev version by design. One thing a site-only publish does not reach is
pkg.go.dev, which shows each version’s examples as they were when it was
tagged.
Versioning policy
Section titled “Versioning policy”Semantic versioning. v1.1.0 was the
first supported one and froze the API; anything removed or changed incompatibly waits for a major
version that nobody is in a hurry to have. Earlier tags were withdrawn and
are retracted in go.mod.