From ee7b266d45685a07cbff367a9bbc2b2f60bd232d Mon Sep 17 00:00:00 2001 From: Netherwarlord Date: Tue, 28 Jul 2026 23:10:29 -0400 Subject: [PATCH] feat: add packaging scripts, build process, and GPU acceleration documentation --- .gitattributes | 9 ++ .github/workflows/release.yml | 79 +++++++++++++ .gitignore | 4 + README.md | 48 ++++++++ packaging/GPU-ACCELERATION.md | 63 ++++++++++ packaging/LM-Gambit.bat | 86 ++++++++++++++ packaging/LM-Gambit.sh | 96 +++++++++++++++ packaging/build.py | 216 ++++++++++++++++++++++++++++++++++ 8 files changed, 601 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/release.yml create mode 100644 packaging/GPU-ACCELERATION.md create mode 100644 packaging/LM-Gambit.bat create mode 100644 packaging/LM-Gambit.sh create mode 100644 packaging/build.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cb66700 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6f55a46 --- /dev/null +++ b/.github/workflows/release.yml @@ -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. diff --git a/.gitignore b/.gitignore index 5278db0..c11ac2e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index be9695d..f000428 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packaging/GPU-ACCELERATION.md b/packaging/GPU-ACCELERATION.md new file mode 100644 index 0000000..8cbe9ba --- /dev/null +++ b/packaging/GPU-ACCELERATION.md @@ -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. diff --git a/packaging/LM-Gambit.bat b/packaging/LM-Gambit.bat new file mode 100644 index 0000000..ef1847a --- /dev/null +++ b/packaging/LM-Gambit.bat @@ -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 diff --git a/packaging/LM-Gambit.sh b/packaging/LM-Gambit.sh new file mode 100644 index 0000000..03ca429 --- /dev/null +++ b/packaging/LM-Gambit.sh @@ -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 </dev/null; then + cat >&2 <&2 <-.{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())