feat: add packaging scripts, build process, and GPU acceleration documentation
tests / python (3.10) (push) Successful in 12m30s
tests / python (3.11) (push) Successful in 12m33s
tests / web (push) Successful in 13m3s

This commit is contained in:
Netherwarlord
2026-07-28 23:10:29 -04:00
parent de0304ca54
commit ee7b266d45
8 changed files with 601 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Shell scripts must keep LF endings even when checked out on Windows.
# packaging/build.py copies the launcher straight out of the working tree, so a
# CRLF checkout would ship "#!/usr/bin/env bash\r" inside the Linux and macOS
# tarballs — which fails at exec with "bad interpreter: ...^M". Cross-building
# from Windows is a supported path, so this cannot be left to core.autocrlf.
*.sh text eol=lf
# The Windows launcher wants the opposite; old cmd.exe parsers mishandle LF.
*.bat text eol=crlf
+79
View File
@@ -0,0 +1,79 @@
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
platform: windows
artifact: "*.zip"
- os: ubuntu-latest
platform: linux
artifact: "*.tar.gz"
- os: macos-latest
platform: macos
artifact: "*.tar.gz"
runs-on: ${{ matrix.os }}
steps:
# build.py copies only what "git ls-files" reports, so it needs real
# history rather than a tarball export.
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# No pip install: build.py is stdlib-only, deliberately, so the release
# path cannot be broken by a dependency it does not ship.
- name: Build archive
run: python packaging/build.py --platform ${{ matrix.platform }}
- uses: actions/upload-artifact@v4
with:
name: LM-Gambit-${{ matrix.platform }}
path: dist/${{ matrix.artifact }}
if-no-files-found: error
publish:
# Tag pushes publish; manual runs stop at the artifacts above, so the
# workflow stays usable for a dry run without creating a release.
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
draft: true
generate_release_notes: true
body: |
Download the archive for your platform, extract it, and run the
launcher inside — `LM-Gambit.bat` on Windows, `./LM-Gambit.sh` on
macOS and Linux.
The first run creates a virtual environment and installs
dependencies; that takes a few minutes and happens once. It needs
**Python 3.10 or newer** on the machine. See `GPU-ACCELERATION.md`
in the archive for CUDA, Metal and ROCm.
+4
View File
@@ -18,6 +18,10 @@ ENV/
# Ignore other build artifacts
*.egg-info/
# Release archives from packaging/build.py. Root-anchored so it cannot swallow
# .core/suites or any future nested "dist" that is real source.
/dist/
# Frontend
web/node_modules/
web/dist/
+48
View File
@@ -32,6 +32,23 @@ The `auto-test.py` CLI still drives the same engine the web interface does.
- Node.js 20+ and npm (only to build the interface)
- macOS, Linux or Windows — Apple Silicon, NVIDIA, AMD or CPU-only
### From a release archive
Download the archive for your platform from
[Releases](../../releases), extract it, and run the launcher inside:
`LM-Gambit.bat` on Windows, `./LM-Gambit.sh` on macOS and Linux. It creates a
virtual environment and installs dependencies on first run — a few minutes,
once — then starts the app. The interface is prebuilt, so Node is not needed.
Python 3.10+ still has to be on the machine. The archive is not a frozen
binary, and that is deliberate: `llama-cpp-python` compiles its GPU backend in
at install time, so anything prebuilt would lock every user to whatever
hardware the build machine had. Installing on your machine lets pip pick the
matching build. See `GPU-ACCELERATION.md` in the archive for CUDA, Metal
and ROCm.
### From source
```bash
python -m pip install -r requirements.txt
cd web && npm install && npm run build && cd ..
@@ -447,6 +464,37 @@ from one origin.
---
## Building a release
```bash
python packaging/build.py # archive for the current platform
python packaging/build.py --platform linux # or windows / macos
python packaging/build.py --skip-web # reuse an existing web/dist
```
Output lands in `dist/``.zip` for Windows, `.tar.gz` for macOS and Linux.
The script is stdlib-only, so it runs before any dependency is installed, and
it can cross-build: a Linux tarball produced on Windows is byte-for-byte
equivalent to one produced on Linux, including the executable bit on
`LM-Gambit.sh` (Windows has no execute bit, so the mode is written into the
archive metadata rather than read off the filesystem).
Only files `git ls-files` reports get copied, plus `web/dist`. Local models,
results, settings and virtual environments cannot leak into an archive even
if they are sitting in the working tree.
**Via GitHub Actions**`.github/workflows/release.yml` builds all three on
`windows-latest`, `ubuntu-latest` and `macos-latest`. Pushing a `v*` tag builds
the archives and opens a **draft** release with them attached; `workflow_dispatch`
builds and uploads artifacts without creating a release, which is the way to
test the pipeline.
```bash
git tag v2.0.0 && git push origin v2.0.0
```
---
## Extending
**A new provider** — add a class in `.core/providers/` inheriting from `Provider`, implement
+63
View File
@@ -0,0 +1,63 @@
# GPU acceleration
The first run installs `llama-cpp-python` from PyPI, which is a **CPU build**.
It works everywhere and needs no setup, but it is many times slower than your
GPU.
This is not something the download could have decided for you. `llama-cpp-python`
compiles its backend in, so a CPU wheel stays CPU-only no matter what hardware
it later finds — there is no runtime switch. Getting GPU speed means installing
a different wheel, once.
Run the matching command from **inside this folder**, after the first launch has
created `.venv`.
## NVIDIA (CUDA)
Check your driver's CUDA version with `nvidia-smi`, then match it:
```bash
# Windows
.venv\Scripts\python -m pip install --force-reinstall --no-cache-dir \
llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
# macOS / Linux
.venv/bin/python -m pip install --force-reinstall --no-cache-dir \
llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
```
Swap `cu124` for `cu121` or `cu122` if your driver is older.
## Apple Silicon (Metal)
The PyPI wheel already includes Metal support on arm64 Macs, so there is
usually nothing to do. To force a rebuild against the local SDK:
```bash
CMAKE_ARGS="-DGGML_METAL=on" .venv/bin/python -m pip install \
--force-reinstall --no-cache-dir llama-cpp-python
```
MLX is used automatically for MLX-format models if `mlx-lm` is installed; the
requirements file pulls it in on Apple Silicon.
## AMD (ROCm)
No prebuilt wheels are published, so this compiles from source and needs the
ROCm toolkit installed:
```bash
CMAKE_ARGS="-DGGML_HIPBLAS=on" .venv/bin/python -m pip install \
--force-reinstall --no-cache-dir llama-cpp-python
```
## Confirming it worked
Start LM-Gambit and look at the **Engine** panel in the bottom-left. It reports
the detected architecture. Then run one question and compare tokens/second
against what you saw before — that number is the real test.
A caveat worth knowing: the engine detects your *hardware*, not what
`llama-cpp-python` was built with. On an NVIDIA machine it will report `cuda`
even with a CPU wheel installed. If the architecture says `cuda` but throughput
is unchanged, the wheel is still the CPU one.
+86
View File
@@ -0,0 +1,86 @@
@echo off
setlocal enabledelayedexpansion
rem LM-Gambit launcher (Windows).
rem
rem First run creates a virtual environment beside this script and installs the
rem dependencies into it; later runs skip straight to starting the app.
rem
rem The environment is built on YOUR machine rather than shipped prebuilt, and
rem that is the whole point: llama-cpp-python is a compiled package whose GPU
rem support is baked in at install time. A prebuilt bundle would freeze one
rem choice -- almost certainly CPU-only -- for everybody. Installing here means
rem pip picks the build that matches this machine.
rem
rem For NVIDIA or AMD GPU acceleration, see GPU-ACCELERATION.md.
cd /d "%~dp0"
set "VENV=%~dp0.venv"
if exist "%VENV%\Scripts\python.exe" goto :run
echo First run - setting up. This takes a few minutes and happens once.
echo.
rem The Windows launcher is the reliable way to pick a version; fall back to
rem whatever "python" resolves to, which may be the Microsoft Store stub.
set "PY_CMD="
for %%V in (3.13 3.12 3.11 3.10) do (
if not defined PY_CMD (
py -%%V -c "import sys" >nul 2>&1 && set "PY_CMD=py -%%V"
)
)
if not defined PY_CMD (
python -c "import sys; raise SystemExit(0 if sys.version_info >= (3,10) else 1)" >nul 2>&1 && set "PY_CMD=python"
)
if not defined PY_CMD (
echo No suitable Python found.
echo.
echo LM-Gambit needs Python 3.10 or newer.
echo Download: https://www.python.org/downloads/
echo Tick "Add python.exe to PATH" during installation.
echo.
echo If you see a Microsoft Store window when typing "python", that is a
echo placeholder rather than a real install - use the link above.
echo.
pause
exit /b 1
)
for /f "delims=" %%V in ('%PY_CMD% --version 2^>^&1') do echo Using %%V
%PY_CMD% -m venv "%VENV%"
if errorlevel 1 (
echo.
echo Could not create a virtual environment.
echo.
pause
exit /b 1
)
echo Installing dependencies...
"%VENV%\Scripts\python.exe" -m pip install --quiet --upgrade pip
"%VENV%\Scripts\python.exe" -m pip install -r "%~dp0requirements.txt"
if errorlevel 1 (
echo.
echo Dependency installation failed.
echo.
echo The usual culprit is llama-cpp-python, which compiles from source when
echo no prebuilt wheel matches this platform. That needs Visual Studio
echo Build Tools with the "Desktop development with C++" workload:
echo https://visualstudio.microsoft.com/visual-cpp-build-tools/
echo.
echo The setup is resumable - run this again once that is installed.
echo.
rmdir /s /q "%VENV%" 2>nul
pause
exit /b 1
)
echo.
echo Setup complete.
echo.
:run
"%VENV%\Scripts\python.exe" "%~dp0app.py" %*
if errorlevel 1 pause
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# LM-Gambit launcher (macOS / Linux).
#
# First run creates a virtual environment beside this script and installs the
# dependencies into it; later runs skip straight to starting the app.
#
# The environment is built on YOUR machine rather than shipped prebuilt, and
# that is the whole point: llama-cpp-python is a compiled package whose GPU
# support is baked in at install time. A prebuilt bundle would freeze one
# choice — almost certainly CPU-only — for everybody. Installing here means pip
# picks the build that matches this machine.
#
# For NVIDIA, AMD or Apple GPU acceleration, see GPU-ACCELERATION.md.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
VENV="$HERE/.venv"
PYTHON_MIN="3.10"
find_python() {
for candidate in python3.13 python3.12 python3.11 python3.10 python3 python; do
if command -v "$candidate" >/dev/null 2>&1; then
if "$candidate" -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" 2>/dev/null; then
echo "$candidate"
return 0
fi
fi
done
return 1
}
if [ ! -x "$VENV/bin/python" ]; then
echo "First run — setting up. This takes a few minutes and happens once."
echo
if ! PYTHON="$(find_python)"; then
cat >&2 <<EOF
No suitable Python found.
LM-Gambit needs Python $PYTHON_MIN or newer on your PATH.
macOS brew install python@3.12
or download from https://www.python.org/downloads/
Linux sudo apt install python3 python3-venv (Debian/Ubuntu)
sudo dnf install python3 (Fedora)
Then run this script again.
EOF
exit 1
fi
echo "Using $("$PYTHON" --version) at $(command -v "$PYTHON")"
# python3-venv is a separate package on Debian/Ubuntu and its absence is a
# confusing failure, so say what to install rather than let it fall over.
if ! "$PYTHON" -m venv "$VENV" 2>/dev/null; then
cat >&2 <<EOF
Could not create a virtual environment.
On Debian or Ubuntu this usually means the venv module is missing:
sudo apt install python3-venv
EOF
exit 1
fi
echo "Installing dependencies..."
"$VENV/bin/python" -m pip install --quiet --upgrade pip
if ! "$VENV/bin/python" -m pip install -r "$HERE/requirements.txt"; then
cat >&2 <<EOF
Dependency installation failed.
The usual culprit is llama-cpp-python, which compiles from source when no
prebuilt wheel matches this platform. It needs a C compiler:
macOS xcode-select --install
Linux sudo apt install build-essential cmake
The setup is resumable — run this script again once that is in place.
EOF
rm -rf "$VENV"
exit 1
fi
echo
echo "Setup complete."
echo
fi
exec "$VENV/bin/python" "$HERE/app.py" "$@"
+216
View File
@@ -0,0 +1,216 @@
"""Assemble a portable LM-Gambit archive.
python packaging/build.py # for the current platform
python packaging/build.py --platform linux # or windows / macos
python packaging/build.py --skip-web # reuse an existing web/dist
Produces ``dist/LM-Gambit-<version>-<platform>.{zip,tar.gz}`` containing the
application source, the prebuilt web interface, and a launcher that creates a
virtual environment on first run.
**Why not a frozen binary.** ``llama-cpp-python`` compiles its backend in at
install time, so a bundled interpreter would freeze one choice — whatever the
build machine had, which for a CI runner means CPU-only — for every user
forever. Installing on the target machine lets pip pick the wheel that matches
that machine's hardware. The cost is a Python requirement; the alternative is
shipping something that cannot use anybody's GPU.
Only files git tracks are copied, so the archive cannot pick up local models,
results, settings or virtual environments.
"""
from __future__ import annotations
import argparse
import os
import platform
import re
import shutil
import subprocess
import tarfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PACKAGING = ROOT / "packaging"
DIST = ROOT / "dist"
#: Tracked paths that have no business in a distribution.
EXCLUDE_PREFIXES = (
".github/",
"tests_py/",
"packaging/",
"web/src/",
"web/public/",
"web/package.json",
"web/package-lock.json",
"web/tsconfig",
"web/vite.config.ts",
".gitignore",
".python-version",
"requirements-ci.txt",
)
PLATFORMS = {
"windows": {"launcher": "LM-Gambit.bat", "archive": "zip"},
"linux": {"launcher": "LM-Gambit.sh", "archive": "tar.gz"},
"macos": {"launcher": "LM-Gambit.sh", "archive": "tar.gz"},
}
def current_platform() -> str:
system = platform.system().lower()
if system == "windows":
return "windows"
if system == "darwin":
return "macos"
return "linux"
def read_version() -> str:
text = (ROOT / "server" / "__init__.py").read_text(encoding="utf-8")
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
if not match:
raise SystemExit("Could not read __version__ from server/__init__.py")
return match.group(1)
def tracked_files() -> list[str]:
"""Files git tracks, so nothing local leaks into the archive."""
result = subprocess.run(
["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True
)
return [line for line in result.stdout.splitlines() if line.strip()]
def included(path: str) -> bool:
return not any(path.startswith(prefix) for prefix in EXCLUDE_PREFIXES)
def build_web() -> None:
npm = shutil.which("npm") or shutil.which("npm.cmd")
if npm is None:
raise SystemExit("npm not found. Install Node 20+, or pass --skip-web.")
print("Building the web interface...")
for args in (["ci"], ["run", "build"]):
subprocess.run([npm, *args], cwd=ROOT / "web", check=True, shell=False)
def stage(target: str, staging: Path) -> None:
"""Copy the application into a clean staging directory."""
if staging.exists():
shutil.rmtree(staging)
staging.mkdir(parents=True)
copied = 0
for relative in tracked_files():
if not included(relative):
continue
source = ROOT / relative
if not source.is_file():
continue
destination = staging / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
copied += 1
# web/dist is gitignored but is the whole point of shipping a build.
dist_source = ROOT / "web" / "dist"
if not (dist_source / "index.html").is_file():
raise SystemExit(
"web/dist is missing or incomplete. Run without --skip-web, or "
"build it with: cd web && npm ci && npm run build"
)
shutil.copytree(dist_source, staging / "web" / "dist")
launcher = PLATFORMS[target]["launcher"]
_copy_launcher(PACKAGING / launcher, staging / launcher)
shutil.copy2(PACKAGING / "GPU-ACCELERATION.md", staging / "GPU-ACCELERATION.md")
print(f"Staged {copied} tracked files plus the web build.")
def _copy_launcher(source: Path, destination: Path) -> None:
"""Copy a launcher with the line endings its interpreter requires.
``.gitattributes`` pins these too, but the archive must be correct no
matter how the tree was checked out: a CRLF ``.sh`` fails on Linux with
"bad interpreter: ...^M", which is a baffling error for someone who just
downloaded a release. Normalising here means a stale clone or an unusual
``core.autocrlf`` cannot ship a broken launcher.
"""
text = source.read_bytes().replace(b"\r\n", b"\n")
if destination.suffix == ".bat":
text = text.replace(b"\n", b"\r\n")
destination.write_bytes(text)
if destination.suffix == ".sh":
os.chmod(destination, 0o755)
def archive(target: str, staging: Path, version: str) -> Path:
DIST.mkdir(exist_ok=True)
stem = f"LM-Gambit-{version}-{target}"
if PLATFORMS[target]["archive"] == "zip":
out = DIST / f"{stem}.zip"
out.unlink(missing_ok=True)
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(staging.rglob("*")):
if path.is_file():
zf.write(path, Path(stem) / path.relative_to(staging))
return out
out = DIST / f"{stem}.tar.gz"
out.unlink(missing_ok=True)
with tarfile.open(out, "w:gz") as tf:
# arcname roots everything under a single directory so extracting does
# not scatter files into the user's current folder.
tf.add(staging, arcname=stem, filter=_normalize)
return out
def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
"""Force sane POSIX metadata regardless of the build machine.
Windows has no execute bit, so ``os.chmod(0o755)`` on the staged launcher
is a no-op there and the shell script arrives non-executable — a Linux user
extracts it and gets "permission denied". Setting the mode on the archive
member instead makes a tarball built on Windows identical to one built on
Linux. Ownership is zeroed for the same reason: whoever built it is not who
should own the extracted files.
"""
info.uid = info.gid = 0
info.uname = info.gname = "root"
if info.isdir():
info.mode = 0o755
elif info.name.endswith(".sh"):
info.mode = 0o755
else:
info.mode = 0o644
return info
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--platform", choices=sorted(PLATFORMS), default=current_platform())
parser.add_argument("--skip-web", action="store_true", help="Reuse an existing web/dist")
args = parser.parse_args()
version = read_version()
print(f"LM-Gambit {version} -> {args.platform}")
if not args.skip_web:
build_web()
staging = DIST / f"_staging-{args.platform}"
stage(args.platform, staging)
out = archive(args.platform, staging, version)
shutil.rmtree(staging, ignore_errors=True)
size_mb = out.stat().st_size / (1024 * 1024)
print(f"\n {out.relative_to(ROOT)} ({size_mb:.1f} MB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())