feat: add musl linux release build

This commit is contained in:
utophii committed 2026-11-03 20:32:47 +03:00
1 parent 49a6e02ee3
commit fefde42033
6 files changed
+151 -9

No files matched your search

+20 -3
View File
@@ -94,6 +94,11 @@ jobs:
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: linux-musl
os: ubuntu-22.04
musl: true
dir: horizon-linux-musl-x86_64
asset: horizon-linux-musl-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
@@ -141,17 +146,19 @@ jobs:
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
if: runner.environment == 'github-hosted' && !matrix.musl
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
if: runner.os == 'Linux' && !matrix.musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
@@ -173,6 +180,7 @@ jobs:
codesign --verify --strict --verbose=2 "$BIN/$binary"
done
- name: Only system libraries
if: ${{ !matrix.musl }}
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
@@ -188,13 +196,14 @@ jobs:
- name: Baseline x86-64 CPUs only
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
if: runner.os != 'macOS'
if: runner.os != 'macOS' && !matrix.musl
run: |
set -euo pipefail
rustup component add llvm-tools
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
- name: Every program starts
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
bin=$BIN
@@ -250,7 +259,15 @@ jobs:
with:
name: original-exporters
path: exporters
- name: Build, check and package for musl in Alpine
if: matrix.musl
# Run Docker on the Ubuntu host: JavaScript actions themselves require glibc.
run: |
docker run --rm -v "$PWD:/work" -w /work \
-e HORIZON_VERSION -e HORIZON_NIGHTLY \
rust:alpine3.22 sh tools/ci/build-musl.sh
- name: Package
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
+2 -1
View File
@@ -173,7 +173,7 @@ jobs:
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
dist/horizon-source.tar.gz dist/horizon-linux-musl-x86_64.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -235,6 +235,7 @@ jobs:
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-linux-musl-x86_64.tar.gz#Game files for musl Linux (horizon-linux-musl-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
+8
View File
@@ -277,6 +277,7 @@ the notes.
| Archive | Runs on | Checks |
| --- | --- | --- |
| `horizon-linux-x86_64.tar.gz` | GitHub `ubuntu-22.04` (an older glibc, so it runs on any recent distribution) | the standard loader and libraries only (`tools/check_imports.py`) |
| `horizon-linux-musl-x86_64.tar.gz` | Alpine 3.22 in Docker on GitHub `ubuntu-22.04`, targeting `x86_64-unknown-linux-musl` | musl loader and libraries only (`tools/check_imports.py --musl`), CPU baseline and every program's startup checked inside Alpine |
| `horizon-server-linux-x86_64.tar.gz` | the same Linux job, after the client archive: `horizon-server`, `horizon-prepare` and the game built with `--no-default-features` as `horizon-engine` (docs/server.md) | as above, and neither `libasound.so.2` nor `libudev.so.1` (`tools/check_imports.py --server`) |
| `horizon-windows-x64.zip` | GitHub `windows-latest`, C runtime linked in | system DLLs only (`tools/check_imports.py`) |
| `horizon-macos-arm64.zip` | The Mac mini's runner (`vars.MAC_RUNNER=macos-latest` sends it to GitHub) | system libraries only (`otool -L`); a 1500-frame offscreen run of a new world (about 12 s; 300 frames end before the world has loaded) with resources prepared from the runner's own Minecraft (`--offscreen --world smoke --frames 1500 --smoke-report`: frames drawn, chunks generated and meshed, a player, no error) |
@@ -287,6 +288,13 @@ each one (`--help`, `--version`), packages the folder with `cargo run -p xtask -
GitHub jobs keep their Cargo caches (`Swatinem/rust-cache`); the Mac mini keeps its `target/`. Only the Mac
runs the game: GitHub's runners have neither a GPU worth the name nor the game files.
The musl archive is a manual download for musl-based Linux desktops. The launcher still selects the glibc
archive. It includes the same client and tools, with audio and controllers enabled. It links musl dynamically
(`-C target-feature=-crt-static`) so graphics drivers and window libraries can be loaded at runtime. Install
musl, libgcc, ALSA, libudev and the desktop's graphics drivers (on Alpine: `musl libgcc alsa-lib eudev-libs`,
plus the appropriate Mesa/Vulkan driver). This is not a fully static binary. The Nightly signs and publishes
both Linux archives. The dedicated-server archive remains the existing glibc build.
### Notes
`tools/ci/nightly.py pick` writes them: what reached main since the previous Nightly (`### New`, label `feature`,
+68
View File
@@ -0,0 +1,68 @@
import contextlib
import io
from pathlib import Path
import struct
import tempfile
import unittest
from tools import check_imports
def elf(interpreter, libraries, runpath=None):
data = bytearray(4096)
data[:7] = b"\x7fELF\x02\x01\x01"
struct.pack_into("<Q", data, 32, 64)
struct.pack_into("<HH", data, 54, 56, 3 if interpreter else 2)
struct.pack_into("<IIQQQQQQ", data, 64, 1, 4, 0, 0x400000, 0, len(data), len(data), 4096)
strings = bytearray(b"\0")
entries = [(5, 0x400400)]
for tag, value in [(1, lib) for lib in libraries] + ([(29, runpath)] if runpath else []):
entries.append((tag, len(strings)))
strings.extend(value.encode() + b"\0")
entries.append((0, 0))
struct.pack_into("<IIQQQQQQ", data, 120, 2, 4, 512, 0x400200, 0,
len(entries) * 16, len(entries) * 16, 8)
if interpreter:
raw = interpreter.encode() + b"\0"
data[256:256 + len(raw)] = raw
struct.pack_into("<IIQQQQQQ", data, 176, 3, 4, 256, 0x400100, 0, len(raw), len(raw), 1)
for i, entry in enumerate(entries):
struct.pack_into("<qQ", data, 512 + i * 16, *entry)
data[1024:1024 + len(strings)] = strings
return data
class LinuxImportsTests(unittest.TestCase):
def check_binary(self, interpreter, libraries, *, runpath=None, **options):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "program"
path.write_bytes(elf(interpreter, libraries, runpath))
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
return check_imports.check(path, **options)
def test_musl_desktop(self):
self.assertTrue(self.check_binary("/lib/ld-musl-x86_64.so.1",
["libc.musl-x86_64.so.1", "libgcc_s.so.1", "libasound.so.2", "libudev.so.1"],
musl=True))
def test_musl_requires_its_dynamic_loader(self):
for loader in (None, "/lib64/ld-linux-x86-64.so.2", "/nix/store/musl/lib/ld-musl-x86_64.so.1"):
with self.subTest(loader=loader):
self.assertFalse(self.check_binary(loader, [], musl=True))
def test_musl_rejects_glibc_and_unexpected_libraries(self):
for library in ("libc.so.6", "libpthread.so.0", "libm.so.6", "libcustom.so"):
with self.subTest(library=library):
self.assertFalse(self.check_binary("/lib/ld-musl-x86_64.so.1", [library], musl=True))
def test_glibc_policy_still_rejects_musl(self):
self.assertTrue(self.check_binary("/lib64/ld-linux-x86-64.so.2", ["libc.so.6", "libasound.so.2"]))
self.assertFalse(self.check_binary("/lib/ld-musl-x86_64.so.1", ["libc.musl-x86_64.so.1"]))
def test_server_and_runpath_checks_apply_to_both_abis(self):
for musl, loader in ((False, "/lib64/ld-linux-x86-64.so.2"), (True, "/lib/ld-musl-x86_64.so.1")):
with self.subTest(musl=musl):
self.assertTrue(self.check_binary(loader, ["libgcc_s.so.1"], musl=musl, server=True))
for lib in ("libasound.so.2", "libudev.so.1"):
self.assertFalse(self.check_binary(loader, [lib], musl=musl, server=True))
self.assertFalse(self.check_binary(loader, [], musl=musl, runpath="/nix/store/libs"))
+14 -5
View File
@@ -7,6 +7,7 @@ loaded at run time, so a missing driver is an error message, not a dialog.
Linux ELF: the loader must be the standard one (not a /nix/store path) and the
needed libraries the ones every desktop distribution has. `--server`: a server
build, which may not need ALSA or udev either (minimal server images lack them).
`--musl`: require the musl loader and libraries instead of glibc.
"""
import argparse
@@ -33,6 +34,8 @@ LINUX_SYSTEM = {
"libc.so.6", "libm.so.6", "libgcc_s.so.1", "libdl.so.2", "libpthread.so.0", "librt.so.1",
"ld-linux-x86-64.so.2", "libasound.so.2", "libudev.so.1",
}
MUSL_LOADER = "/lib/ld-musl-x86_64.so.1"
MUSL_SYSTEM = {"libc.musl-x86_64.so.1", "libgcc_s.so.1", "libasound.so.2", "libudev.so.1"}
# The game's sound (cpal) and controllers (gilrs): a desktop has them, a server image may not.
LINUX_DESKTOP_ONLY = {"libasound.so.2", "libudev.so.1"}
@@ -116,18 +119,23 @@ def elf_needs(data):
return interpreter, needed, runpath
def check(path, server=False):
def check(path, server=False, musl=False):
data = Path(path).read_bytes()
problems = []
if data[:2] == b"MZ":
if musl:
problems.append("expected a musl ELF binary, got a Windows executable")
names = pe_imports(data)
problems += [f"imports {n}, which Windows does not ship" for n in names if not windows_allowed(n)]
summary = ", ".join(sorted(set(n.lower() for n in names)))
else:
interpreter, needed, runpath = elf_needs(data)
if interpreter not in (None, LINUX_LOADER):
problems.append(f"loader is {interpreter}, not {LINUX_LOADER}")
problems += [f"needs {n}, which not every distribution has" for n in needed if n not in LINUX_SYSTEM]
loader = MUSL_LOADER if musl else LINUX_LOADER
libraries = MUSL_SYSTEM if musl else LINUX_SYSTEM
# The desktop musl build must be dynamic: GPU and window libraries use dlopen.
if interpreter != loader and (musl or interpreter is not None):
problems.append(f"loader is {interpreter}, not {loader}")
problems += [f"needs {n}, which not every distribution has" for n in needed if n not in libraries]
if server:
problems += [f"needs {n}, which a server may not have" for n in needed if n in LINUX_DESKTOP_ONLY]
problems += [f"has a run path to {p}" for p in runpath if "/nix/store" in p]
@@ -143,8 +151,9 @@ def main():
parser.add_argument("binaries", nargs="+")
parser.add_argument("--server", action="store_true",
help="a server build: not even ALSA or udev (the game's sound and controllers)")
parser.add_argument("--musl", action="store_true", help="require dynamic musl instead of glibc")
args = parser.parse_args()
ok = all([check(path, args.server) for path in args.binaries])
ok = all([check(path, args.server, args.musl) for path in args.binaries])
sys.exit(0 if ok else 1)
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Called inside rust:alpine3.22 by game-build.yml, without release credentials.
set -eu
apk add --no-cache build-base pkgconf alsa-lib-dev eudev-dev python3 git
git config --global --add safe.directory /work
rustup component add llvm-tools
# Static musl cannot dlopen the graphics/window libraries. Keep audio and controllers,
# and link against Alpine's native libraries rather than Ubuntu's glibc libraries.
export RUSTFLAGS='-C target-feature=-crt-static'
target=x86_64-unknown-linux-musl
bin="target/$target/release"
cargo build --release --locked --target "$target" \
-p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl \
-p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
set -- "$bin/horizon-client" "$bin/horizon-ctl" "$bin/horizon-prepare" "$bin/horizon-server"
python3 tools/check_imports.py --musl "$@"
python3 tools/check_cpu_baseline.py "$@"
# Even --help must never touch real player data.
export HORIZON_HOME="$(mktemp -d)"
export HORIZON_LAUNCHER_HOME="$(mktemp -d)"
export XDG_DATA_HOME="$(mktemp -d)"
export XDG_CONFIG_HOME="$(mktemp -d)"
export HORIZON_LAUNCHER_NO_SELF_UPDATE=1 HORIZON_NO_AUDIO=1
"$bin/horizon-client" --help > "$HORIZON_HOME/client-help"
grep -q -- '--resources' "$HORIZON_HOME/client-help"
"$bin/horizon-ctl" --help > /dev/null
"$bin/horizon-prepare" --help > "$HORIZON_HOME/prepare-help"
grep -q -- '--minecraft-dir' "$HORIZON_HOME/prepare-help"
for binary in horizon-prepare horizon-server; do
test "$("$bin/$binary" --version)" = "$binary $HORIZON_VERSION"
done
cargo run --locked --target "$target" -p xtask -- package \
"$bin/horizon-client" dist/horizon-linux-musl-x86_64
tar -C dist -czf dist/horizon-linux-musl-x86_64.tar.gz horizon-linux-musl-x86_64