feat: add packaging scripts, build process, and GPU acceleration documentation
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -18,6 +18,10 @@ ENV/
|
|||||||
# Ignore other build artifacts
|
# Ignore other build artifacts
|
||||||
*.egg-info/
|
*.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
|
# Frontend
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
|
|||||||
@@ -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)
|
- Node.js 20+ and npm (only to build the interface)
|
||||||
- macOS, Linux or Windows — Apple Silicon, NVIDIA, AMD or CPU-only
|
- 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
|
```bash
|
||||||
python -m pip install -r requirements.txt
|
python -m pip install -r requirements.txt
|
||||||
cd web && npm install && npm run build && cd ..
|
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
|
## Extending
|
||||||
|
|
||||||
**A new provider** — add a class in `.core/providers/` inheriting from `Provider`, implement
|
**A new provider** — add a class in `.core/providers/` inheriting from `Provider`, implement
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user