Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions .github/workflows/linux-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Linux build steps mirror release-desktop.yml; this workflow never publishes.
name: Linux package compatibility
'on':
pull_request:
paths:
- .github/workflows/**
- scripts/**
- tauri-shell/src-tauri/**
- texada/**
- pyproject.toml
- uv.lock
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: linux-package-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
name: Ubuntu 22.04 packaged startup (offline)
runs-on: ubuntu-22.04
timeout-minutes: 90
strategy:
matrix:
include:
- name: Linux compatibility packages
os: ubuntu-22.04
rust-targets: x86_64-unknown-linux-gnu
tauri-args: --target x86_64-unknown-linux-gnu --bundles deb,appimage
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Read source version
id: version
run: python -c 'import tomllib; print("version=" + tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])' >> "$GITHUB_OUTPUT"
- name: Install uv
run: python -m pip install --upgrade uv
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-targets }}
- name: Install Linux native build and installation dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential pkg-config libglib2.0-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 xvfb xauth cmake
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: tauri-shell/src-tauri -> target
- name: Restore pinned Tauri CLI
id: cli-cache
uses: actions/cache/restore@v4
with:
path: ~/.cargo/bin/cargo-tauri*
key: tauri-cli-2.12.2-${{ matrix.rust-targets }}-${{ matrix.os }}
- name: Install Tauri CLI
if: steps.cli-cache.outputs.cache-hit != 'true'
run: cargo install tauri-cli --version "2.12.2" --locked
- name: Save pinned Tauri CLI
if: steps.cli-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ~/.cargo/bin/cargo-tauri*
key: tauri-cli-2.12.2-${{ matrix.rust-targets }}-${{ matrix.os }}
- name: Build pinned Linux llama-server on the compatibility baseline
run: bash scripts/build-linux-llama-server.sh
- name: Build bundled FastAPI backend sidecar
run: python scripts/build-backend-sidecar.py --mode pyinstaller --target ${{ matrix.rust-targets }}
- name: Build desktop bundle
uses: tauri-apps/tauri-action@v1
env:
APPIMAGE_EXTRACT_AND_RUN: '1'
with:
projectPath: tauri-shell/src-tauri
tauriScript: cargo tauri
args: ${{ matrix.tauri-args }}
- name: Normalize installer filenames for stable download links
shell: bash
env:
RELEASE_VERSION: ${{ steps.version.outputs.version }}
RELEASE_TARGET: ${{ matrix.rust-targets }}
run: |
python - <<'PY'
import os
from pathlib import Path

version, target = os.environ["RELEASE_VERSION"], os.environ["RELEASE_TARGET"]
layouts = {
"aarch64-apple-darwin": [("dmg", "*.dmg", f"TeXada_{version}_aarch64.dmg")],
"x86_64-apple-darwin": [("dmg", "*.dmg", f"TeXada_{version}_x64.dmg")],
"x86_64-pc-windows-msvc": [("nsis", "*.exe", f"TeXada_{version}_x64-setup.exe")],
"x86_64-unknown-linux-gnu": [("deb", "*.deb", f"TeXada_{version}_amd64.deb"),
("appimage", "*.AppImage", f"TeXada_{version}_amd64.AppImage")],
}
for bundle, pattern, name in layouts[target]:
candidates = list(Path("tauri-shell/src-tauri/target").glob(f"**/bundle/{bundle}/{pattern}"))
if len(candidates) != 1:
raise SystemExit(f"Expected one {bundle} installer, found {len(candidates)}")
original = candidates[0]
original.replace(original.with_name(name))
PY
- name: Reject newer glibc imports in Linux package contents
shell: bash
run: |
set -euo pipefail
appdir="$(find tauri-shell/src-tauri/target -type d -name '*.AppDir' -print -quit)"
test -n "$appdir"
python scripts/check-linux-glibc.py "$appdir"
- name: Smoke both installed Linux packages under Xvfb
shell: bash
run: |
set -euo pipefail
for format in deb AppImage; do
if [ "$format" = deb ]; then pattern='*/bundle/deb/*.deb'; else pattern='*/bundle/appimage/*.AppImage'; fi
installer="$(find tauri-shell/src-tauri/target -path "$pattern" -type f -print -quit)"
test -n "$installer" || { echo "::error::Linux $format installer missing."; exit 1; }
# Block external networking while retaining loopback for the sidecar.
xvfb-run -a sudo --preserve-env=DISPLAY,XAUTHORITY unshare --net -- \
bash -c 'ip link set lo up; exec runuser -u "$1" -- "${@:2}"' \
bash "$USER" "$(command -v python)" scripts/smoke-installed-linux.py --installer "$installer" --format "$format" --version "${{ steps.version.outputs.version }}" --expected-commit "$(git rev-parse HEAD)" --out "native-smoke-${{ matrix.rust-targets }}-$format.json"
done
- name: Upload installer artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
if-no-files-found: error
path: |
tauri-shell/src-tauri/target/**/bundle/dmg/*.dmg
tauri-shell/src-tauri/target/**/bundle/nsis/*.exe
tauri-shell/src-tauri/target/**/bundle/deb/*.deb
tauri-shell/src-tauri/target/**/bundle/appimage/*.AppImage
native-smoke-*.json
notarization-*.json
23 changes: 20 additions & 3 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ jobs:
rust-targets: x86_64-pc-windows-msvc
tauri-args: --target x86_64-pc-windows-msvc --bundles nsis
- name: Linux x64 deb and AppImage
os: ubuntu-24.04
os: ubuntu-22.04
rust-targets: x86_64-unknown-linux-gnu
tauri-args: --target x86_64-unknown-linux-gnu --bundles deb,appimage

Expand All @@ -146,7 +146,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y build-essential pkg-config libglib2.0-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2t64 xvfb xauth
sudo apt-get install -y build-essential pkg-config libglib2.0-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 xvfb xauth cmake

- name: Rust cache
uses: swatinem/rust-cache@v2
Expand Down Expand Up @@ -223,7 +223,12 @@ jobs:
exit 1
}

- name: Build pinned Linux llama-server on the compatibility baseline
if: runner.os == 'Linux'
run: bash scripts/build-linux-llama-server.sh

- name: Fetch pinned llama-server binary
if: runner.os != 'Linux'
run: bash scripts/fetch-llama-server.sh "${{ matrix.rust-targets }}"

- name: Sign bundled macOS llama runtime
Expand Down Expand Up @@ -515,6 +520,15 @@ jobs:
python scripts/smoke-installed-desktop.py $installer.FullName --version "${{ needs.preflight.outputs.version }}" --expected-commit "$env:GITHUB_SHA" --output "native-smoke-${{ matrix.rust-targets }}.json"
if ($LASTEXITCODE -ne 0) { throw "Installed Windows smoke failed" }

- name: Reject newer glibc imports in Linux package contents
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
appdir="$(find tauri-shell/src-tauri/target -type d -name '*.AppDir' -print -quit)"
test -n "$appdir"
python scripts/check-linux-glibc.py "$appdir"

- name: Smoke both installed Linux packages under Xvfb
if: runner.os == 'Linux'
shell: bash
Expand All @@ -524,7 +538,10 @@ jobs:
if [ "$format" = deb ]; then pattern='*/bundle/deb/*.deb'; else pattern='*/bundle/appimage/*.AppImage'; fi
installer="$(find tauri-shell/src-tauri/target -path "$pattern" -type f -print -quit)"
test -n "$installer" || { echo "::error::Linux $format installer missing."; exit 1; }
xvfb-run -a python scripts/smoke-installed-linux.py --installer "$installer" --format "$format" --version "${{ needs.preflight.outputs.version }}" --expected-commit "$GITHUB_SHA" --out "native-smoke-${{ matrix.rust-targets }}-$format.json"
# Block external networking while retaining loopback for the sidecar.
xvfb-run -a sudo --preserve-env=DISPLAY,XAUTHORITY unshare --net -- \
bash -c 'ip link set lo up; exec runuser -u "$1" -- "${@:2}"' \
bash "$USER" "$(command -v python)" scripts/smoke-installed-linux.py --installer "$installer" --format "$format" --version "${{ needs.preflight.outputs.version }}" --expected-commit "$GITHUB_SHA" --out "native-smoke-${{ matrix.rust-targets }}-$format.json"
done

- name: Upload installer artifact
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ GitHub Actions builds release installers manually from `main` and automatically

### Shortcuts

Linux packaging targets the Ubuntu 22.04 / glibc 2.35 baseline. The desktop, Python sidecar and pinned llama-server are built on that baseline; PR package CI checks imported glibc symbols and starts both installers with external networking disabled (loopback remains available for the local backend). This is a compatibility gate, not validation of every Linux distribution.

Linux system-cursor insertion is not implemented; formula clicks copy for manual paste. Wayland integration and Linux shortcuts have not received independent manual acceptance testing.

| Action | Shortcut |
Expand Down
45 changes: 45 additions & 0 deletions scripts/build-linux-llama-server.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Build the pinned runtime on the same glibc baseline as the desktop/sidecar.
# Upstream Ubuntu binaries can require a newer glibc than our supported hosts.
set -euo pipefail
SOURCE_COMMIT=620d2225cc436ab0b001e3200e590e3b286ef9ab
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RES_DIR="$ROOT/tauri-shell/src-tauri/resources/llama-server"
WORK="$(mktemp -d)"
STAGED=""
trap 'rm -rf "$WORK"; if [ -n "$STAGED" ]; then rm -rf "$STAGED"; fi' EXIT
# Keep history: upstream derives build 11669 from the commit count.
git clone --filter=blob:none --no-checkout https://github.com/ggml-org/llama.cpp.git "$WORK/source"
git -C "$WORK/source" checkout --detach "$SOURCE_COMMIT"
test "$(git -C "$WORK/source" rev-parse HEAD)" = "$SOURCE_COMMIT"
cmake -S "$WORK/source" -B "$WORK/build" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_BUILD_RPATH_USE_ORIGIN=ON -DGGML_NATIVE=OFF -DGGML_BACKEND_DL=OFF \
-DGGML_CUDA=OFF -DGGML_VULKAN=OFF -DLLAMA_OPENSSL=OFF \
-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=ON
cmake --build "$WORK/build" --target llama-server --parallel 2
VERSION_OUT="$("$WORK/build/bin/llama-server" --version 2>&1)"
grep -Eq '\bbuild[[:space:]]+11669\b' <<< "$VERSION_OUT"
mkdir -p "$(dirname "$RES_DIR")"
STAGED="$(mktemp -d "${RES_DIR}.staging.XXXXXX")"
cp -a "$WORK/build/bin"/. "$STAGED"/
# Verify that copied libraries resolve without depending on the build directory.
"$STAGED/llama-server" --version
python3 - "$STAGED/runtime-source.json" "$SOURCE_COMMIT" <<'PY'
import json
import sys
from pathlib import Path
Path(sys.argv[1]).write_text(json.dumps({
"schema_version": 1, "target": "x86_64-unknown-linux-gnu", "build": 11669,
"source_commit": sys.argv[2], "build_mode": "native-source",
"source_url": "https://github.com/ggml-org/llama.cpp/tree/" + sys.argv[2],
}, indent=2) + "\n")
PY
PREVIOUS="${RES_DIR}.previous.$$"
if [ -e "$RES_DIR" ]; then mv "$RES_DIR" "$PREVIOUS"; fi
if ! mv "$STAGED" "$RES_DIR"; then
if [ -e "$PREVIOUS" ]; then mv "$PREVIOUS" "$RES_DIR"; fi
exit 1
fi
STAGED=""
rm -rf "$PREVIOUS"
echo "$VERSION_OUT"
39 changes: 39 additions & 0 deletions scripts/check-linux-glibc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Reject packaged ELF requirements newer than the Ubuntu 22.04 glibc baseline."""
import argparse
import re
import subprocess
from pathlib import Path


def check_tree(root: Path, baseline=(2, 35)) -> int:
checked = 0
failures = []
for path in sorted(root.rglob('*')):
if not path.is_file():
continue
with path.open('rb') as stream:
if stream.read(4) != b'\x7fELF':
continue
checked += 1
# Only undefined imports impose requirements on the host. Definitions
# inside a bundled library must not be mistaken for imported versions.
symbols = subprocess.check_output(['readelf', '--dyn-syms', '--wide', str(path)], text=True)
for line in symbols.splitlines():
if not re.search(r'\bUND\b', line):
continue
for version in re.findall(r'GLIBC_(\d+(?:\.\d+)+)', line):
if tuple(map(int, version.split('.'))) > baseline:
failures.append(f'{path.relative_to(root)}: GLIBC_{version}')
if not checked:
raise RuntimeError('No packaged ELF objects found')
if failures:
raise RuntimeError('Packaged glibc requirements exceed 2.35:\n' + '\n'.join(failures))
return checked


if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('root', type=Path)
args = parser.parse_args()
print(f'glibc <= 2.35: checked {check_tree(args.root)} ELF objects')
35 changes: 35 additions & 0 deletions tests/test_linux_glibc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Check host symbol requirements, including weak imports and version ordering."""
import importlib.util
from pathlib import Path

import pytest

spec = importlib.util.spec_from_file_location(
'glibc_check', Path(__file__).parents[1] / 'scripts/check-linux-glibc.py'
)
check = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check)


@pytest.mark.parametrize('version', ['2.38', '2.39', '2.100'])
def test_rejects_newer_imports_including_weak(monkeypatch, tmp_path, version):
(tmp_path / 'library.so').write_bytes(b'\x7fELFfake')
monkeypatch.setattr(check.subprocess, 'check_output', lambda *a, **k:
f'000 w DF UND 000 (GLIBC_{version}) function')
with pytest.raises(RuntimeError, match='exceed 2.35'):
check.check_tree(tmp_path)


def test_ignores_definitions_and_accepts_older_imports(monkeypatch, tmp_path):
(tmp_path / 'library.so').write_bytes(b'\x7fELFfake')
(tmp_path / 'text').write_text('not an ELF')
monkeypatch.setattr(check.subprocess, 'check_output', lambda *a, **k:
'000 g DF .text 000 GLIBC_2.39 definition\n'
'000 g DF UND 000 (GLIBC_2.9) old_import\n'
'000 g DF UND 000 (GLIBC_2.35) baseline_import')
assert check.check_tree(tmp_path) == 1


def test_empty_package_cannot_pass(tmp_path):
with pytest.raises(RuntimeError, match='No packaged ELF'):
check.check_tree(tmp_path)
Loading