Releasing
Every component โ compiler, runtime, LSP, dev tooling, adapters โ
ships under one version. The tagged tree must be self-consistent, so
go install/go get consumers see the same version as the release
binaries: the workflow verifies the version, it never stamps it.
Automated patch releases
Every merge to main cuts the next patch release. auto-release.yml
performs steps 1 and 2 of the manual procedure below, then calls the
Release workflow, so the same gates run either way. The prep commit
also assembles the changelog: scripts/assemble-changelog.sh folds the
changelog.d/ fragments (one per PR โ see changelog.d/README.md)
into CHANGELOG.md under the version being released and refreshes the
docs site's embedded copies, so the tagged tree carries its own
release notes. GitHub's generated notes (the PR-title list) still
accompany the release; CHANGELOG.md is for what a title cannot carry.
The tag is created after the gates pass, never before. The prep
commit is parked on a scratch branch, release.yml gates that commit,
and only then does the tag exist. A failed gate leaves no published
version behind โ which matters because a tag is immutable once the
module proxy has seen it.
A failed run keeps its auto-release/vX.Y.Z branch so the gated tree
stays inspectable and the run can be retried. Re-run all jobs rather
than only the failed ones; the version is derived from the last tag, so
a retry recomputes the same one. Tag publishing is idempotent โ already
correct tags are left alone, missing ones are filled in, and a tag
pointing at a different commit fails the job rather than moving.
- Version. The next patch above the highest
vX.Y.Ztag. A!:in a subject, or a line startingBREAKING CHANGE:, bumps the minor instead โ pre-1.0 this project allows breaking changes between minor versions, so they must not ship as a patch. Every commit the release covers is scanned, not just the merge tip, so a marker still counts when its own run was coalesced away or when it sits behind a merge commit. - Opting out. Put
[skip release]in the merge commit message. A release is also skipped when nothing but*.md,docs, or.github/has changed since the last tag โ measured from the tag, not from the single push, so a merge whose event GitHub drops or coalesces is picked up by the next run instead of being lost. - Where the bump lives.
mainis branch-protected withenforce_admins, so CI cannot commit to it. The prep commit is built offmainโ its parent is the merge commit โ and the tag points at that commit.mainis never written to, and the tagged tree still satisfies the verify-never-stamp invariant. - Consequence.
.versiononmainis only a dev placeholder; the value that ships is the one the workflow writes into the tag. Do not readmain's.versionas the released version. Keep it in theX.Y.Z-devshape so that it says so: a build frommainโ or from a source checkout, orgo run ./cmd/ghtmxโ stamps every generated file it writes with// ghtmx: version: v<that value>, and a bare release number there reads as a real release in a downstream drift report. The same branch protection that sends the changelog fold back as a pull request also stops the automation from bumping this marker, so it lags the released version until someone edits it; that lag is expected, the missing-devsuffix would not be. - First release. Automation bumps from an existing tag and fails loudly if none exists. Cut the first one by hand with the procedure below.
Because every release gets a fresh prep commit, adapters/<name>/vX.Y.Z
always requires the matching ghtmx vX.Y.Z. Adapter requires on main
still lag the latest tag; that only affects go get .../adapters/...@main,
never a released version.
The changelog fold PR
The prep commit that assembled CHANGELOG.md lives off main, and
main is branch-protected โ CI cannot write to it. So after a
successful release the workflow rebuilds the same fold on top of
main's tip (CHANGELOG.md as the tag shipped it, the folded fragments
deleted, docs copies re-synced), pushes it to a changelog/vX.Y.Z
branch, and opens a pull request from it.
- Today the PR must be opened by hand. The repository setting
"Allow GitHub Actions to create and approve pull requests" is off
(enabling it needs admin rights), so the workflow can only push the
branch โ it logs the compare URL as a warning and exits green. Run
gh pr create --base main --head changelog/vX.Y.Zyourself. A PR opened from your own account starts CI normally. Until the fold PR merges,main's CHANGELOG.md stays at the previous release โ the tag itself always carries the assembled changelog. - Merge it when convenient. It is markdown- and docs-only, so
merging never cuts a release; it does redeploy the docs site, whose
/docs/changelogpage rendersmain's copy. - If the setting is ever enabled, bot-opened PR checks do not start on their own: GitHub starts no CI runs for events the workflow token causes. Close and reopen the PR (or push any commit to its branch) to start the required checks, then merge when green.
- Leaving it open is safe. A later release restores any
released-but-unfolded section from its tag, and each fold PR carries
every section
mainis missing โ the newest one supersedes and closes the older ones.
Manual procedure
Used for the first release, and any release the automation cannot make.
- Release-prep commit. In one commit:
- Set
.versionto the release version without thevprefix (e.g.1.2.3), no trailing newline. - In every
adapters/*/go.mod, set therequire github.com/go-monolith/ghtmxversion to the tag (e.g.v1.2.3). The localreplacestays โ consumers ignore it; it only serves in-repo development. - In
docs/official/go.modandinternal/wasmcheck/fixture/go.mod, set both requires โgithub.com/go-monolith/ghtmxandgithub.com/go-monolith/ghtmx/adapters/chiโ to the tag. They are gated exactly like the adapters, and they fail to build otherwise. Both stay internal and are never tagged themselves. - Run
bash scripts/assemble-changelog.sh X.Y.Z(novprefix), which folds thechangelog.d/fragments intoCHANGELOG.mdunder that version and restores any released-but-unfolded sections from their tags. Then refresh the embedded docs copies:cd docs/official && go run ./internal/sync.
- Set
- Tag and push. Tag that commit
v1.2.3and push the tag. The Release workflow then:- verifies
.versionand the adapter requires match the tag, - runs the full gate set (tests including the WASM matrix, generated-code currency, govulncheck, the performance gates, and the release artifact matrix),
- builds checksummed archives for every supported platform,
verifies the shipped binary reports the tag, publishes the GitHub
release, and pushes the lockstep adapter tags
(
adapters/<name>/v1.2.3).
- verifies
- Post-release commit. Set
.versionback to the nextX.Y.Z-devand the adapter requires may stay at the released version until the next release-prep commit.
A mismatch anywhere fails the gates job before any artifact exists.
Mixing the two paths
Landing a manual release-prep commit on main and then tagging it by
hand still works, but do it in that order and within one merge. If the
prep commit merges first, the automation sees main already carrying
the target version, finds nothing to rewrite, and gates main's tip
as-is โ fine. If you tag before that merge lands, the automation reads
your new tag as the baseline and cuts the next patch from the same tree.
Why the Release workflow is callable
release.yml takes two inputs. ref is the commit to gate and tag;
tag is the version that commit claims. A hand-pushed tag supplies
both implicitly โ the tag exists already and is its own ref.
A tag pushed with the default GITHUB_TOKEN starts no workflow run, so
the automation could not rely on the tag-push trigger even if it wanted
to. It calls release.yml through workflow_call instead, which keeps
everything on the built-in token โ no PAT or GitHub App to mint, store,
or rotate. release.yml still runs from a plain git push origin v1.2.3 exactly as before.