mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
feat: add musl linux release build
This commit is contained in:
1 parent
49a6e02ee3
commit
fefde42033
6 files changed
+151
-9
No files matched your search
@@ -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 }}"
|
||||
|
||||
@@ -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)" \
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user