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.

BumpWhen
MAJORSave-format break, hard removal of a build option, or 1.0 itself
MINORNew feature, new platform support, opt-in behavior change
PATCHBugfix 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_VERSION in SOURCES/COMMON.H — the save-file on-disk format version (currently 36). Bumping the engine version does not bump NUM_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] shapeFlow
Empty / _Nothing yet._Subsequent releasegit cliff --prepend
Hand-curated narrativeFirst-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-dev0.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:

  1. 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 createDiscussion mutation in docs/RELEASE_TEMPLATES.md. Worked examples: v0.9.0, v0.10.0.
  2. 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.txt to 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) cat this to name artifacts.
  • LBA2_VERSION_STRING macro in build/VERSION_GENERATED.h, pulled in via BUILD_INFO.h. The game banner (Version in SOURCES/VERSION.CPP) and ./lba2cc --version both 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 clone0.9.0-dev
Dev clone with uncommitted changes0.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 variableDefaultUsed by
LBA2_EXECUTABLE_NAMElba2ccbinary name, .desktop Exec= / StartupWMClass
LBA2_PRODUCT_NAMELBA2 Classic Communitywindow title (with version), .desktop Name, AppImage
LBA2_PRODUCT_NAME_DEMOLBA2 Twinsen's Odyssey Demowindow title when built with -DDEMO
LBA2_PRODUCT_DESCRIPTION(one-line fork description).desktop Comment
LBA2_DESKTOP_IDlba2cc.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 under packaging/lba2cc.{png,ico}. If you override LBA2_DESKTOP_ID (say to lba2-anniv), the AppImage script looks for packaging/<id>.png, the Windows resource for packaging/<id>.ico, and the macOS bundle for an <id>.icns generated from <id>.png; all three require a matching source file in packaging/. The binary, window title, and .desktop entry 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 (lba2lba2cc 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:

ConcernConvention
Triggerspush: tags: ['v*'] + workflow_dispatch:. Tags do real releases; dispatch is for validation runs.
Job structureTwo 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 handoffReusable 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 uploadsoftprops/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 uploadRelease 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 sourcebuild/VERSION.txt, generated by cmake/git_version.cmake from the canonical VERSION file. Read once after configure.
Packaging logicLives 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:

  1. Packaging scriptscripts/packaging/bundle-<platform>.sh (or a monolithic make-<platform>.sh for AppImage-style targets that do their own configure+build inside the script). Takes built artifact, version, arch, build-dir, and output-dir as --flag arguments; 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.

  2. Configured templates (optional)scripts/packaging/<platform>-readme.txt.in for an in-archive README, packaging/<template>.in for Info.plist- style configure-time substitution. Wire via configure_file() in CMake.

  3. Local dry-run (optional but recommended)scripts/dev/build-<platform>-release.sh that runs the same configure

    • build + bundle locally without GitHub Actions. Lets contributors exercise the artifact path without pushing a branch.
  4. Reusable build workflow.github/workflows/reusable-build-<platform>.yml. Must live at the top level of .github/workflows/; GHA rejects workflow_call workflows in subdirectories. Copy the nearest sibling and swap runner / preset / toolchain setup / bundle script invocation. Keep the workflow_call trigger, the artifact-prefix input, and the upload-artifact step using name: ${{ inputs.artifact-prefix }}-${{ matrix.arch }} — that's the contract the release jobs rely on.

    If your target uses libsdl-org/setup-sdl and has a multi-arch matrix, set discriminator: ${{ runner.arch }} under the with: block. setup-sdl's cache key only includes os.platform() (Linux / MacOS / Windows), not arch, so multi-arch runners on the same OS collide on the cache and one leg pulls a wrong-arch libSDL3.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.

  5. Thin caller.github/workflows/release-<platform>.yml: on: push: tags: ['v*'] + workflow_dispatch, a build job that just uses: the reusable, and a release job gated by if: startsWith(github.ref, 'refs/tags/') that downloads via pattern: '<Platform>-*' and runs softprops/action-gh-release@v2. ~25 lines total — release-windows.yml is 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>.yml will not run when release-latest.yml builds 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 a workflow_call input rather than splitting it across files.

  6. release-latest.yml — add a build-<platform> job that calls the reusable with with: artifact-prefix: latest-<platform>. Add to the release.needs: list and the file glob to the release.files: block.

  7. Conventions table — add an artifact-name row to the table above showing the exact <exe>-<version>-<platform>-<arch>.<ext> for the new target.

  8. 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 a workflow_dispatch validation 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 main and confirm release-latest.yml picks up the new leg and includes its glob in the rolling release.
  • Cut a v0.X.Y-rc1 pre-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.

AspectDifference from the per-tag releases
Triggerpush: branches: [main] (vs push: tags: ['v*'])
TagStatic latest, force-moved to current commit on every push
Pre-release flagAlways true (vs always false for versioned tags)
make_latestfalse (vs default — versioned tags get GitHub's "Latest" promotion)
ConcurrencySingle release-latest group, cancel-in-progress: true. Back-to-back commits don't queue; newest wins.
Partial release on failureif: 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 " with the items at the volume root (no wrapping directory):

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 x64 artifact. 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_linux2win preset, produces an i686 (32-bit) artifact. Cheap if you have mingw-w64 already installed, but also requires SDL3 for the i686 cross-arch, which most distros don't ship by default. CI handles this via setup-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 zip or python3 for the archive step (script falls back to python3 -m zipfile if zip isn'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-w64 on Debian/Ubuntu, pacman -S mingw-w64-gcc on 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 main since the previous tag.
  • Conventional-commit prefixes (feat:, fix:, port:, docs:, ...) drive the section grouping. See cliff.toml and the Commit & PR conventions section in AGENTS.md.
  • One entry per PR. Cliff queries the GitHub API for each commit on main to 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_preprocessors rule 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 in cliff.toml enables author handle lookup via the GitHub API (commit.remote.username). Each entry then ends with — by @handle linking to the author's profile. If the API call is unauthenticated and rate-limited, cliff falls back to the git author name — exporting GITHUB_TOKEN (above) avoids that.