Releasing
May 18, 2026 · View on GitHub
Short maintainer recipe for cutting a release.
Versioning
The engine uses Semantic Versioning. The fork is pre-1.0 while crashes are still being ironed out.
| Bump | When |
|---|---|
| MAJOR | Save-format break, hard removal of a build option, or 1.0 itself |
| MINOR | New feature, new platform support, opt-in behavior change |
| PATCH | Bugfix or doc-only release |
1.0.0 bar
1.0.0 is reached when:
- All original features are implemented and working — feature parity with the retail game, no functional gaps.
- The fork has had at least a few rounds of community play-tests to surface remaining issues against a real playthrough.
- Equivalence and host tests pass on Linux, macOS, and Windows in CI.
Until then, MINOR releases ship features and platform progress; PATCH
releases ship fixes. Don't wait for 1.0 to bump versions — there's
plenty of new work in the fork already, and incremental 0.x releases
keep momentum.
Engine version vs NUM_VERSION
These are independent:
- Engine version (
v0.9.0,v1.0.0, ...) — the semver tag this doc is about. NUM_VERSIONinSOURCES/COMMON.H— the save-file on-disk format version (currently36). Bumping the engine version does not bumpNUM_VERSION, and vice versa. See issue #64 for the canonical save-format work tracked separately.
Cutting a release
Before tagging, run the pre-tag fact-check in
docs/RELEASE_TEMPLATES.md over the
[Unreleased] section. Five-minute pass per release; catches the kind
of claim-vs-reality discrepancies that are embarrassing once they hit
the Releases page + Discussion + Discord (e.g. v0.10.0's "six weeks
after v0.9.0" was actually eight days).
The flow differs by what's already in [Unreleased]:
[Unreleased] shape | Flow |
|---|---|
Empty / _Nothing yet._ | Subsequent release — git cliff --prepend |
| Hand-curated narrative | First-release-style promote — rename the heading |
The "promote-heading" path is also right for any release where
[Unreleased] has been hand-curated since the previous tag — cliff's
auto-generated section would either duplicate or replace that work,
and commit.remote.username returns the merge-commit author rather
than the patch author, so external-contributor attribution is lost.
v0.10.0 took this path.
First release (v0.9.0)
The current [Unreleased] section in CHANGELOG.md is hand-curated — it
narrates everything shipped since the fork was created and is the
canonical content for v0.9.0. Do not run git cliff -o here; it
would replace the narrative with a sparse auto-generated section (most
pre-tag commits are not conventional-commit-formatted). Just promote the
heading.
The version number itself lives in the VERSION file at the repo
root. CMake reads it, embeds it in the binary, and writes it to
build/VERSION.txt for release pipelines (PR #74) to consume. So a release
cut also bumps that file: 0.9.0-dev → 0.9.0 for the release commit,
then 0.10.0-dev for the next development cycle.
VERSION=v0.9.0
DATE=$(date +%Y-%m-%d)
# 1. In CHANGELOG.md, change:
# ## [Unreleased]
# to:
# ## [Unreleased]
# _Nothing yet._
#
# ## [0.9.0] - YYYY-MM-DD
#
# Update the compare link at the bottom of the file:
# [Unreleased]: .../compare/main...HEAD
# becomes:
# [Unreleased]: .../compare/v0.9.0...HEAD
# [0.9.0]: .../compare/<initial-commit-sha>...v0.9.0
# 2. Bump VERSION file: 0.9.0-dev -> 0.9.0
echo "0.9.0" > VERSION
# 3. Review, commit, tag, push.
#
# Use the GitHub noreply form for user.email — otherwise GitHub rejects
# the push with GH007 ("would publish a private email address") on
# repos with email-privacy enabled. Find your form at
# https://github.com/settings/emails.
git diff CHANGELOG.md VERSION
git add CHANGELOG.md VERSION
git -c user.email=<id>+<user>@users.noreply.github.com \
commit -m "chore(release): $VERSION"
git tag -a "$VERSION" -m "Release $VERSION"
#
# The push to main will print a "Bypassed rule violations for
# refs/heads/main" warning — expected, release commits skip the PR
# gate by design.
git push origin main
git push origin "$VERSION"
# 4. Sanity-check the binary picks it up.
#
# If --version still reports "<version>-dirty" after this rebuild on
# a clean working tree, delete the cached header and rebuild:
#
# rm build/VERSION_GENERATED.h build/VERSION.txt
# cmake --build build
#
# The generator's DEPENDS list doesn't include .git/HEAD or .git/index,
# so a fresh commit doesn't invalidate the cached header on its own.
# Tracked as #156; once that lands this note can be removed.
cmake --build build
./build/SOURCES/lba2cc --version # expect: 0.9.0
cat build/VERSION.txt # expect: 0.9.0
# 5. Bump VERSION for next development cycle: 0.9.0 -> 0.10.0-dev
echo "0.10.0-dev" > VERSION
git add VERSION
git commit -m "chore: bump VERSION to 0.10.0-dev"
git push origin main
git-cliff is not required for the first release.
Subsequent releases
From the second release on, git cliff --prepend adds a new versioned
section to the top of CHANGELOG.md, generated from PR titles since the
previous tag. Existing content (including the hand-curated v0.9.0
narrative) stays untouched.
VERSION=v0.10.0
# 1. Prepend the new section. NOTE: --prepend, not -o (which would
# overwrite the entire file). GITHUB_TOKEN is required: the body
# template renders one entry per PR using the PR title resolved from
# the GitHub API. Without a token, no entries render.
GITHUB_TOKEN="$(gh auth token)" git cliff --tag "$VERSION" --prepend CHANGELOG.md
# 2. Review the new section at the top. Hand-curate if useful (group
# highlights, link to docs).
git diff CHANGELOG.md
# 3. Add a compare link at the bottom of the file:
# [v0.10.0]: .../compare/v0.9.0...v0.10.0
# 4. Commit, tag, push.
git add CHANGELOG.md
git commit -m "chore(release): $VERSION"
git tag -a "$VERSION" -m "Release $VERSION"
git push origin main
git push origin "$VERSION"
Why GITHUB_TOKEN is required. The template renders one entry per
PR using commit.remote.pr_title and commit.remote.pr_number, which
cliff fetches from the GitHub API for each commit. This is what gives a
single clean entry per PR regardless of merge mode (squash, merge-commit,
or rebase). Without a token, those fields are empty and no entries
render. Use a token with public read scope only.
GitHub release
A git tag is just a pointer to a commit. A GitHub Release is a
separate object layered on top of a tag — it adds the user-facing
Releases-page entry (/releases/tag/v0.9.0), a title and rendered
release notes, optional binary attachments, and an RSS feed (/releases.atom)
that auto-update tools watch. Pushing the tag without creating a Release
means it shows up under "Tags" but not "Releases" — most browsers and
tools only look at the latter.
Drafting the release
Pushing the tag auto-creates the GitHub Release: the four per-platform
release workflows (release-{linux-appimage,linux-tarball,macos,windows}.yml)
each fire on push: tags: ['v*'], attach their artifact, and trigger
softprops/action-gh-release@v2 which materialises the Release. No
manual gh release create is needed.
Two things still need maintainer action after the workflows finish:
1. Replace the auto-generated body with the CHANGELOG section. The
per-platform workflows each set generate_release_notes: true, which
appends the PR-title dump on every run — four runs = four duplicates
(#153;
until that lands, dedup is a manual step). Extract the
## [<version>] section from CHANGELOG.md, wrap it in a short
header + Full Changelog footer, un-wrap to single-line bullets, and
publish via gh release edit --notes-file.
The extraction, un-wrap script, and wrapper template are in docs/RELEASE_TEMPLATES.md. Worked example: the v0.10.0 body — https://github.com/LBALab/lba2-classic-community/releases/tag/v0.10.0.
2. Run the post-release smoke test before announcing. 30-second sanity check that confirms the artifacts GitHub serves are actually runnable on a clean box.
Post-release smoke test
After a tag-driven release publishes its artifacts, run:
bash scripts/dev/verify-release.sh v0.9.0 # or `latest` for rolling
The script downloads the Linux tarballs and AppImages attached to the
release, extracts them, and runs each binary in a clean
debian:stable-slim container with no SDL3 / X11 / audio deps
installed. Verifies the unique signal CI doesn't cover: the artifact
GitHub serves (post-upload, post-download) actually runs on a fresh
system, executable bit preserved and static-linking claim holding.
Cross-arch tarballs are checked via qemu-user-static binfmt (auto-registered);
cross-arch AppImages are skipped because qemu doesn't handle the AppImage
type-2 runtime stub reliably.
Windows ZIPs and macOS DMGs aren't checked — running them on a Linux
host needs wine / qemu-system-x86 / a Mac, which is more
machinery than the marginal signal justifies.
Use this as a pre-publicize gate: a tagged release that passes the local verifier is safe to announce on Discord.
After the release
Don't announce until the smoke test passes. Then post to two surfaces, in order:
- GitHub Discussions, under Announcements — long-form post that
players can link to and that the Releases page can be referenced
from. Template +
gh api graphql createDiscussionmutation in docs/RELEASE_TEMPLATES.md. Worked examples: v0.9.0, v0.10.0. - Discord, in the LBALab community channel — brief post pointing at the Discussion. Template in docs/RELEASE_TEMPLATES.md.
Natural follow-ups
These become unblocked once v0.9.0 is tagged:
- Linux AppImages
(PR #74)
and macOS/Windows release pipelines. They consume
build/VERSION.txtto name artifacts. - Optional GitHub repo setting: Settings → General → "Default to PR title for merge commits". Turning it on makes merge-commit subjects in the git log match the PR title. Cosmetic — cliff handles both formats already.
How the binary knows its version
The VERSION file at the repo root is the single source of truth.
CMake reads it at build time and produces:
build/VERSION.txt— plain text, the resolved version. Release pipelines (PR #74)catthis to name artifacts.LBA2_VERSION_STRINGmacro inbuild/VERSION_GENERATED.h, pulled in viaBUILD_INFO.h. The game banner (VersioninSOURCES/VERSION.CPP) and./lba2cc --versionboth read it.
If git is available and the working tree has uncommitted changes, the
resolved version is suffixed with -dirty. So:
| Build context | ./lba2cc --version |
|---|---|
Tarball (no .git) | 0.9.0-dev (whatever the file says) |
| Clean dev clone | 0.9.0-dev |
| Dev clone with uncommitted changes | 0.9.0-dev-dirty |
At a release commit (file says 0.9.0) | 0.9.0 |
The maintainer bumps the VERSION file as part of cutting a release
(see step 2 in the recipe above). After the bump-and-commit lands on
main, ./lba2cc --version reports the real semver.
Product metadata overrides
Release-facing strings (executable name, runtime window title,
.desktop entry, AppImage label) all flow from one place: the LBA2_*
cache variables in the root CMakeLists.txt. Override any of them at
configure time and every surface follows.
| Cache variable | Default | Used by |
|---|---|---|
LBA2_EXECUTABLE_NAME | lba2cc | binary name, .desktop Exec= / StartupWMClass |
LBA2_PRODUCT_NAME | LBA2 Classic Community | window title (with version), .desktop Name, AppImage |
LBA2_PRODUCT_NAME_DEMO | LBA2 Twinsen's Odyssey Demo | window title when built with -DDEMO |
LBA2_PRODUCT_DESCRIPTION | (one-line fork description) | .desktop Comment |
LBA2_DESKTOP_ID | lba2cc | .desktop filename stem and Icon= value |
Example — produce an alternate-branded build:
cmake -B build \
-DLBA2_PRODUCT_NAME="LBA2 Anniversary Build" \
-DLBA2_EXECUTABLE_NAME=lba2-anniv
cmake --build build
The window title bar reads LBA2 Anniversary Build <version>, the
binary lands at build/SOURCES/lba2-anniv, and the generated
build/packaging/lba2cc.desktop carries the matching Name= and
Exec= entries. The AppImage script (scripts/packaging/make-appimage.sh)
sources build/packaging/appimage_env.sh so its outputs follow too.
Note on
LBA2_DESKTOP_ID. Icon assets are committed underpackaging/lba2cc.{png,ico}. If you overrideLBA2_DESKTOP_ID(say tolba2-anniv), the AppImage script looks forpackaging/<id>.png, the Windows resource forpackaging/<id>.ico, and the macOS bundle for an<id>.icnsgenerated from<id>.png; all three require a matching source file inpackaging/. The binary, window title, and.desktopentry don't need an icon and follow overrides cleanly on their own.
Renaming the default binary (rare)
Overriding LBA2_EXECUTABLE_NAME per-build (above) is the common case.
Bumping the cache default — actually changing the project's shipped
binary name — has happened once (lba2 → lba2cc for retail-collision
reasons; see PR #94).
The CMake change is one line; the doc and CI sweep is most of the work,
and easy to underestimate.
If it ever happens again, run three greps — each catches a different class of reference, and a single combined grep will leave stale mentions behind:
OLD=lba2 NEW=lba2new # adjust
# 1. Build/output paths in CI workflows, scripts, READMEs.
grep -rnE "build/SOURCES/$OLD|out/build/.+/SOURCES/$OLD" .
# 2. Bare invocations in docs (./lba2 --version, ./lba2 42).
# Most error-prone because the token also appears in lba2.cfg,
# lba2.hqr, LBA2_* env vars, and brand mentions — filter the
# false positives, don't skip the grep.
grep -rnE "(\./|\s)$OLD( |\$|\.exe)" --include="*.md" --include="*.sh" .
# 3. CMake target references in workflows, Makefiles, scripts.
grep -rnE "target $OLD" .github/workflows scripts/ Makefile
Don't forget the Verify Binary step in .github/workflows/{linux,macos,windows}.yml
— it's a separate test -f line that pattern (1) catches but is
easy to miss visually because it lives outside the build step.
After substituting, rebuild with make build to confirm the binary
lands at the new path, then re-run all three greps with the new
name to catch typos and partial substitutions.
Release workflow conventions
Every per-platform release workflow under .github/workflows/release-*.yml
follows the same shape so artifacts land predictably regardless of which
platform fires first, and adding a new platform is a copy-and-fill exercise:
| Concern | Convention |
|---|---|
| Triggers | push: tags: ['v*'] + workflow_dispatch:. Tags do real releases; dispatch is for validation runs. |
| Job structure | Two jobs: build$ (\text{matrix}, \text{even} \text{if} 1 \times 1) → $release. The build job is a uses: call into .github/workflows/reusable-build-<platform>.yml — all toolchain setup, configure, build, and bundle steps live there so release-latest.yml can share them. Reusables live at the top level of .github/workflows/; GitHub Actions rejects workflow_call files in subdirectories. fail-fast: false so one arch failing doesn't drop others. |
| Artifact handoff | Reusable build legs use actions/upload-artifact@v7 with name: <artifact-prefix>-<arch>, where artifact-prefix is a workflow input. Tag callers leave it at the default (AppImage, LinuxTarball, Windows, macOS); release-latest.yml overrides to latest-*. The release job downloads via pattern: '<prefix>-*' merge-multiple: true. |
| Release upload | softprops/action-gh-release@v2 with generate_release_notes: true. Asset attach is idempotent across the four per-tag workflows; body is not — the action appends the regenerated notes on every run, so the body lands duplicated 4× and is replaced post-tag by the CHANGELOG extraction in Drafting the release. Tracked as #153. |
| Tag-only upload | Release job is gated by if: startsWith(github.ref, 'refs/tags/'). workflow_dispatch produces downloadable artifacts via the build job's upload step but does not touch the Releases page (avoids creating a "release" named after a branch). |
| Artifact naming | <exe>-<version>-<platform>-<arch>.<ext>, e.g. lba2cc-0.9.0-windows-x64.zip, lba2cc-0.9.0-AppImage-x86_64.AppImage, lba2cc-0.9.0-linux-x86_64.tar.gz. |
| Version source | build/VERSION.txt, generated by cmake/git_version.cmake from the canonical VERSION file. Read once after configure. |
| Packaging logic | Lives under scripts/packaging/ (make-appimage.sh, bundle-linux-tarball.sh, bundle-windows.sh, bundle-macos.sh). One script per platform; the workflow is glue. |
When adding a new platform, copy the closest existing workflow, swap the runner / toolchain / packaging script, and the rest of the shape carries over. The next section spells the steps out.
Adding a new release target
The release infra is split so adding a platform is mechanical: a packaging
script + a reusable build workflow + a thin caller + one matrix leg in
release-latest.yml. Concrete order:
-
Packaging script —
scripts/packaging/bundle-<platform>.sh(or a monolithicmake-<platform>.shfor AppImage-style targets that do their own configure+build inside the script). Takes built artifact, version, arch, build-dir, and output-dir as--flagarguments; produces the release artifact under the output dir. Naming:lba2cc-<version>-<platform>-<arch>.<ext>. The existing scripts are the templates — Windows is the cleanest split-bundle pattern, AppImage is the monolithic pattern, macOS shows bundle-with-platform-metadata, tarball is the simplest pure-cmake bundle. -
Configured templates (optional) —
scripts/packaging/<platform>-readme.txt.infor an in-archive README,packaging/<template>.inforInfo.plist- style configure-time substitution. Wire viaconfigure_file()in CMake. -
Local dry-run (optional but recommended) —
scripts/dev/build-<platform>-release.shthat runs the same configure- build + bundle locally without GitHub Actions. Lets contributors exercise the artifact path without pushing a branch.
-
Reusable build workflow —
.github/workflows/reusable-build-<platform>.yml. Must live at the top level of.github/workflows/; GHA rejectsworkflow_callworkflows in subdirectories. Copy the nearest sibling and swap runner / preset / toolchain setup / bundle script invocation. Keep theworkflow_calltrigger, theartifact-prefixinput, and theupload-artifactstep usingname: ${{ inputs.artifact-prefix }}-${{ matrix.arch }}— that's the contract the release jobs rely on.If your target uses
libsdl-org/setup-sdland has a multi-arch matrix, setdiscriminator: ${{ runner.arch }}under thewith:block. setup-sdl's cache key only includesos.platform()(Linux / MacOS / Windows), not arch, so multi-arch runners on the same OS collide on the cache and one leg pulls a wrong-archlibSDL3.a, breaking the link step. The Linux tarball reusable already does this; it's the only current target that hits the multi-arch-with-setup-sdl shape (AppImage uses an Arch container, Windows uses MSYS2 pacman, macOS is single-arch arm64). Re-add to macOS if a universal2 / Intel leg lands; same for any AppImage variant that drops the container. -
Thin caller —
.github/workflows/release-<platform>.yml:on: push: tags: ['v*'] + workflow_dispatch, abuildjob that justuses:the reusable, and areleasejob gated byif: startsWith(github.ref, 'refs/tags/')that downloads viapattern: '<Platform>-*'and runssoftprops/action-gh-release@v2. ~25 lines total —release-windows.ymlis the cleanest example.Build steps belong in the reusable, never in the thin caller. Any configure/build/bundle step you add only to
release-<platform>.ymlwill not run whenrelease-latest.ymlbuilds the rolling pre-release, so the tagged and rolling artifacts will diverge silently. If you need a step for one caller only, gate it inside the reusable on aworkflow_callinput rather than splitting it across files. -
release-latest.yml— add abuild-<platform>job that calls the reusable withwith: artifact-prefix: latest-<platform>. Add to therelease.needs:list and the file glob to therelease.files:block. -
Conventions table — add an artifact-name row to the table above showing the exact
<exe>-<version>-<platform>-<arch>.<ext>for the new target. -
CHANGELOG — entry under
[Unreleased].
What CMake might need touching: configure-time templates
(configure_file(... Info.plist.in ...)), per-target properties
(MACOSX_BUNDLE-style), or a new flag if the platform needs a build
mode the existing LBA2_LINK_STATIC toggle doesn't cover. Keep platform
specifics behind a CMAKE_SYSTEM_NAME check, not a new option.
Testing the new target before tagging:
- Local dry-run produces an artifact identical in shape to what CI will.
gh workflow run release-<platform>.yml --ref <branch>triggers aworkflow_dispatchvalidation run. Build job runs, artifact uploads, release job is skipped by the tag gate. Download the artifact from the run page and exercise it.- For the rolling-release leg: push a no-op commit to a fork's
mainand confirmrelease-latest.ymlpicks up the new leg and includes its glob in the rolling release. - Cut a
v0.X.Y-rc1pre-release tag once dispatch validation passes — exercises the full tag path end-to-end without committing to a stable version.
Rolling latest pre-release
.github/workflows/release-latest.yml is a deliberate exception to the
"tags only" convention above. It triggers on every push: branches: [main]
and force-overwrites a latest GitHub Release with the just-built
artifacts. Marked prerelease: true and make_latest: false so the most
recent stable tag (e.g. v0.9.0) keeps GitHub's "Latest" badge — the
rolling release sits below it, clearly labeled.
| Aspect | Difference from the per-tag releases |
|---|---|
| Trigger | push: branches: [main] (vs push: tags: ['v*']) |
| Tag | Static latest, force-moved to current commit on every push |
| Pre-release flag | Always true (vs always false for versioned tags) |
make_latest | false (vs default — versioned tags get GitHub's "Latest" promotion) |
| Concurrency | Single release-latest group, cancel-in-progress: true. Back-to-back commits don't queue; newest wins. |
| Partial release on failure | if: always() && !cancelled() on the release job. If one platform's build leg fails, the rolling release still updates with the platforms that succeeded. Tag releases are stricter (if: startsWith(github.ref, 'refs/tags/')) because a tagged release is meant to be complete. |
Same packaging scripts (make-appimage.sh, bundle-linux-tarball.sh,
bundle-windows.sh, bundle-macos.sh), same artifact shape, same
naming. The rolling release is functionally a tag-release at HEAD-of-main
with a moving tag instead of a fixed semver.
When to point users at it: for community testers who want to
verify a fix on main without building from source, or for "does this
reproduce on the latest main?" bug-report triage. Otherwise prefer
linking to a versioned release (stable, immutable, won't change under
their feet between two clicks).
Linux tarball release artifact (local dry-run)
The Linux tarball is the static-binary alternative to the AppImage — same binary contents, no AppImage runtime, no desktop integration. For users on distros where AppImage friction matters (no FUSE, immutable distros, packagers wrapping the binary), and for "is this a clean-binary issue?" triage.
bash scripts/dev/build-linux-tarball.sh
Configures the linux preset with -DLBA2_LINK_STATIC=ON, builds,
and calls scripts/packaging/bundle-linux-tarball.sh to produce
dist/lba2cc-<version>-linux-<arch>.tar.gz. Arch label comes from
uname -m, so x86_64 hosts produce x86_64 artifacts and aarch64 hosts
produce aarch64.
Tarball layout:
lba2cc-<version>-linux-<arch>/
lba2cc ← statically linked: no libSDL3.so, no libsmacker.so
README.txt ← LF line endings, populated from
scripts/packaging/linux-readme.txt.in
LICENSE.txt ← GPL-2.0 from repo root
Three files, deliberately. No icon, no .desktop, no install script —
users who want desktop integration grab the AppImage. The bundle
script's ldd audit flags any non-glibc-family shared dependency that
slipped through static linking.
Local SDL3 caveat. Most distros ship SDL3 as a shared library only,
and -DLBA2_LINK_STATIC=ON requires the SDL3-static target — so with
only a shared SDL3 installed, find_package(SDL3) fails outright. To
produce a truly static binary locally, build SDL3 from source with the
same flags CI uses and pass its prefix via CMAKE_PREFIX_PATH (CMake
picks it up natively from the environment):
git clone --depth 1 --branch release-3.2.16 \
https://github.com/libsdl-org/SDL /tmp/SDL
cmake -S /tmp/SDL -B /tmp/SDL/build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DSDL_STATIC=ON -DSDL_SHARED=OFF \
-DCMAKE_INSTALL_PREFIX=/tmp/sdl3-static-prefix
cmake --build /tmp/SDL/build && cmake --install /tmp/SDL/build
CMAKE_PREFIX_PATH=/tmp/sdl3-static-prefix \
bash scripts/dev/build-linux-tarball.sh
CI does the same dance via libsdl-org/setup-sdl with -DSDL_STATIC=ON -DSDL_SHARED=OFF (see .github/workflows/release-linux-tarball.yml).
A genuinely-static tarball's ldd output shows only linux-vdso,
libc, libm, and the loader — no SDL3, no libsmacker.
macOS release artifact (local dry-run)
On a macOS box (Apple Silicon or Intel):
bash scripts/dev/build-macos-release.sh
Auto-detects host arch and picks the matching CMake preset (macos_arm64
or macos_x86_64), configures with -DLBA2_LINK_STATIC=ON, builds, and
calls scripts/packaging/bundle-macos.sh to produce dist/lba2cc-<version>-macos-<arch>.dmg.
Override the auto-detection with --preset macos_x86_64 if you want to
build x86_64 on Apple Silicon (Rosetta-friendly Xcode required).
Requires macOS host — uses hdiutil (DMG creation), sips (icon resampling),
and iconutil (.icns packaging). All three ship with Xcode CLI tools.
SDL3 needs to be discoverable. The simplest setup: brew install sdl3. The
script auto-detects the Homebrew prefix and prepends it to CMAKE_PREFIX_PATH
so find_package(SDL3) resolves cleanly on both Apple Silicon (/opt/homebrew)
and Intel (/usr/local). CI uses libsdl-org/setup-sdl instead and points at
its own prefix — different machinery, same outcome.
DMG mounts as volume "LBA2 Classic Community
LBA2 Classic Community.app
Applications -> /Applications (drag-to-install symlink)
README.txt
LICENSE.txt
Bundle directory carries LBA2_PRODUCT_NAME (the Finder display name).
The inner Mach-O at Contents/MacOS/lba2cc and the DMG filename both
follow LBA2_EXECUTABLE_NAME — kept space-free for CLI use and to match
the cross-platform artifact naming (<exe>-<version>-<platform>-<arch>).
The first-launch Gatekeeper friction (right-click → Open) is documented in the README inside the DMG. Code signing + notarization are deferred until the project gets an Apple Developer Program account.
Windows release artifact (local dry-run)
Iterate on the Windows release ZIP locally without burning CI minutes:
bash scripts/dev/build-windows-release.sh
The script auto-detects the build environment:
- MSYS2 (UCRT64 / MINGW64) — uses the matching native preset, produces an
x64artifact. This is the recommended local path because it's bit-for-bit the same toolchain the CI release workflow (B2) uses, and SDL3 is straightforward (pacman -S mingw-w64-ucrt-x86_64-SDL3). - Linux (incl. WSL) — falls back to the
cross_linux2winpreset, produces ani686(32-bit) artifact. Cheap if you havemingw-w64already installed, but also requires SDL3 for the i686 cross-arch, which most distros don't ship by default. CI handles this viasetup-sdl; on a dev box you'd typically just use MSYS2 instead.
Override the preset explicitly with --preset windows_ucrt64, --preset windows_mingw64, etc. if you want to test a specific configuration regardless of host environment.
Both paths invoke the same scripts/packaging/bundle-windows.sh, so the ZIP layout cannot drift between local dry-run and CI. The CI release workflow (B2, separate PR) calls the same script.
Prerequisites:
cmake,ninja— both paths.- Either
ziporpython3for the archive step (script falls back topython3 -m zipfileifzipisn't installed). - MSYS2 path: a working MSYS2 UCRT64 dev environment (the same one used for native dev builds).
- Linux/WSL path:
mingw-w64(apt install mingw-w64on Debian/Ubuntu,pacman -S mingw-w64-gccon Arch) plus SDL3 built or installed for i686.
ZIP layout:
lba2cc-<version>-windows-<arch>/
lba2cc.exe ← static-linked: no SDL3.dll, no MSYS2 runtime DLLs
README.txt ← CRLF line endings, populated from
scripts/packaging/windows-readme.txt.in
LICENSE.txt ← GPL-2.0 from repo root, CRLF
-arch is i686 for the local cross-compile dry-run, x64 for the
native MSYS2 UCRT64 release workflow. The bundle script's DLL audit
flags any non-system DLL dependency that slipped through static linking.
To produce a non-static dev build that matches the day-to-day MSYS2 workflow, use the regular preset and skip this script — it's purely a release-packaging path.
What git-cliff reads
- Commit messages on
mainsince the previous tag. - Conventional-commit prefixes (
feat:,fix:,port:,docs:, ...) drive the section grouping. Seecliff.tomland the Commit & PR conventions section in AGENTS.md. - One entry per PR. Cliff queries the GitHub API for each commit on
mainto resolve its associated PR (number + title) and renders one line per PR. Works identically for squash-merge, merge-commit, and rebase modes — the merge mode doesn't matter, the PR title does. The PR-title CI check (.github/workflows/pr-title.yml) is what keeps the log clean enough. - A small
commit_preprocessorsrule rewrites GitHub's default merge subject (Merge pull request #N from owner/branch) into the conventional-commit-formatted PR title that GitHub puts on the third line of the body. This lets the type-based grouping (Added / Fixed / ...) work correctly for merge-commit-mode PRs. chore:is the explicit "skip from changelog" prefix. Use it for housekeeping PRs (formatting, internal refactors that aren't worth surfacing to readers, tooling fixes). Cliff drops them entirely.- The
[remote.github]block incliff.tomlenables author handle lookup via the GitHub API (commit.remote.username). Each entry then ends with— by @handlelinking to the author's profile. If the API call is unauthenticated and rate-limited, cliff falls back to the git author name — exportingGITHUB_TOKEN(above) avoids that.