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.Z tag. A !: in a subject, or a line starting BREAKING 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. main is branch-protected with enforce_admins, so CI cannot commit to it. The prep commit is built off main โ€” its parent is the merge commit โ€” and the tag points at that commit. main is never written to, and the tagged tree still satisfies the verify-never-stamp invariant.
  • Consequence. .version on main is only a dev placeholder; the value that ships is the one the workflow writes into the tag. Do not read main's .version as the released version. Keep it in the X.Y.Z-dev shape so that it says so: a build from main โ€” or from a source checkout, or go 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 -dev suffix 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.Z yourself. 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/changelog page renders main'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 main is missing โ€” the newest one supersedes and closes the older ones.

Manual procedure

Used for the first release, and any release the automation cannot make.

  1. Release-prep commit. In one commit:
    • Set .version to the release version without the v prefix (e.g. 1.2.3), no trailing newline.
    • In every adapters/*/go.mod, set the require github.com/go-monolith/ghtmx version to the tag (e.g. v1.2.3). The local replace stays โ€” consumers ignore it; it only serves in-repo development.
    • In docs/official/go.mod and internal/wasmcheck/fixture/go.mod, set both requires โ€” github.com/go-monolith/ghtmx and github.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 (no v prefix), which folds the changelog.d/ fragments into CHANGELOG.md under 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.
  2. Tag and push. Tag that commit v1.2.3 and push the tag. The Release workflow then:
    • verifies .version and 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).
  3. Post-release commit. Set .version back to the next X.Y.Z-dev and 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.