At a glance

Workflow
.github/workflows/release.yml: tag trigger, test gate, two build jobs, publish
Toolchains
scripts/ci/setup-cross-toolchain.sh: the cross toolchain per target, idempotent
Build and package
scripts/ci/release-build.sh: build, stage, archive, checksum
For players
Which archive to download and what to open: desktop apps

Cutting a release

git tag v0.2.0
git push origin v0.2.0

That is the whole procedure. The workflow runs the test gate, builds every target, and creates the release for the tag if it does not exist yet.

v* taggit push verifyfmt, clippy, tests (self-hosted) build-crossLinux x2 + Windows, arm64 host build-macosuniversal macOS (GitHub-hosted) publishneeds all three4 archives or fail archives as artifacts GitHub release4 archives+ SHA256SUMS
Only publish edits the release, after the test gate and both build jobs, so a release never appears with a platform missing and SHA256SUMS always covers every archive. The macOS build runs beside the test gate rather than after it; nothing it builds is published until the gate is green.
  • A tag containing - (say v0.2.0-rc1) is marked as a prerelease.
  • A dry run publishes nothing. The workflow_dispatch trigger (gh workflow run release.yml -f tag=<ref>) runs every job and leaves the archives as workflow artifacts unless its publish input is checked. A dry run takes any ref, a branch included; publishing takes only an existing tag, since the release action would otherwise mint a new one.

Target matrix

The self-hosted runner is arm64 Linux, so every other target is a cross-compile - including x86_64 Linux.

TargetToolchainTop-level programs
aarch64-unknown-linux-gnuNativeLegend of Legaia, Legaia Explorer
x86_64-pc-windows-gnumingw-w64 crossLegend of Legaia.exe, Legaia Explorer.exe (both GUI-subsystem)
x86_64-unknown-linux-gnucargo-zigbuild + an amd64 ALSA / libudev sysroot, glibc pinned to 2.28As on arm64 Linux
universal-apple-darwinNative on a macOS runner; the two slices fused by lipoLegend of Legaia.app, Legaia Explorer.app

Every target also carries every command-line program. The per-target list is declared in release-build.sh, which fails when an expected program is missing from the build, so a target cannot quietly drop one.

Archive layout

The top folder holds only what a player should open; every command-line program sits one level down.

legaia-tools-<version>-<target>/
    Legend of Legaia            (.exe on Windows, .app on macOS)
    Legaia Explorer             (.exe on Windows, .app on macOS)
    README-PLAY.txt
    LICENSE  LICENSE-MIT  THIRD-PARTY-LICENSES.txt
    packs/
        sample.lxpack           the Sample Expansion Pack
    command-line-tools/
        README.txt              every tool, one line each
        legaia-engine           the engine: launcher, play-window, every subcommand
        asset-viewer  legaia-extract  legaia-patcher  asset  tim  tmd  vab  ...

Legend of Legaia on Linux and Windows is a small launcher stub that starts command-line-tools/legaia-engine with the same arguments; a lone file argument (from "Open with", or a drop on the icon) becomes --open <file>. One copy of the engine serves both the game and the command line. The stub and Explorer's Play button find the engine by walking up to four folders above the running program, which also covers a plain cargo build and a macOS bundle.

The bundled pack. packs/sample.lxpack is the Sample Expansion Pack, bundled at staging time by the format's one deterministic writer, which refuses a pack that fails validation - the no-disc-bytes rule among the checks. What ships is disc coordinates plus the pack's own art and audio.

Icons and the per-platform details

The game and Explorer carry original icons drawn by a script in crates/app-theme/icons/; no game asset is drawn from, and the outputs are committed so a release never runs the script. Windows executables embed the icon through windres; the macOS bundles name it in Info.plist; the engine and Explorer also set a window icon at runtime. release-build.sh fails unless all three Windows executables carry an icon group.

macOS
Built on a GitHub-hosted runner, since Apple's SDK cannot be redistributed onto a Linux one. Both slices are fused into one file per program, deployment target macOS 11. The bundles are ad-hoc signed - arm64 macOS refuses to run unsigned code - but not notarised, so Gatekeeper asks the player to confirm the first launch; README-PLAY.txt gives the steps.
Windows console
legaia-engine.exe is a console program and keeps the terminal contract every subcommand relies on. The top-level Legend of Legaia.exe is a GUI-subsystem stub that starts it with no console window and reports a failure in a message box. The build reads the PE headers and fails unless the stub is GUI and the engine console.
Legaia Explorer
A GUI program on every target with no command-line contract, so it is GUI-subsystem itself on Windows and needs no stub. It draws through OpenGL (egui's glow backend) and links no wgpu.
Why -gnu on Windows
mingw-w64 is an apt package that emits x86_64 PE objects on an arm64 host, and the whole workspace crosses with it, GUI included. The MSVC ABI would need a downloaded Microsoft SDK.
Why an ALSA sysroot
The engine's audio and controller crates bind libasound and libudev through pkg-config, which needs x86_64 copies to cross-link. Arm64 Ubuntu's package mirror publishes no amd64 packages, so the setup script fetches the four .debs from the main archive and unpacks them into a cache - no root, no apt sources touched. The glibc 2.28 pin survives, and the build checks it in the symbol table.

Artifacts

legaia-tools-<version>-aarch64-unknown-linux-gnu.tar.gz
legaia-tools-<version>-x86_64-pc-windows-gnu.zip
legaia-tools-<version>-x86_64-unknown-linux-gnu.tar.gz
legaia-tools-<version>-universal-apple-darwin.tar.gz
SHA256SUMS

<version> is the tag without its leading v. Each archive extracts to one top-level folder. Besides the programs and the license files (the project is MIT OR Unlicense) it carries three generated text files: README-PLAY.txt (what to open, bring your own disc image, controls, where settings and saves live), command-line-tools/README.txt (one line per tool), and THIRD-PARTY-LICENSES.txt.

Third-party notices are written at staging time by scripts/ci/third-party-licenses.py from the crates the programs actually link (cargo tree per target, so features resolve as in the build), with each distinct license text once. The release fails when a linked crate declares no license or a license has no text anywhere.

Where the build host can run the result (the native Linux row and macOS), the build starts the staged engine, game entry and Explorer with --version, so a program that cannot start fails the release rather than a player. To check a download:

sha256sum -c SHA256SUMS --ignore-missing

Runner provisioning

RequirementHow it arrivesRoot?
mingw-w64sudo apt install mingw-w64Yes, once
Rust target stdrustup target add, automaticNo
zigpip wheel into a cache venv, automaticNo
cargo-zigbuildcargo install, automaticNo
amd64 ALSA / libudev sysroot.debs unpacked into the cache, automaticNo
libasound2-dev, libudev-devAlready present for the native buildYes, once

Root-free tooling is cached under $LEGAIA_RELEASE_CACHE (default ~/.cache/legaia-release); deleting it forces a clean reinstall. The macOS job names its hosted image by version; when GitHub retires that image the job fails before its first step, and the fix is to move runs-on to a supported label and rehearse with a build-only dispatch before tagging.

Interaction with main-ci.yml

main-ci.yml does not trigger on tags: two workflows on one tag would contend for the single self-hosted runner. The release stays test-gated anyway, because its verify job runs the same format, lint and test gates, and publish needs it. Disc-gated tests skip there exactly as in CI.

Local rehearsal

Both scripts run outside CI, the fastest way to check a matrix change before tagging:

scripts/ci/setup-cross-toolchain.sh x86_64-pc-windows-gnu
scripts/ci/release-build.sh 0.0.0-test x86_64-pc-windows-gnu

On a Mac the same pair builds the universal-apple-darwin row. The archive and its .sha256 land in target/dist/, inside the gitignored target/.