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
|
||||
*.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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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