Releasing¶
The steps a release takes, in order. Written after v0.0.5, where the version bump was missed — every commit for the rest of that day built claiming to be the released version, and that version rides in inter-BBS packets and feeds the League Coordinator's minimum-version gate.
Before tagging¶
-
Curate the ChangeLog. The in-progress block accumulates in commit order, which is neither the reader's order nor a finished list. Three passes, in order:
-
Reorder so the most important, most user-facing changes come first and sysop, command-line and packaging entries come last.
- Fold entries that describe one change, and delete any entry that fixes something added in the same cycle — nobody upgrading ever saw the bug.
- Shorten to the one-line rule: no entry wraps, no second person, no explanation of how a feature plays, no clause saying what BRE does. If a change will not fit on one line, ask before writing a longer one.
Expect this to be substantial: the v0.0.6 pass cut 71 wrapped entries to 69 single lines.
- Regenerate the translations. They are deliberately NOT regenerated on ordinary commits, so a release is where they catch up:
python3 scripts/gen-ui-pot.py && scripts/merge-ui-po.sh
scripts/gen-help-translations.sh
Check what the UI extractor is not seeing. gen-ui-pot.py scans for a
fixed list of call forms, so a print helper added since the last release
drops out of the catalogs silently — its strings translate at runtime and no
translator has ever been shown them. At v0.0.6 that was okNoPause,
askYesNoHere and promptSuggestedTight, 28 strings between them. To find
the next one, list the helpers that pass a literal through i18n.T and
compare them against CALL_PATTERNS.
Never clear a #, fuzzy flag without reading the translation. msgmerge
fills a new entry from whichever old one has the most similar English, so a
fuzzy msgstr is a guess about spelling, not about meaning. de.po currently
renders Specialization as "Regionen" and Relations as "Regionen" — both
inherited from Regions on string similarity alone, and ru.po has the same
pair. Nobody sees them: internal/i18n skips fuzzy entries by design, so the
reader gets English until a human validates one. Un-fuzzying in bulk is what
would ship them.
-
Stamp the ChangeLog. Replace the
(in-progress:)heading withYYYY-MM-DD (vX.Y.Z), matching the existing entries. -
Update the status line in
AGENTS.md— which version is released and which is in development. -
Verify. All of these, not a subset:
gofmt -l . # silence
go vet ./...
go test ./...
GOARCH=386 go test ./internal/game/ # money math on the 32-bit door builds
go test ./internal/play/ -race
-
Check the Windows 7 toolchain pin.
scripts/fetch-win7-go.shpins a go-legacy-win7 release, which builds thewindows/386asset because a stock-Go binary cannot start on Windows 7 at all (#181). Compare the pin against that project's releases: if it has followed upstream to a newer patch level, bumpVERSIONandSHA256together, taking the hash from the release page rather than from the download. Left alone the release still builds, on the older Go — which is the failure worth catching here, since nothing else reports it. -
Commit as
release: vX.Y.Z.
Tagging¶
Push trunk, then draft the release on GitHub and let publishing create the tag. Do not tag or push tags from the command line.
The Release workflow builds on publish and attaches the assets — Linux,
macOS and Windows archives, the vendored source tarball, and SHA256SUMS.
Watch it: a release with no assets is a release nobody can use.
After publishing¶
- Bump
Versionininternal/game/game.goto the next patch. This is the step that gets forgotten, because the release feels finished once the assets are up.
It is not cosmetic. The constant is the in-development version, so leaving it
at the released number means every later build identifies itself as that
release — in -version, in the About screen, in the door log, and in the
version stamped on every inter-BBS packet, where a Coordinator's minimum
version is tested against it.
- Decide whether
game.Protocolneeds bumping, and settle it now rather than when the next format change is half-written.
Bump it only when the packet format actually moved. It is deliberately not the release version: a release that changes menus or balance leaves it alone, so boards on either side of that release go on exchanging packets. Bumping it every release turns it back into the version and forces the whole league to upgrade in lockstep for changes that never touched the wire.
TestPacketWireShapeIsFrozen is what makes this a decision instead of an
oversight — it fails the build when a field is added, renamed or reordered,
and its comment says which of the three answers applies.
A protocol bump is not the tool for a balance change. Retuning a formula
leaves the wire byte-identical, so the protocol number stays put and boards
go on exchanging packets while computing different outcomes. That is worse
than a format break, not better: an interplanetary attack is resolved on the
TARGET board, so the same attack gives different results depending on which
end resolves it, and nothing announces it. MinBoardVersion is what covers
this — it gates on the game version, which does move for a formula change.
Set it when a release changes gameplay a league would notice.
If it did move, the release notes must say the upgrade is coordinated. A league does not roll a protocol change through board by board: it closes the game, drains every board's outbound queue, and switches together (decided 2026-08-31, #229). A held packet comes back only when the READER moves to the number it already carries, so a staggered upgrade strands the board that moves first. Saying this in the notes is the whole mechanism; nothing in the code enforces it.
-
Delete any renamed or removed asset left behind on the snapshot prerelease by hand.
replacesArtifactsonly replaces an asset of the same name, so a rename leaves the old file sitting beside the new one. -
Bump the Homebrew formula in
HomebrewFormula/immortal-barons.rb— theurl, thesha256, and the doc list:
gh release download vX.Y.Z -p '*-vendored-source.tar.gz'
sha256sum immortal-barons-vX.Y.Z-vendored-source.tar.gz
tar tzf immortal-barons-vX.Y.Z-vendored-source.tar.gz | grep 'docs/.*\.md$'
The formula installs from that tarball, so every path in doc.install has
to exist inside it — the third command is the check. The vendored tarball
carries the whole docs/ tree, which is a wider set than the platform
archives get from scripts/build-archives.sh.
A doc added on trunk does not go in the formula. It is not in any
released tarball yet, so the Homebrew CI job fails the install with
Errno::ENOENT — which is how this step came to be written. The list
changes only when the pin moves. scripts/build-archives.sh is the
opposite: it runs from the tree at release time, so a new doc goes in it
straight away.
Missed at v0.0.5, which left the formula installing v0.0.4 until it was noticed.