Skip to content

Manage per-model recipe options without loading #8468

Manage per-model recipe options without loading

Manage per-model recipe options without loading #8468

name: C++ Server Build, Test, and Release 🚀
on:
push:
branches: ["release-v*"]
tags:
- v*
# `labeled` is here so the ci:* escape hatches take effect without a push.
# Adding `types` replaces the defaults, so the three default activity types
# have to be listed explicitly. `unlabeled` is deliberately absent: removing
# a label only ever drops work, and including it would re-run everything.
pull_request:
types: [opened, synchronize, reopened, labeled]
merge_group:
workflow_dispatch:
inputs:
enable_signing:
description: 'Enable MSI signing with SignPath (for testing)'
required: false
default: false
type: boolean
disable_macos_signing:
description: 'Force the unsigned macOS path (reproduces a fork PR, which has no Apple secrets). Skips the self-hosted inference rigs.'
required: false
default: false
type: boolean
permissions:
contents: write
actions: read # Required for SignPath to read workflow/job details
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'pull_request' && 'pr' || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
LEMONADE_DISABLE_SYSTEMD_JOURNAL: "1"
jobs:
# ========================================================================
# BUILD JOBS - Run on rai-160-sdk workers
# ========================================================================
build-lemonade-server-installer:
name: Build Lemonade Server Installer
runs-on: windows-latest
outputs:
unsigned-artifact-id: ${{ steps.upload-unsigned-msi.outputs.artifact-id }}
steps:
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Install CMake if not available
uses: ./.github/actions/install-cmake-windows
- name: Install WiX Toolset
uses: ./.github/actions/install-wix
- name: Cache FetchContent dependencies
uses: actions/cache@v5
with:
path: build/_deps
# vs2026 marker: VS 2022-built _deps are generator-incompatible with the
# VS 2026 configure, so they must not be restored across the toolchain bump.
key: fetchcontent-windows-vs2026-${{ hashFiles('CMakeLists.txt') }}
restore-keys: |
fetchcontent-windows-vs2026-
- name: Run setup.ps1 to configure environment
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
.\setup.ps1
- name: Build installers
shell: PowerShell
env:
# The Tauri desktop app (cargo + webpack) is roughly a third of this
# build, and it only ends up in lemonade.msi. Every downstream test
# job installs lemonade-server-minimal.msi, so on the PR and
# merge-queue paths the full installer is pure critical-path latency;
# build-lemonade-desktop-installer covers it off the critical path.
BUILD_FULL_INSTALLER: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.enable_signing == true }}
run: |
$ErrorActionPreference = "Stop"
$full = $env:BUILD_FULL_INSTALLER -eq 'true'
$target = if ($full) { "wix_installers" } else { "wix_installer_minimal" }
# wix_installers is the only target that owns two wix build runs, and
# it is only ever built here on the release and signing paths — so a
# parallel configuration used there would first run for real during a
# release. Build it serially; the two MSIs take seconds either way.
$nodes = if ($full) { "/m:1" } else { "/m:2" }
$expected = @(
"build\Release\lemond.exe",
"build\Release\LemonadeServer.exe",
"build\resources\web-app\index.html",
"lemonade-server-minimal.msi"
)
if ($full) { $expected += "build\app\lemonade-app.exe", "lemonade.msi" }
Write-Host "Building target $target..." -ForegroundColor Cyan
# Build by directory (not --preset) so we follow whichever generator
# setup.ps1 auto-detected for the installed Visual Studio version.
# /m lets MSBuild overlap independent projects — the lemonade CLI and
# LemonadeServer otherwise wait for lemonade-server-core to finish.
# Capped at 2 because /MP already spawns one cl.exe per core inside
# each project, and the two multiply: unbounded, a 4-core runner can
# reach 16 concurrent compilers and run itself out of heap.
# Passed straight through rather than via --parallel, which makes
# CMake set CL_MPCount=1 and cancel the /MP in CMakeLists.txt.
cmake --build build --config Release --target $target -- $nodes
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Verify outputs
$failures = @()
$expected | ForEach-Object {
if (-not (Test-Path $_)) {
Write-Host "ERROR: $_ not found!" -ForegroundColor Red
$failures += $_
}
}
if ($failures.Count -gt 0) { exit 1 }
Write-Host "Build and packaging successful!" -ForegroundColor Green
- name: Run MCP client unit test on Windows
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
# Exercise the Windows-only process, quoting, environment-block, and
# pipe/handle paths that Linux and macOS cannot cover.
cmake --build build --config Release --target test_mcp_client_config
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
ctest --test-dir build -C Release --output-on-failure -R "^mcp_client_config$"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload Lemonade Server Installers
id: upload-unsigned-msi
uses: actions/upload-artifact@v7
with:
name: Lemonade_Server_MSI
# Glob rather than two literal paths: lemonade.msi is only built on
# the release/signing path (see BUILD_FULL_INSTALLER above).
path: lemonade*.msi
retention-days: 7
build-lemonade-desktop-installer:
name: Build Lemonade Desktop Installer
# Packaging-only variant: it builds the Tauri desktop app and lemonade.msi,
# which no test job installs. The merge queue gates every merge, so PR
# pushes skip it and any desktop/full-MSI break still blocks at merge-queue
# time. It reports through the `Packaging builds` gate, so it takes that
# gate's `ci:distros` label rather than introducing a second label for one
# check. The release paths are excluded because the build above already
# produces lemonade.msi there; that first clause is the negation of
# BUILD_FULL_INSTALLER above, spelled the same way so the two stay
# provably complementary.
if: >-
!(startsWith(github.ref, 'refs/tags/v') || inputs.enable_signing == true) &&
(github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:distros'))
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Install CMake if not available
uses: ./.github/actions/install-cmake-windows
- name: Install WiX Toolset
uses: ./.github/actions/install-wix
- name: Restore FetchContent dependencies
# Restore-only, and deliberately sharing build-lemonade-server-installer's
# namespace: both jobs run bare setup.ps1, so they configure the same
# preset and produce an identical build/_deps. That job runs
# concurrently and owns the save; saving from here too would just race
# it for the same key and log a reservation failure every cold run.
# Best-effort by construction: on a run that changes CMakeLists.txt
# neither job can hit the exact key, so this one falls back through
# restore-keys or builds the dependencies cold.
uses: actions/cache/restore@v5
with:
path: build/_deps
key: fetchcontent-windows-vs2026-${{ hashFiles('CMakeLists.txt') }}
restore-keys: |
fetchcontent-windows-vs2026-
- name: Run setup.ps1 to configure environment
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
.\setup.ps1
- name: Build desktop installer
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
# /m:1, not /m:2: wix_installer_full depends on both web-app and
# tauri-app, and they both touch src/app — web-app robocopies the whole
# tree while tauri-app runs npm ci inside it. Run concurrently, robocopy
# hits files npm is rewriting and retries under its default
# /R:1000000 /W:30, hanging the job rather than failing.
cmake --build build --config Release --target wix_installer_full -- /m:1
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$failures = @()
@(
"build\app\lemonade-app.exe",
"build\resources\web-app\index.html",
"lemonade.msi"
) | ForEach-Object {
if (-not (Test-Path $_)) {
Write-Host "ERROR: $_ not found!" -ForegroundColor Red
$failures += $_
}
}
if ($failures.Count -gt 0) { exit 1 }
Write-Host "Desktop installer built successfully!" -ForegroundColor Green
- name: Upload Lemonade Desktop Installer
# Without this the MSI is built and discarded, so nothing installs it
# until a tag. Note this is a new artifact name, not a restoration of
# Lemonade_Server_MSI: that one now carries only the minimal MSI off the
# release path. No in-repo consumer reads lemonade.msi from it.
uses: actions/upload-artifact@v7
with:
name: Lemonade_Desktop_MSI
path: lemonade.msi
retention-days: 7
tauri-compile-check:
name: Compile-check the Tauri host (${{ matrix.os }})
# The Tauri host is Rust, which `npm run typecheck` does not touch. Both
# jobs that build it — the Windows desktop installer and the macOS .dmg —
# defer to the merge queue, so without this a broken src-tauri compiles
# nowhere until a PR is already queued.
#
# Windows is not optional here: webview_shim.rs and lib.rs gate code behind
# cfg(target_os = "windows"), and webview2-com/windows are Windows-only
# dependencies, so a Linux-only check would miss exactly the code whose
# packaging job was deferred. cargo check rather than a full build — this
# is a compile canary, not a packaging step, so it catches type and borrow
# errors but not link failures or asset embedding: `custom-protocol` stays
# off, which is also why it needs no built renderer. Those still surface in
# the desktop installer and .dmg jobs at merge-queue time.
#
# Deliberately uncached: a cold check measures ~1.5 min, and the Actions
# cache is a 10 GB LRU pool shared with the FetchContent caches the Windows
# installer jobs restore from.
#
# Lives here rather than in Docs And Style so it can report through the
# `Packaging builds` gate, alongside the desktop installer it stands in for.
# A new job name would otherwise block nothing until someone registered it
# in branch protection.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v5
- name: Install Tauri build dependencies
if: runner.os == 'Linux'
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev \
libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Compile-check the Tauri host
working-directory: src/app/src-tauri
run: cargo check --locked --all-targets
sign-msi-installers:
name: Sign MSI Installers with SignPath
runs-on: windows-latest
needs: build-lemonade-server-installer
# Sign on tag pushes (releases) or when manually enabled via workflow_dispatch
if: startsWith(github.ref, 'refs/tags/v') || inputs.enable_signing == true
steps:
- name: Sign MSI Installers with SignPath
id: sign-msi
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '8103545b-7814-4edc-86d6-a91dc2a2291b'
project-slug: 'lemonade'
signing-policy-slug: 'release-signing'
github-artifact-id: '${{ needs.build-lemonade-server-installer.outputs.unsigned-artifact-id }}'
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: 'signed-msi'
parameters: |
version: "${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'test' }}"
- name: Verify Signed MSI Files
shell: PowerShell
run: |
Write-Host "Verifying signed MSI files..." -ForegroundColor Cyan
if (-not (Test-Path "signed-msi\lemonade-server-minimal.msi")) {
Write-Host "ERROR: Signed lemonade-server-minimal.msi not found!" -ForegroundColor Red
exit 1
}
if (-not (Test-Path "signed-msi\lemonade.msi")) {
Write-Host "ERROR: Signed lemonade.msi not found!" -ForegroundColor Red
exit 1
}
Write-Host "Signed MSI files verified!" -ForegroundColor Green
Get-ChildItem -Path "signed-msi" -Recurse | Format-Table Name, Length
- name: Upload Signed MSI Installers
uses: actions/upload-artifact@v7
with:
name: Lemonade_Server_MSI_Signed
path: |
signed-msi/lemonade-server-minimal.msi
signed-msi/lemonade.msi
retention-days: 7
build-lemonade-deb:
name: Build Lemonade .deb Package
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
container:
image: ghcr.io/lemonade-sdk/lemonade/build-environment:ubuntu24.04
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Configure git safe directory
run: git config --global --add safe.directory $(pwd)
- name: Use the compiler cache primed into the build image
# Release builds opt out so their -fdebug-prefix-map keeps the package
# version dpkg derives from the changelog. Everywhere else it is pinned
# to the value the prime recorded, which is what makes the primed
# objects reusable across commits at all. release-v* is included
# because those pushes also produce .deb and -dbgsym artifacts.
if: ${{ !startsWith(github.ref, 'refs/tags/') && !startsWith(github.ref, 'refs/heads/release-v') }}
shell: bash
run: |
set -euo pipefail
# The prime writes this file itself, so an image built without it —
# including one published before the prime existed — is detected
# rather than assumed. Compiling without a cache is the safe fallback.
debugpath_file=/opt/ccache-debugpath
if [ ! -f "$debugpath_file" ]; then
echo "No primed compiler cache in this build image; compiling without it."
exit 0
fi
{
echo "CMAKE_C_COMPILER_LAUNCHER=ccache"
echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache"
echo "DEB_BUILD_DEBUGPATH=$(cat "$debugpath_file")"
} >> "$GITHUB_ENV"
ccache -z
- name: Prepare Debian build
id: get_version
uses: ./.github/actions/prepare-debian-build
with:
release: '24.04'
codename: 'noble'
- name: Build Debian package
id: build_deb
uses: ./.github/actions/build-debian-package
with:
package-type: binary
deb-version: ${{ steps.get_version.outputs.version }}
- name: Upload .deb package
uses: actions/upload-artifact@v7
with:
name: lemonade-deb
path: ${{ steps.build_deb.outputs.output-dir }}/*.deb
retention-days: 7
- name: Report compiler cache hit rate
# Set only by the opt-in step above, so this covers both the release
# path and an image published before the prime existed.
if: ${{ always() && env.CMAKE_CXX_COMPILER_LAUNCHER == 'ccache' }}
shell: bash
run: |
set -euo pipefail
ccache -s
# Zero hits against a primed image means the prime and this job
# disagree on the compile command line — most likely the workspace
# path, which GitHub derives from the repository name and the prime
# hardcodes. Warn rather than fail: a change to a widely included
# header legitimately invalidates every entry.
stats="$(ccache --print-stats 2>/dev/null || true)"
if ! grep -q '^direct_cache_hit' <<< "$stats"; then
echo "Could not read ccache counters; skipping the hit-rate check."
exit 0
fi
hits="$(awk -F'\t' '/^(direct|preprocessed)_cache_hit\t/ { n += $2 } END { print n+0 }' <<< "$stats")"
echo "Cache hits this build: ${hits}"
if [ "$hits" -eq 0 ]; then
echo "::warning title=Primed ccache produced no hits::The build image ships a primed cache but nothing matched. Check that the prime path in .ubuntu/Dockerfile still matches this job's workspace."
fi
cpp-unit-tests:
name: C++ unit tests
# Own job rather than a step of build-lemonade-deb: it shares nothing with
# the packaging build (different configure, different flags), so as a step
# it just added its full build time to the critical path that gates every
# .deb test job. Same build-environment image, so the configure resolves
# the same system dependencies.
runs-on: ubuntu-latest
container:
image: ghcr.io/lemonade-sdk/lemonade/build-environment:ubuntu24.04
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v5
- name: Configure git safe directory
run: git config --global --add safe.directory $(pwd)
- name: Install build dependencies
# As a step of build-lemonade-deb this inherited the build-dep install
# that job performs. Standalone it would rely on whatever the nightly
# image baked in, so a newly added Build-Depends would fail here — and
# not in the .deb job, which installs them — until the image caught up.
shell: bash
run: |
set -euo pipefail
apt-get update && apt-get build-dep ./contrib -y
- name: Build and run C++ unit tests
shell: bash
run: |
set -euo pipefail
cmake --preset default
cmake --build --preset default --target cpp-ci-tests
ctest --test-dir build --output-on-failure --no-tests=error -L "^cpp-ci$"
build-lemonade-debian13:
name: Build Lemonade .deb Package (Debian 13)${{ matrix.job-suffix }}
# Packaging-only variant: the merge queue gates every merge, so PR pushes
# skip it. Any Debian 13 build break still blocks at merge-queue time.
# Label a PR `ci:distros` to opt it back in.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:distros')
runs-on: ${{ matrix.runs-on }}
outputs:
version: ${{ steps.get_version.outputs.version }}
container:
image: debian:trixie
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
include:
- arch-name: X86_64
runs-on: ubuntu-latest
deb-arch: amd64
artifact-suffix: x86_64
job-suffix: ""
- arch-name: ARM64
runs-on: ubuntu-24.04-arm
deb-arch: arm64
artifact-suffix: arm64
job-suffix: " (ARM64)"
steps:
- name: Enable Trixie backports
run: |
echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/trixie-backports.list
- name: Install git
run: |
set -e
apt-get update
apt-get install -y git
- name: Install from backports
run: |
set -e
apt-get install -y -t trixie-backports libcpp-httplib-dev node-markdown-it-texmath
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Configure git safe directory
run: git config --global --add safe.directory $(pwd)
- name: Prepare Debian build
id: get_version
uses: ./.github/actions/prepare-debian-build
with:
release: '13'
codename: 'trixie'
- name: Build Debian package
id: build_deb
uses: ./.github/actions/build-debian-package
with:
package-type: binary
deb-version: ${{ steps.get_version.outputs.version }}
- name: Rename server .deb for release
id: rename
shell: bash
run: |
set -e
VERSION="${{ steps.get_version.outputs.version }}"
OUTPUT_DIR="${{ steps.build_deb.outputs.output-dir }}"
SRC=$(ls "$OUTPUT_DIR"/lemonade-server_*_${{ matrix.deb-arch }}.deb | head -n1)
if [ -z "$SRC" ]; then
echo "ERROR: server .deb not found in $OUTPUT_DIR"
ls -la "$OUTPUT_DIR"
exit 1
fi
DEST="$OUTPUT_DIR/lemonade-server_${VERSION}-debian13_${{ matrix.deb-arch }}.deb"
mv "$SRC" "$DEST"
echo "Renamed $SRC -> $DEST"
echo "deb-path=$DEST" >> $GITHUB_OUTPUT
- name: Upload .deb package
uses: actions/upload-artifact@v7
with:
name: lemonade-debian13-${{ matrix.artifact-suffix }}
path: ${{ steps.rename.outputs.deb-path }}
retention-days: 7
test-lemonade-debian13-smoke:
name: Smoke test .deb (Debian 13)${{ matrix.job-suffix }}
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:distros')
runs-on: ${{ matrix.runs-on }}
needs: build-lemonade-debian13
container:
image: debian:trixie
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
include:
- arch-name: X86_64
runs-on: ubuntu-latest
deb-arch: amd64
artifact-suffix: x86_64
job-suffix: ""
- arch-name: ARM64
runs-on: ubuntu-24.04-arm
deb-arch: arm64
artifact-suffix: arm64
job-suffix: " (ARM64)"
env:
LEMONADE_VERSION: ${{ needs.build-lemonade-debian13.outputs.version }}
steps:
- name: Download Lemonade .deb Package
uses: actions/download-artifact@v7
with:
name: lemonade-debian13-${{ matrix.artifact-suffix }}
path: .
- name: Install and smoke test
shell: bash
run: |
set -e
DEB_FILE=$(ls lemonade-server_*-debian13_${{ matrix.deb-arch }}.deb 2>/dev/null | head -n1)
if [ -z "$DEB_FILE" ]; then
echo "ERROR: .deb file not found"
ls -la *.deb 2>/dev/null || echo "No .deb files found in current directory"
exit 1
fi
echo "Debian 13 ${{ matrix.arch-name }} version: $(cat /etc/os-release | grep PRETTY_NAME)"
echo "Testing artifact: $DEB_FILE"
echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/trixie-backports.list
apt-get update
apt-get install -y curl
apt-get install -y -t trixie-backports ./"$DEB_FILE"
/usr/bin/lemond --version
/usr/bin/lemonade --version
echo "Package installed successfully"
mkdir -p "$RUNNER_TEMP/xdg-runtime"
chmod 700 "$RUNNER_TEMP/xdg-runtime"
export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime"
/usr/bin/lemond "$(pwd)" > "$RUNNER_TEMP/lemonade-server.log" 2>&1 &
SERVER_PID=$!
echo "Server started (PID $SERVER_PID)"
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:13305/live > /dev/null 2>&1; then
echo "Server is running and healthy on port 13305"
break
fi
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "ERROR: Server process exited prematurely"
echo "=== Server log ==="
cat "$RUNNER_TEMP/lemonade-server.log" 2>/dev/null || echo "(no log)"
exit 1
fi
echo "Waiting for server... ($i/30)"
sleep 2
done
kill $SERVER_PID 2>/dev/null || true
build-lemonade-rpm:
name: Build Lemonade .rpm - Fedora ${{ matrix.fedora-version }}${{ matrix.job-suffix }}
# Packaging-only variant: the merge queue gates every merge, so PR pushes
# skip it. Any Fedora build break still blocks at merge-queue time.
# Label a PR `ci:distros` to opt it back in.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:distros')
runs-on: ${{ matrix.runs-on }}
container:
image: fedora:${{ matrix.fedora-version }}
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
fedora-version: ["43", "44"]
runs-on: ["ubuntu-latest", "ubuntu-24.04-arm"]
include:
- runs-on: ubuntu-latest
arch-name: X86_64
rpm-arch: x86_64
artifact-suffix: x86_64
job-suffix: ""
- runs-on: ubuntu-24.04-arm
arch-name: ARM64
rpm-arch: aarch64
artifact-suffix: arm64
job-suffix: " (ARM64)"
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Install RPM packaging tools
shell: bash
run: |
set -e
dnf install -y rpm-build
- name: Get version from CMakeLists.txt
id: get_version
uses: ./.github/actions/get-version
- name: Build Linux .rpm package
shell: bash
run: |
set -e
echo "Running setup.sh to configure build environment..."
bash setup.sh
# setup.sh configures with testing on, and the build below has no
# --target, so every unit-test binary would be built and then left out
# of the .rpm. Same reasoning as BUILD_TESTING=OFF in contrib/debian/rules.
echo "Reconfiguring without the unit-test targets..."
cmake --preset default -DBUILD_TESTING=OFF
echo "Building lemond and lemonade for Fedora ${{ matrix.fedora-version }} (${{ matrix.arch-name }})..."
cmake --build --preset default
cd build
echo "Creating .rpm package with CPack..."
cpack -G RPM -V
CPACK_RPM="lemonade-server-${LEMONADE_VERSION}.${{ matrix.rpm-arch }}.rpm"
RPM_FILE="lemonade-server-${LEMONADE_VERSION}-fc${{ matrix.fedora-version }}.${{ matrix.rpm-arch }}.rpm"
if [ ! -f "$CPACK_RPM" ]; then
echo "ERROR: .rpm package not created!"
echo "Contents of build directory:"
ls -lR .
exit 1
fi
mv "$CPACK_RPM" "$RPM_FILE"
echo "Package information:"
rpm -qip "$RPM_FILE"
- name: Upload .rpm package
uses: actions/upload-artifact@v7
with:
name: lemonade-rpm-fedora-${{ matrix.fedora-version }}-${{ matrix.artifact-suffix }}
path: build/lemonade-server-${{ env.LEMONADE_VERSION }}-fc${{ matrix.fedora-version }}.${{ matrix.rpm-arch }}.rpm
retention-days: 7
build-lemonade-macos-dmg:
name: Build Lemonade macOS .dmg (with Tauri App)
# macOS runners are the scarcest GitHub-hosted resource (org-wide cap: 5
# concurrent) and observed queue waits reach 40+ minutes. Build Embeddable
# Lemonade (macOS) stays on PR pushes as the AppleClang compile canary; the
# Tauri/dmg/pkg surface is packaging-only, so it gates at merge-queue time.
# Label a PR `ci:macos` to opt it back in.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:macos')
runs-on: macos-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
env:
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Get version from CMakeLists.txt
id: get_version
uses: ./.github/actions/get-version
- name: Check signing secrets
id: check_signing
shell: bash
env:
APP_CONNECT_KEY: ${{ secrets.MACOS_APP_CONNECT_KEY_GERAMY }}
run: |
if [ "${{ inputs.disable_macos_signing }}" == "true" ]; then
echo "has_signing=false" >> $GITHUB_OUTPUT
echo "Signing disabled by workflow_dispatch input - exercising the unsigned path"
elif [ -n "$APP_CONNECT_KEY" ]; then
echo "has_signing=true" >> $GITHUB_OUTPUT
echo "Signing secrets available - will build signed and notarized .pkg"
else
echo "has_signing=false" >> $GITHUB_OUTPUT
echo "No signing secrets - will build an unsigned .pkg without notarizing"
fi
- name: Setup macOS Keychain
if: steps.check_signing.outputs.has_signing == 'true'
uses: ./.github/actions/setup-macos-keychain
with:
dev-signing-key: ${{ secrets.MACOS_DEV_SIGNING_IDENTITY_KEY }}
inst-signing-key: ${{ secrets.MACOS_INST_SIGNING_IDENTITY_KEY }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
api-key: ${{ secrets.MACOS_APP_CONNECT_KEY_GERAMY }}
api-key-id: '3WFZZ8F948'
api-issuer-id: '2e545619-8206-4d14-9ba9-ef23eff841b2'
- name: Build macOS .dmg
uses: ./.github/actions/build-macos-dmg
with:
include-tauri: 'true'
sign: ${{ steps.check_signing.outputs.has_signing }}
- name: Run C++ unit tests
shell: bash
run: |
set -euo pipefail
cmake --build --preset default --target cpp-ci-tests
ctest --test-dir build --output-on-failure --no-tests=error -L "^cpp-ci$"
- name: Upload .pkg package
# Two test jobs download this unconditionally; a unit-test failure above
# must not turn into a confusing "artifact not found" downstream.
if: always()
uses: actions/upload-artifact@v7
with:
name: lemonade-macos-pkg
path: build/*.pkg
retention-days: 7
- name: Cleanup keychain
if: always() && steps.check_signing.outputs.has_signing == 'true'
shell: bash
run: |
if [ -n "$SIGNING_KEYCHAIN_PATH" ] && [ -f "$SIGNING_KEYCHAIN_PATH" ]; then
echo "Cleaning up temporary keychain..."
security delete-keychain "$SIGNING_KEYCHAIN_PATH" 2>/dev/null || true
fi
build-lemonade-embeddable-linux:
name: Build Embeddable Lemonade (Linux${{ matrix.job-suffix }})
runs-on: ${{ matrix.runs-on }}
outputs:
version: ${{ steps.get_version.outputs.version }}
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
include:
- arch-name: X86_64
runs-on: ubuntu-latest
platform: ubuntu-x64
artifact-suffix: x86_64
job-suffix: ""
- arch-name: ARM64
runs-on: ubuntu-24.04-arm
platform: ubuntu-arm64
artifact-suffix: arm64
job-suffix: " ARM64"
steps:
- uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0
- name: Get version from CMakeLists.txt
id: get_version
uses: ./.github/actions/get-version
- name: Install minimal build dependencies
run: |
set -e
sudo apt-get update
sudo apt-get install -y cmake ninja-build g++ pkg-config libssl-dev libdrm-dev
- name: Build embeddable archive
shell: bash
run: |
set -e
# Do not use setup.sh — we intentionally avoid installing system
# libraries so that FetchContent statically links all deps, making
# the embeddable binary portable across Ubuntu versions.
cmake --preset default -DBUILD_WEB_APP=OFF
cmake --build --preset default --target embeddable
ARCHIVE="build/lemonade-embeddable-${LEMONADE_VERSION}-${{ matrix.platform }}.tar.gz"
test -f "$ARCHIVE"
echo "Archive contents:"
tar tzf "$ARCHIVE"
ls -lh "$ARCHIVE"
- name: Upload embeddable archive
uses: actions/upload-artifact@v4
with:
name: lemonade-embeddable-linux-${{ matrix.artifact-suffix }}
path: build/lemonade-embeddable-${{ env.LEMONADE_VERSION }}-${{ matrix.platform }}.tar.gz
retention-days: 7
build-lemonade-linux-arm64:
name: Build Lemonade (Linux ARM64)
runs-on: ubuntu-24.04-arm
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Get version from CMakeLists.txt
id: get_version
uses: ./.github/actions/get-version
- name: Install build dependencies
run: |
set -e
sudo apt-get update
sudo apt-get install -y cmake ninja-build g++ pkg-config libssl-dev libdrm-dev
- name: Build
run: |
set -e
cmake --preset default -DBUILD_WEB_APP=OFF
cmake --build --preset default --target lemond lemonade
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: lemonade-linux-arm64-build
path: |
build/lemond
build/lemonade
build/resources/
retention-days: 7
build-lemonade-embeddable-windows:
name: Build Embeddable Lemonade (Windows)
runs-on: windows-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0
- name: Install CMake if not available
uses: ./.github/actions/install-cmake-windows
- name: Get version from CMakeLists.txt
id: get_version
uses: ./.github/actions/get-version
- name: Cache FetchContent dependencies
uses: actions/cache@v4
with:
path: build/_deps
# vs2026 marker: see installer job — VS 2022-built _deps cannot be
# reused by the VS 2026 configure.
key: fetchcontent-windows-embeddable-vs2026-${{ hashFiles('CMakeLists.txt') }}
restore-keys: fetchcontent-windows-embeddable-vs2026-
- name: Build embeddable archive
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
cmake --preset vs18 -DBUILD_WEB_APP=OFF
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cmake --build build --config Release --target embeddable -- /m:2
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$archive = "build\lemonade-embeddable-$($env:LEMONADE_VERSION)-windows-x64.zip"
if (-not (Test-Path $archive)) { throw "Archive not found: $archive" }
Write-Host "Archive created:" -ForegroundColor Green
Get-ChildItem $archive
- name: Upload embeddable archive
uses: actions/upload-artifact@v4
with:
name: lemonade-embeddable-windows
path: build/lemonade-embeddable-${{ env.LEMONADE_VERSION }}-windows-x64.zip
retention-days: 7
build-lemonade-embeddable-macos:
name: Build Embeddable Lemonade (macOS)
runs-on: macos-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0
- name: Get version from CMakeLists.txt
id: get_version
uses: ./.github/actions/get-version
- name: Install minimal build dependencies
shell: bash
run: |
set -e
# macos-latest already ships cmake; install ninja if missing.
# We intentionally avoid setup.sh so FetchContent statically links
# deps where possible, matching the Linux/Windows embeddable jobs.
if ! command -v ninja >/dev/null 2>&1; then
brew install ninja
fi
- name: Build embeddable archive
shell: bash
run: |
set -e
cmake --preset default -DBUILD_WEB_APP=OFF
cmake --build --preset default --target embeddable
ARCHIVE="build/lemonade-embeddable-${LEMONADE_VERSION}-macos-arm64.tar.gz"
test -f "$ARCHIVE"
echo "Archive contents:"
tar tzf "$ARCHIVE"
ls -lh "$ARCHIVE"
- name: Upload embeddable archive
uses: actions/upload-artifact@v4
with:
name: lemonade-embeddable-macos
path: build/lemonade-embeddable-${{ env.LEMONADE_VERSION }}-macos-arm64.tar.gz
retention-days: 7
# ========================================================================
# TEST JOBS - Inference tests on self-hosted runners
# ========================================================================
test-exe-inference:
name: Test .exe - ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
needs: build-lemonade-server-installer
# Skip inference tests when signing is enabled (tag pushes or manual workflow_dispatch).
# Self-hosted rig capacity is the binding constraint, so the merge queue runs
# these instead of PR pushes. Label a PR `ci:backends` to opt it back in.
if: >-
!startsWith(github.ref, 'refs/tags/') &&
inputs.enable_signing != true &&
inputs.disable_macos_signing != true &&
(github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:backends'))
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
include:
- name: llamacpp
script: server_llm.py
extra_args: "--wrapped-server llamacpp"
backends: "vulkan rocm"
runner: [Windows, vulkan, rocm, lemon-prod]
- name: omni
script: server_omni.py
extra_args: "--wrapped-server llamacpp"
backends: "vulkan"
runner: [Windows, vulkan, stx-halo, lemon-prod]
- name: ryzenai
script: server_llm.py
extra_args: "--wrapped-server ryzenai"
backends: "cpu hybrid npu"
runner: [Windows, xdna2, lemon-prod]
- name: flm
script: server_llm.py
extra_args: "--wrapped-server flm"
backends: "npu"
runner: [Windows, xdna2, lemon-prod]
- name: whisper
script: server_whisper.py
extra_args: "--wrapped-server whispercpp"
backends: "cpu npu rocm"
runner: [Windows, stx-halo, lemon-prod]
- name: flm-whisper
script: server_whisper.py
extra_args: "--wrapped-server flm"
backends: "npu"
runner: [Windows, xdna2, lemon-prod]
- name: moonshine
script: server_moonshine.py
extra_args: "--wrapped-server moonshine"
backends: "cpu"
runner: [self-hosted, Windows, lemon-prod]
- name: classify-onnxruntime
script: server_classify.py
extra_args: "--wrapped-server onnxruntime"
backends: "cpu"
runner: [self-hosted, Windows, lemon-prod]
- name: router-onnxruntime
script: server_router.py
extra_args: "--wrapped-server onnxruntime"
backends: "cpu"
runner: [self-hosted, Windows, lemon-prod]
- name: stable-diffusion
script: server_sd.py
extra_args: ""
backends: "cpu rocm vulkan"
runner: [Windows, rocm, vulkan, lemon-prod]
- name: audio-gen-thinksound
script: server_audio_generation.py
extra_args: "--wrapped-server thinksound"
backends: "vulkan rocm"
runner: [Windows, rocm, vulkan, lemon-prod]
- name: audio-gen-acestep
script: server_audio_generation.py
extra_args: "--wrapped-server acestep"
backends: "vulkan rocm"
runner: [Windows, rocm, vulkan, lemon-prod]
- name: text-to-speech
script: server_tts.py
extra_args: ""
backends: ""
runner: [self-hosted, Windows, lemon-prod]
- name: 3d-trellis
script: server_3d.py
extra_args: "--wrapped-server trellis"
backends: "vulkan rocm"
runner: [Windows, rocm, vulkan, lemon-prod]
- name: tts-openmoss
script: server_tts_openmoss.py
extra_args: "--wrapped-server openmoss"
backends: "vulkan rocm"
runner: [Windows, rocm, vulkan, lemon-prod]
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Prepare Windows runner for long paths and stale workspace caches
shell: PowerShell
run: |
$ErrorActionPreference = "Continue"
# 1. Kill any running/orphaned processes to release file locks
$patterns = @("lemonade", "lemond", "llama-server", "llama", "flm", "ort-server", "moonshine-server", "wscript", "LemonadeServer")
foreach ($p in $patterns) {
Get-Process | Where-Object { $_.ProcessName -like "*$p*" } | Stop-Process -Force -ErrorAction SilentlyContinue
}
# 2. Force-delete stale in-tree legacy paths that could lock git checkout
# Local composite actions are unavailable before actions/checkout,
# so this minimal long-path-safe cleanup stays inline. It removes
# stale workspace cache/install dirs that can otherwise make
# checkout clean/reset fail on persistent self-hosted Windows runners.
Write-Host "Enabling Git long path support before checkout..." -ForegroundColor Cyan
git config --global core.longpaths true
try {
New-ItemProperty `
-Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" `
-Value 1 `
-PropertyType DWord `
-Force `
-ErrorAction Stop | Out-Null
Write-Host "Windows long paths enabled in registry." -ForegroundColor Green
} catch {
Write-Host "Could not set Windows long-path registry flag; continuing with Git core.longpaths=true: $_" -ForegroundColor Yellow
}
function Remove-LongPathTree {
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return }
$fullPath = [System.IO.Path]::GetFullPath($Path)
if ($fullPath.StartsWith("\\")) {
$longPath = "\\?\UNC\" + $fullPath.Substring(2)
} else {
$longPath = "\\?\" + $fullPath
}
Write-Host "Removing stale path: $fullPath" -ForegroundColor Yellow
cmd /c rmdir /s /q "$longPath" 2>$null
if (Test-Path -LiteralPath $Path) {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
}
}
# These legacy locations were inside the repository workspace and could
# make actions/checkout fail to clean/reset the repository on Windows,
# especially after TheRock created very deep bin/ci-cache/bin/therock trees.
if ($env:GITHUB_WORKSPACE) {
Remove-LongPathTree (Join-Path $env:GITHUB_WORKSPACE "ci-cache")
Remove-LongPathTree (Join-Path $env:GITHUB_WORKSPACE "hf-cache")
Remove-LongPathTree (Join-Path $env:GITHUB_WORKSPACE "lemonade_server_install")
}
- uses: actions/checkout@v5
with:
clean: true
fetch-depth: 0
- name: Setup & sanitize Windows CI environment
uses: ./.github/actions/setup-windows-ci-env
with:
matrix-name: ${{ matrix.name }}
- name: Install and Verify Lemonade Server
uses: ./.github/actions/install-lemonade-server-msi
with:
install-path: ${{ env.LEMONADE_INSTALL_PATH }}
- name: Setup Python and virtual environment
uses: ./.github/actions/setup-venv
with:
venv-name: '.venv'
python-version: '3.10'
requirements-file: 'test/requirements.txt'
- name: Install FLM backend for FLM wrapped-server tests
if: ${{ runner.os == 'Windows' && contains(matrix.extra_args, '--wrapped-server flm') }}
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
$lemonadeExe = Join-Path $env:LEMONADE_INSTALL_PATH "bin\lemonade.exe"
Write-Host "Installing FLM backend for CI inference tests..." -ForegroundColor Cyan
& $lemonadeExe backends install flm:npu
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Run tests
shell: PowerShell
env:
HF_HOME: ${{ env.HF_HOME }}
run: |
$ErrorActionPreference = "Stop"
$venvPython = ".\.venv\Scripts\python.exe"
$serverExe = Join-Path $env:LEMONADE_INSTALL_PATH "bin\lemonade.exe"
$extraArgs = "${{ matrix.extra_args }}" -split " " | Where-Object { $_ }
$backends = "${{ matrix.backends }}" -split " " | Where-Object { $_ }
function Invoke-TestBackend {
param(
[string]$Backend,
[int]$MaxAttempts = 1
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
Write-Host "Running test/${{ matrix.script }} ${{ matrix.extra_args }} --backend $Backend (attempt $attempt/$MaxAttempts)" -ForegroundColor Cyan
& $venvPython test/${{ matrix.script }} @extraArgs --backend $Backend --cli-binary $serverExe
$testExitCode = $LASTEXITCODE
if ($testExitCode -eq 0) {
return
}
if ($attempt -lt $MaxAttempts) {
Write-Host "::warning title=Retrying transient backend test::test/${{ matrix.script }} --backend $Backend failed with exit code $testExitCode; cleaning sd-server state and retrying."
Get-Process sd-server -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 20
try {
Invoke-RestMethod `
-Uri "http://localhost:13305/api/v1/unload" `
-Method Post `
-ContentType "application/json" `
-Body "{}" `
-TimeoutSec 30 | Out-Null
} catch {
Write-Host "Unload before retry failed or server had no model loaded: $_" -ForegroundColor Yellow
}
Start-Sleep -Seconds 10
continue
}
exit $testExitCode
}
}
if ($backends.Count -eq 0) {
Write-Host "Running test/${{ matrix.script }} ${{ matrix.extra_args }}" -ForegroundColor Cyan
& $venvPython test/${{ matrix.script }} @extraArgs --cli-binary $serverExe
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} else {
foreach ($backend in $backends) {
$maxAttempts = 1
if ("${{ matrix.name }}" -eq "stable-diffusion" -and $backend -eq "rocm") {
$maxAttempts = 3
}
Invoke-TestBackend -Backend $backend -MaxAttempts $maxAttempts
}
}
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-exe-${{ matrix.name }}
- name: Teardown Windows CI environment
if: always()
uses: ./.github/actions/teardown-windows-ci-env
test-deb-inference:
name: Test .deb - ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
needs: build-lemonade-deb
# Skip inference tests when signing is enabled (tag pushes or manual workflow_dispatch).
# Self-hosted rig capacity is the binding constraint, so the merge queue runs
# these instead of PR pushes. Label a PR `ci:backends` to opt it back in.
if: >-
!startsWith(github.ref, 'refs/tags/') &&
inputs.enable_signing != true &&
inputs.disable_macos_signing != true &&
(github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:backends'))
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
include:
- name: llamacpp
script: server_llm.py
extra_args: "--wrapped-server llamacpp"
backends: "vulkan rocm"
runner: [Linux, vulkan, rocm, lemon-prod]
- name: omni
script: server_omni.py
extra_args: "--wrapped-server llamacpp"
backends: "vulkan"
runner: [Linux, vulkan, stx-halo, lemon-prod]
- name: stable-diffusion
script: server_sd.py
extra_args: ""
backends: "cpu rocm vulkan"
runner: [Linux, vulkan, rocm, lemon-prod]
- name: thenoise
script: server_thenoise.py
extra_args: "--wrapped-server thenoise"
backends: "rocm"
runner: [Linux, rocm, stx-halo, lemon-prod]
- name: audio-gen-thinksound
script: server_audio_generation.py
extra_args: "--wrapped-server thinksound"
backends: "vulkan rocm"
runner: [Linux, vulkan, rocm, lemon-prod]
- name: audio-gen-acestep
script: server_audio_generation.py
extra_args: "--wrapped-server acestep"
backends: "vulkan rocm"
runner: [Linux, vulkan, rocm, lemon-prod]
- name: whisper
script: server_whisper.py
extra_args: "--wrapped-server whispercpp"
backends: "cpu vulkan rocm"
runner: [Linux, rocm, xdna2, lemon-prod]
- name: moonshine
script: server_moonshine.py
extra_args: "--wrapped-server moonshine"
backends: "cpu"
runner: [self-hosted, Linux, lemon-prod]
- name: flm
script: server_llm.py
extra_args: "--wrapped-server flm"
backends: "npu"
runner: [Linux, xdna2, lemon-prod]
- name: text-to-speech
script: server_tts.py
extra_args: ""
backends: ""
runner: [self-hosted, Linux, lemon-prod]
- name: 3d-trellis
script: server_3d.py
extra_args: "--wrapped-server trellis"
backends: "vulkan rocm"
runner: [Linux, vulkan, rocm, lemon-prod]
- name: tts-openmoss
script: server_tts_openmoss.py
extra_args: "--wrapped-server openmoss"
backends: "vulkan rocm"
runner: [Linux, vulkan, rocm, lemon-prod]
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
LEMONADE_VERSION: ${{ needs.build-lemonade-deb.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: Cleanup processes
uses: ./.github/actions/cleanup-processes-linux
- name: Set HF_HOME environment variable
run: |
# Persistent HF cache outside the workspace so models survive across
# jobs — the checkout clean wipes untracked workspace files (#2103)
HF_HOME="$HOME/.cache/lemonade-ci/huggingface"
mkdir -p "$HF_HOME"
echo "HF_HOME=$HF_HOME" >> $GITHUB_ENV
- name: Install Lemonade (.deb)
uses: ./.github/actions/install-lemonade-deb
with:
version: ${{ env.LEMONADE_VERSION }}
# These jobs run directly on shared self-hosted runners (no container
# network isolation), so the default port can be held by a concurrent
# job. Use a job-unique free port to avoid the collision.
port: auto
- name: Install FLM backend for FLM wrapped-server tests
if: ${{ contains(matrix.extra_args, '--wrapped-server flm') }}
run: |
set -e
echo "Installing FLM backend for CI inference tests..."
lemonade backends install flm:npu
- name: Setup Python and virtual environment
uses: ./.github/actions/setup-venv
with:
venv-name: '.venv'
python-version: '3.10'
requirements-file: 'test/requirements.txt'
- name: Run tests
env:
HF_HOME: ${{ env.HF_HOME }}
run: |
set -e
if [ -z "${{ matrix.backends }}" ]; then
echo "Running test/${{ matrix.script }} ${{ matrix.extra_args }}"
.venv/bin/python test/${{ matrix.script }} ${{ matrix.extra_args }} --cli-binary lemonade
else
for backend in ${{ matrix.backends }}; do
echo "Running test/${{ matrix.script }} ${{ matrix.extra_args }} --backend $backend"
.venv/bin/python test/${{ matrix.script }} ${{ matrix.extra_args }} --backend $backend --cli-binary lemonade
done
fi
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-deb-${{ matrix.name }}
- name: Cleanup
if: always()
uses: ./.github/actions/cleanup-processes-linux
test-rpm-package:
name: Test .rpm - Fedora ${{ matrix.fedora-version }}${{ matrix.job-suffix }}
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:distros')
runs-on: ${{ matrix.runs-on }}
needs: build-lemonade-rpm
container:
image: fedora:${{ matrix.fedora-version }}
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
fedora-version: ["43", "44"]
runs-on: ["ubuntu-latest", "ubuntu-24.04-arm"]
include:
- runs-on: ubuntu-latest
arch-name: X86_64
rpm-arch: x86_64
artifact-suffix: x86_64
job-suffix: ""
- runs-on: ubuntu-24.04-arm
arch-name: ARM64
rpm-arch: aarch64
artifact-suffix: arm64
job-suffix: " (ARM64)"
env:
LEMONADE_VERSION: ${{ needs.build-lemonade-rpm.outputs.version }}
steps:
- name: Download Lemonade .rpm Package
uses: actions/download-artifact@v7
with:
name: lemonade-rpm-fedora-${{ matrix.fedora-version }}-${{ matrix.artifact-suffix }}
path: .
- name: Install and verify Lemonade (.rpm)
shell: bash
run: |
set -e
RPM_FILE="lemonade-server-${LEMONADE_VERSION}-fc${{ matrix.fedora-version }}.${{ matrix.rpm-arch }}.rpm"
if [ ! -f "$RPM_FILE" ]; then
echo "ERROR: .rpm file not found: $RPM_FILE"
ls -la *.rpm 2>/dev/null || echo "No .rpm files found in current directory"
exit 1
fi
dnf install -y shadow-utils "$RPM_FILE"
echo "Installed package information:"
rpm -qi lemonade-server
echo "Installed file list:"
rpm -ql lemonade-server | sort
test -f /opt/bin/lemonade
test -f /opt/bin/lemond
/opt/bin/lemonade --version
/opt/bin/lemond --version
rpm -qpl "$RPM_FILE" | grep -Fx "/usr/lib/systemd/user/lemond.service"
rpm -qpl --dump "$RPM_FILE" \
| grep -E '^/usr/lib/systemd/user/lemond\.service ' \
| grep -F "/opt/lib/systemd/user/lemond.service"
# Verify tray is NOT in the server RPM
echo "Verifying server-only RPM content..."
if rpm -qpl "$RPM_FILE" | grep -Fxq "/opt/bin/lemonade-tray"; then
echo "ERROR: lemonade-tray binary found in server RPM"
exit 1
fi
if rpm -qpl "$RPM_FILE" | grep -Fxq "/usr/bin/lemonade-tray"; then
echo "ERROR: lemonade-tray symlink found in server RPM"
exit 1
fi
# Verify no desktop dependency leakage
if rpm -qpR "$RPM_FILE" | grep -qiE "gtk|appindicator|ayatana"; then
echo "ERROR: server RPM requires desktop dependencies (GTK/AppIndicator/Ayatana)"
rpm -qpR "$RPM_FILE" | grep -iE "gtk|appindicator|ayatana"
exit 1
fi
# Gate job that ensures: 1. in the merge queue, all jobs in `needs:` ran
# successfully (otherwise the merge is blocked), and 2. these jobs do not need
# to run on ordinary pull request pushes.
packaging-gate:
name: Packaging builds
needs: [build-lemonade-debian13, test-lemonade-debian13-smoke, build-lemonade-rpm, test-rpm-package, build-lemonade-desktop-installer, tauri-compile-check]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check gated jobs
env:
NEEDS: ${{ toJSON(needs) }}
run: |
# $NEEDS: {"job-id": {"result": "success|failure|skipped|cancelled"}, ...}
# Fail if any job broke. In the merge queue, also fail if any never ran.
echo "$NEEDS"
broke=$(jq -r 'to_entries[]|select(.value.result=="failure" or .value.result=="cancelled")|.key' <<<"$NEEDS")
if [ -n "$broke" ]; then
echo "FAILED: $broke"
exit 1
fi
if [ "${{ github.event_name }}" = "merge_group" ]; then
absent=$(jq -r 'to_entries[]|select(.value.result!="success")|.key' <<<"$NEEDS")
if [ -n "$absent" ]; then
echo "DID NOT RUN IN MERGE QUEUE: $absent"
exit 1
fi
fi
# Same gate pattern as packaging-gate, for the self-hosted inference rigs
# that PR pushes skip. The job name below belongs in main's required status
# checks — that registration is what makes the merge-queue strictness bind,
# so renaming the job requires a matching branch-protection update.
backends-gate:
name: Inference backend tests
needs: [build-lemonade-server-installer, build-lemonade-deb, cpp-unit-tests, test-exe-inference, test-deb-inference]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check gated jobs
env:
NEEDS: ${{ toJSON(needs) }}
run: |
# $NEEDS: {"job-id": {"result": "success|failure|skipped|cancelled"}, ...}
# Fail if any job broke. In the merge queue, also fail if any never ran.
echo "$NEEDS"
broke=$(jq -r 'to_entries[]|select(.value.result=="failure" or .value.result=="cancelled")|.key' <<<"$NEEDS")
if [ -n "$broke" ]; then
echo "FAILED: $broke"
exit 1
fi
if [ "${{ github.event_name }}" = "merge_group" ]; then
absent=$(jq -r 'to_entries[]|select(.value.result!="success")|.key' <<<"$NEEDS")
if [ -n "$absent" ]; then
echo "DID NOT RUN IN MERGE QUEUE: $absent"
exit 1
fi
fi
# Same gate pattern as packaging-gate, for the macOS jobs that PR pushes skip.
macos-gate:
name: macOS builds
needs: [build-lemonade-macos-dmg, test-cli-endpoints-macos, test-embeddable-macos, test-dmg-inference]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check gated jobs
env:
NEEDS: ${{ toJSON(needs) }}
run: |
# $NEEDS: {"job-id": {"result": "success|failure|skipped|cancelled"}, ...}
# Fail if any job broke. In the merge queue, also fail if any never ran.
echo "$NEEDS"
broke=$(jq -r 'to_entries[]|select(.value.result=="failure" or .value.result=="cancelled")|.key' <<<"$NEEDS")
if [ -n "$broke" ]; then
echo "FAILED: $broke"
exit 1
fi
if [ "${{ github.event_name }}" = "merge_group" ]; then
absent=$(jq -r 'to_entries[]|select(.value.result!="success")|.key' <<<"$NEEDS")
if [ -n "$absent" ]; then
echo "DID NOT RUN IN MERGE QUEUE: $absent"
exit 1
fi
fi
test-embeddable-posix:
name: Test Embeddable (${{ matrix.label }})
runs-on: ${{ matrix.os }}
needs: build-lemonade-embeddable-linux
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
include:
- label: Linux
os: ubuntu-latest
platform: ubuntu-x64
artifact: lemonade-embeddable-linux-x86_64
- label: Linux ARM64
os: ubuntu-24.04-arm
platform: ubuntu-arm64
artifact: lemonade-embeddable-linux-arm64
env:
LEMONADE_VERSION: ${{ needs.build-lemonade-embeddable-linux.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: Download embeddable archive
uses: actions/download-artifact@v4
with:
name: ${{ matrix.artifact }}
path: .
- name: Smoke test embeddable archive
uses: ./.github/actions/smoke-test-embeddable
with:
version: ${{ env.LEMONADE_VERSION }}
platform: ${{ matrix.platform }}
label: ${{ matrix.label }}
test-embeddable-macos:
name: Test Embeddable (macOS)
# Own job rather than a leg of test-embeddable-posix so that PR pushes can
# skip it at job level. Build Embeddable Lemonade (macOS) still runs on every
# PR as the AppleClang compile canary; this job re-tests the packaged archive
# on a clean runner, which is merge-queue work.
# Label a PR `ci:macos` to opt it back in.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:macos')
runs-on: macos-latest
needs: build-lemonade-embeddable-macos
env:
LEMONADE_VERSION: ${{ needs.build-lemonade-embeddable-macos.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: Download embeddable archive
uses: actions/download-artifact@v4
with:
name: lemonade-embeddable-macos
path: .
- name: Smoke test embeddable archive
uses: ./.github/actions/smoke-test-embeddable
with:
version: ${{ env.LEMONADE_VERSION }}
platform: macos-arm64
label: macOS
test-embeddable-windows:
name: Test Embeddable (Windows)
runs-on: windows-latest
needs: build-lemonade-embeddable-windows
env:
LEMONADE_VERSION: ${{ needs.build-lemonade-embeddable-windows.outputs.version }}
steps:
- name: Download embeddable archive
uses: actions/download-artifact@v4
with:
name: lemonade-embeddable-windows
path: .
- name: Validate archive structure
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
$archiveDir = "lemonade-embeddable-$($env:LEMONADE_VERSION)-windows-x64"
Expand-Archive -Path "$archiveDir.zip" -DestinationPath .
# Verify expected files
$expected = @(
"$archiveDir\lemond.exe",
"$archiveDir\lemonade.exe",
"$archiveDir\LICENSE",
"$archiveDir\resources\server_models.json",
"$archiveDir\resources\backend_versions.json",
"$archiveDir\resources\defaults.json",
"$archiveDir\resources\toolDefinitions.json"
)
$failures = @()
foreach ($f in $expected) {
if (-not (Test-Path $f)) {
Write-Host "ERROR: $f not found!" -ForegroundColor Red
$failures += $f
}
}
if ($failures.Count -gt 0) { exit 1 }
Write-Host "All expected files present" -ForegroundColor Green
# Verify NO forbidden files
$forbidden = @(
"$archiveDir\LemonadeServer.exe",
"$archiveDir\lemonade-app.exe",
"$archiveDir\lemonade-tray.exe",
"$archiveDir\resources\web-app"
)
foreach ($f in $forbidden) {
if (Test-Path $f) {
Write-Host "ERROR: Forbidden file found: $f" -ForegroundColor Red
exit 1
}
}
Write-Host "No forbidden files found" -ForegroundColor Green
- name: Test version commands
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
$archiveDir = "lemonade-embeddable-$($env:LEMONADE_VERSION)-windows-x64"
& "$archiveDir\lemond.exe" --version
& "$archiveDir\lemonade.exe" --version
- name: Test lemond startup and health check
shell: PowerShell
run: |
$ErrorActionPreference = "Stop"
$archiveDir = "lemonade-embeddable-$($env:LEMONADE_VERSION)-windows-x64"
Push-Location $archiveDir
$proc = Start-Process -FilePath ".\lemond.exe" -ArgumentList ".\" -PassThru -NoNewWindow
# Wait for server to become healthy
$healthy = $false
for ($i = 0; $i -lt 15; $i++) {
try {
$response = Invoke-WebRequest -Uri "http://localhost:13305/api/v1/health" -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -eq 200) {
Write-Host "Server is healthy!" -ForegroundColor Green
Write-Host "Health response: $($response.Content)"
$healthy = $true
break
}
} catch {}
Start-Sleep -Seconds 2
}
if (-not $healthy) {
Write-Host "ERROR: Server failed to start within 30 seconds" -ForegroundColor Red
Stop-Process $proc -Force -ErrorAction SilentlyContinue
Pop-Location
exit 1
}
Stop-Process $proc -Force -ErrorAction SilentlyContinue
Pop-Location
Write-Host "Embeddable Windows smoke test PASSED!" -ForegroundColor Green
- name: Cleanup processes (Windows)
if: always()
shell: PowerShell
run: |
Stop-Process -Name lemond -Force -ErrorAction SilentlyContinue
Stop-Process -Name lemonade -Force -ErrorAction SilentlyContinue
test-dmg-inference:
name: Test .dmg - macOS inference
runs-on: macos-latest
needs: build-lemonade-macos-dmg
# Inference is the only macOS coverage no other job provides, so it has to
# run where merges are gated.
# Label a PR `ci:macos` to opt it back in.
if: ${{ !startsWith(github.ref, 'refs/tags/') && inputs.enable_signing != true && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:macos')) }}
timeout-minutes: 45
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
LEMONADE_VERSION: ${{ needs.build-lemonade-macos-dmg.outputs.version }}
# Covers the install action's manual fallback, which runs in this shell.
# The launchd path is covered by the launchctl setenv step below.
GGML_METAL_NO_RESIDENCY: "1"
steps:
- uses: actions/checkout@v5
- name: Set HF_HOME environment variable
run: echo "HF_HOME=$PWD/hf-cache" >> $GITHUB_ENV
# Must precede the install: the .pkg postflight starts lemond via launchd,
# which passes on the launchd domain environment rather than this shell's.
# Killing that server and starting our own instead loses a race for
# 127.0.0.1:13305 often enough to be unusable — lemond then binds IPv6
# only and every test that uses a literal IPv4 address fails.
- name: Put the Metal setting in the launchd environment
shell: bash
run: sudo launchctl setenv GGML_METAL_NO_RESIDENCY 1
- name: Download .pkg package
uses: actions/download-artifact@v7
with:
name: lemonade-macos-pkg
path: .
- name: Install Lemonade Server (.pkg)
id: install-pkg
uses: ./.github/actions/install-lemonade-server-dmg
with:
version: ${{ env.LEMONADE_VERSION }}
download-artifact: 'false'
- name: Verify the server picked up the Metal setting
shell: bash
env:
BIN_PATH: ${{ steps.install-pkg.outputs.bin-path }}
run: |
set -e
echo "CLI_BINARY=$BIN_PATH/lemonade" >> "$GITHUB_ENV"
PID=$(sudo lsof -t -nP -iTCP:13305 -sTCP:LISTEN 2>/dev/null | head -1)
if [ -z "$PID" ]; then
echo "ERROR: nothing is listening on port 13305"
exit 1
fi
# Assert rather than assume: this job exists to exercise Metal, and a
# server without the setting would quietly test something else.
if ! sudo ps eww -p "$PID" | grep -q GGML_METAL_NO_RESIDENCY; then
echo "ERROR: lemond (PID $PID) is missing GGML_METAL_NO_RESIDENCY"
sudo ps eww -p "$PID" || true
exit 1
fi
echo "lemond (PID $PID) has GGML_METAL_NO_RESIDENCY"
- name: Setup Python and virtual environment
id: setup
uses: ./.github/actions/setup-venv
with:
venv-name: '.venv'
python-version: '3.10'
requirements-file: 'test/requirements.txt'
# All inference tests run in one job against the already-started server so
# they share a single setup and model cache (issue #2103). Each step runs
# even if an earlier test failed; the job fails if any failed.
- name: Test llamacpp-metal
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
run: .venv/bin/python test/server_llm.py --wrapped-server llamacpp --backend metal --cli-binary "$CLI_BINARY"
- name: Test whisper-metal
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
run: .venv/bin/python test/server_whisper.py --wrapped-server whispercpp --backend metal --cli-binary "$CLI_BINARY"
# Temporarily disabled — sd-cpp metal test is being skipped
# - name: Test sd-cpp-metal
# if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
# env:
# LEMONADE_TEST_SD_MODEL: SD-Turbo-GGUF
# run: .venv/bin/python test/server_sd.py --wrapped-server sd-cpp --backend metal --cli-binary "$CLI_BINARY"
- name: Test moonshine
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
run: .venv/bin/python test/server_moonshine.py --wrapped-server moonshine --backend cpu --cli-binary "$CLI_BINARY"
- name: Test kokoro-metal
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
run: .venv/bin/python test/server_tts.py --cli-binary "$CLI_BINARY"
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-dmg-inference
# ========================================================================
# CLI AND ENDPOINTS TESTS - Run on GitHub-hosted runners (no GPU needed)
# ========================================================================
test-cli-endpoints-linux:
name: Test CLI/Endpoints (ubuntu-latest)
runs-on: ubuntu-latest
needs: build-lemonade-deb
timeout-minutes: 25
container:
image: ghcr.io/lemonade-sdk/lemonade/build-environment:ubuntu24.04
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
HF_HOME: ${{ github.workspace }}/hf-cache
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
LEMONADE_VERSION: ${{ needs.build-lemonade-deb.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: Configure git safe directory
run: git config --global --add safe.directory $(pwd)
- name: Install pip and test dependencies
shell: bash
run: |
set -e
# Install pip if not already available
if ! command -v pip3 &> /dev/null; then
apt-get update
apt-get install -y python3-pip python3-venv
fi
# Create venv and install test dependencies
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r test/requirements.txt
- name: Prepare HF cache
id: setup
shell: bash
run: |
set -e
# HF_HOME is job-level env so install-lemonade-deb and the lemond
# process it starts inherit the same cache path.
mkdir -p "$HF_HOME"
echo "HF_HOME=$HF_HOME"
- name: Install Lemonade (.deb)
uses: ./.github/actions/install-lemonade-deb
with:
version: ${{ env.LEMONADE_VERSION }}
# All test scripts run in one job so they share a single setup and model
# cache (issue #2103). Each step runs even if an earlier test failed
# (matching the old fail-fast: false matrix); the job fails if any failed.
- name: Test cli
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu cli"
.venv/bin/python test/server_cli2.py --cli-binary lemonade
- name: Test endpoints
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu endpoints"
.venv/bin/python test/server_endpoints.py --cli-binary lemonade
echo "Running WebSocket idle test..."
.venv/bin/python test/test_websocket_idle.py
echo "WebSocket idle test PASSED!"
echo "Running WebSocket auth tests..."
.venv/bin/python test/server_websocket_auth.py
echo "WebSocket auth tests PASSED!"
- name: Test router
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu router"
.venv/bin/python test/server_router.py --cli-binary lemonade
# The full smoke test can be added to a different GPU equipped pipeline
# as well if needed, but it would significantly add to the pipeline runtime.
- name: Test mcp
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu mcp"
.venv/bin/python test/server_mcp.py --cli-binary lemonade
- name: Test web-app path traversal
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu webapp"
.venv/bin/python test/server_webapp.py --cli-binary lemonade
- name: Test model name normalization
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu model-normalization"
.venv/bin/python test/test_model_name_normalization.py --cli-binary lemonade
- name: Test ollama
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
env:
# No persistent HF cache here, so prefer the 2 GB GGUF build.
LEMONADE_TEST_SD_MODEL: SD-Turbo-GGUF
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu ollama"
.venv/bin/python test/test_ollama.py --cli-binary lemonade
- name: Test streaming-errors
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu streaming-errors"
.venv/bin/python test/server_streaming_errors.py --cli-binary lemonade
- name: Test jobs
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu jobs"
.venv/bin/python test/server_jobs.py --lemond-binary lemond
# Must run last: its tearDown stops lemond via /internal/shutdown to
# launch its own instance, and nothing after it can rely on the server.
- name: Test llamacpp-system
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu llamacpp-system"
.venv/bin/python test/test_llamacpp_system_backend.py --cli-binary lemonade
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-ubuntu-latest
test-cli-endpoints-linux-arm64:
name: Test ${{ matrix.test_type }} (ubuntu-24.04-arm)
runs-on: ubuntu-24.04-arm
needs: build-lemonade-linux-arm64
strategy:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix:
# llamacpp-system and env-vars omitted: no system llama-server or .deb on
# GitHub-hosted ARM64 runners; Vulkan tests omitted: no GPU available.
test_type: [cli, endpoints, ollama, streaming-errors]
env:
LEMONADE_CI_MODE: "True"
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- uses: actions/checkout@v5
- name: Download build artifacts
uses: actions/download-artifact@v7
with:
name: lemonade-linux-arm64-build
path: build
- name: Make binaries executable
run: chmod +x build/lemond build/lemonade
- name: Set HF_HOME
run: echo "HF_HOME=$PWD/hf-cache" >> $GITHUB_ENV
- name: Start lemond server
run: |
mkdir -p "$RUNNER_TEMP/xdg-runtime"
chmod 700 "$RUNNER_TEMP/xdg-runtime"
echo "XDG_RUNTIME_DIR=$RUNNER_TEMP/xdg-runtime" >> "$GITHUB_ENV"
export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime"
./build/lemond > "$RUNNER_TEMP/lemond.log" 2>&1 &
LEMOND_PID=$!
disown "$LEMOND_PID"
for i in $(seq 1 30); do
if curl -sf http://localhost:13305/live > /dev/null 2>&1; then
echo "Server is running and healthy (PID $LEMOND_PID)"
exit 0
fi
echo "Waiting for server... ($i/30)"
sleep 2
done
echo "ERROR: Server did not start within 60 seconds"
cat "$RUNNER_TEMP/lemond.log" 2>/dev/null || true
exit 1
- name: Setup Python and virtual environment
uses: ./.github/actions/setup-venv
with:
venv-name: '.venv'
python-version: '3.10'
requirements-file: 'test/requirements.txt'
- name: Run tests
env:
HF_HOME: ${{ env.HF_HOME }}
run: |
set -e
VENV_PYTHON=.venv/bin/python
CLI_BINARY=./build/lemonade
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "arm64 ${{ matrix.test_type }}"
if [ "${{ matrix.test_type }}" = "cli" ]; then
echo "Running CLI tests..."
$VENV_PYTHON test/server_cli2.py --cli-binary "$CLI_BINARY"
elif [ "${{ matrix.test_type }}" = "endpoints" ]; then
echo "Running endpoint tests..."
$VENV_PYTHON test/server_endpoints.py --cli-binary "$CLI_BINARY"
echo "Running WebSocket idle test..."
$VENV_PYTHON test/test_websocket_idle.py
echo "WebSocket idle test PASSED!"
echo "Running WebSocket auth tests..."
$VENV_PYTHON test/server_websocket_auth.py
echo "WebSocket auth tests PASSED!"
elif [ "${{ matrix.test_type }}" = "ollama" ]; then
echo "Running Ollama API tests..."
$VENV_PYTHON test/test_ollama.py --cli-binary "$CLI_BINARY"
elif [ "${{ matrix.test_type }}" = "streaming-errors" ]; then
echo "Running streaming error termination tests..."
$VENV_PYTHON test/server_streaming_errors.py --cli-binary "$CLI_BINARY"
fi
echo "${{ matrix.test_type }} tests PASSED!"
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-arm64-${{ matrix.test_type }}
test-cli-endpoints-windows:
name: Test CLI/Endpoints (windows-latest)
runs-on: windows-latest
needs: build-lemonade-server-installer
timeout-minutes: 30
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
# Pre-checkout cleanup is run here as a safeguard to release file locks on Windows
# in case this job is mapped to persistent self-hosted runners labeled as 'windows-latest'.
- name: Prepare Windows runner for long paths and stale workspace caches
shell: PowerShell
run: |
$ErrorActionPreference = "Continue"
# 1. Kill any running/orphaned processes to release file locks
$patterns = @("lemonade", "lemond", "llama-server", "llama", "flm", "ort-server", "moonshine-server", "wscript", "LemonadeServer")
foreach ($p in $patterns) {
Get-Process | Where-Object { $_.ProcessName -like "*$p*" } | Stop-Process -Force -ErrorAction SilentlyContinue
}
- uses: actions/checkout@v5
- name: Setup & sanitize Windows CI environment
uses: ./.github/actions/setup-windows-ci-env
with:
matrix-name: windows-latest
- name: Install Lemonade Server (Windows)
uses: ./.github/actions/install-lemonade-server-msi
with:
install-path: ${{ env.LEMONADE_INSTALL_PATH }}
- name: Set paths (Windows)
shell: powershell
run: |
echo "VENV_PYTHON=.venv/Scripts/python.exe" >> $Env:GITHUB_ENV
echo "CLI_BINARY=$Env:LEMONADE_INSTALL_PATH\bin\lemonade.exe" >> $Env:GITHUB_ENV
- name: Setup Python and virtual environment
id: setup
uses: ./.github/actions/setup-venv
with:
venv-name: '.venv'
python-version: '3.10'
requirements-file: 'test/requirements.txt'
# All test scripts run in one job so they share a single setup and model
# cache (issue #2103). Each step runs even if an earlier test failed; the
# job fails if any failed.
- name: Test cli
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "windows-latest cli"
$VENV_PYTHON test/server_cli2.py --cli-binary "$CLI_BINARY"
- name: Test endpoints
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "windows-latest endpoints"
$VENV_PYTHON test/server_endpoints.py --cli-binary "$CLI_BINARY"
echo "Running WebSocket idle test..."
$VENV_PYTHON test/test_websocket_idle.py
echo "WebSocket idle test PASSED!"
- name: Test router
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "windows-latest router"
$VENV_PYTHON test/server_router.py --cli-binary "$CLI_BINARY"
- name: Test web-app path traversal
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "windows-latest webapp"
$VENV_PYTHON test/server_webapp.py --cli-binary "$CLI_BINARY"
- name: Test ollama
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
env:
# No persistent HF cache here, so prefer the 2 GB GGUF build.
LEMONADE_TEST_SD_MODEL: SD-Turbo-GGUF
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "windows-latest ollama"
$VENV_PYTHON test/test_ollama.py --cli-binary "$CLI_BINARY"
- name: Test streaming-errors
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "windows-latest streaming-errors"
$VENV_PYTHON test/server_streaming_errors.py --cli-binary "$CLI_BINARY"
# Must run last: its tearDown stops lemond via /internal/shutdown to
# launch its own instance, and nothing after it can rely on the server.
- name: Test llamacpp-system
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "windows-latest llamacpp-system"
$VENV_PYTHON test/test_llamacpp_system_backend.py --cli-binary "$CLI_BINARY"
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-windows-latest
- name: Teardown Windows CI environment
if: always()
uses: ./.github/actions/teardown-windows-ci-env
test-cli-endpoints-macos:
name: Test CLI/Endpoints (macos-latest)
# Skipped at job level rather than dropped from a matrix: this is a required
# status check, and a required context that is never created leaves the PR
# pending forever, whereas a job skipped by `if:` reports success.
# Label a PR `ci:macos` to opt it back in.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:macos')
runs-on: macos-latest
needs: build-lemonade-macos-dmg
timeout-minutes: 45
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
LEMONADE_VERSION: ${{ needs.build-lemonade-macos-dmg.outputs.version }}
# These suites drive the server the .pkg postflight starts via launchd, which
# inherits only the plist environment. Job-level server tuning such as
# GGML_METAL_NO_RESIDENCY would not reach it, so it is not set here; Metal
# inference is covered by test-dmg-inference, which owns its server.
steps:
- uses: actions/checkout@v5
- name: Download .pkg package
uses: actions/download-artifact@v7
with:
name: lemonade-macos-pkg
path: .
- name: Install Lemonade Server (.pkg)
id: install-pkg
uses: ./.github/actions/install-lemonade-server-dmg
with:
version: ${{ env.LEMONADE_VERSION }}
download-artifact: 'false'
- name: Set HF_HOME environment variable
shell: bash
run: echo "HF_HOME=$PWD/hf-cache" >> $GITHUB_ENV
- name: Stop the tray agent started by the .pkg
# postinst-full-mac bootstraps com.lemonade.tray into the GUI domain and
# the tray is single-instance, so test_tray_https.py cannot start its own
# while this one runs. KeepAlive is false, so it stays down.
shell: bash
run: |
set -e
launchctl bootout "gui/$(id -u)/com.lemonade.tray" 2>/dev/null || true
pkill -f lemonade-tray 2>/dev/null || true
sudo pkill -f lemonade-tray 2>/dev/null || true
for i in $(seq 1 15); do
pgrep -f lemonade-tray > /dev/null 2>&1 || break
sleep 1
done
- name: Set environment
shell: bash
run: |
set -e
echo "VENV_PYTHON=.venv/bin/python" >> $GITHUB_ENV
echo "CLI_BINARY=${{ steps.install-pkg.outputs.bin-path }}/lemonade" >> $GITHUB_ENV
echo "LEMOND_BINARY=${{ steps.install-pkg.outputs.bin-path }}/lemond" >> $GITHUB_ENV
- name: Setup Python and virtual environment
id: setup
uses: ./.github/actions/setup-venv
with:
venv-name: '.venv'
python-version: '3.10'
requirements-file: 'test/requirements.txt'
# All test scripts run in one job so they share a single setup and model
# cache (issue #2103). Each step runs even if an earlier test failed; the
# job fails if any failed.
- name: Test cli
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest cli"
$VENV_PYTHON test/server_cli2.py --cli-binary "$CLI_BINARY"
- name: Test endpoints
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest endpoints"
$VENV_PYTHON test/server_endpoints.py --cli-binary "$CLI_BINARY"
$VENV_PYTHON test/test_websocket_idle.py
$VENV_PYTHON test/server_websocket_auth.py
$VENV_PYTHON test/test_tray_https.py
$VENV_PYTHON test/server_jobs.py --lemond-binary "$LEMOND_BINARY"
- name: Test router
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest router"
$VENV_PYTHON test/server_router.py --cli-binary "$CLI_BINARY"
- name: Test web-app path traversal
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest webapp"
$VENV_PYTHON test/server_webapp.py --cli-binary "$CLI_BINARY"
- name: Test model name normalization
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest model-normalization"
$VENV_PYTHON test/test_model_name_normalization.py --cli-binary "$CLI_BINARY"
- name: Test ollama
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest ollama"
$VENV_PYTHON test/test_ollama.py --cli-binary "$CLI_BINARY"
- name: Test streaming-errors
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest streaming-errors"
$VENV_PYTHON test/server_streaming_errors.py --cli-binary "$CLI_BINARY"
# Must run last: its tearDown stops lemond via /internal/shutdown to
# launch its own instance, and nothing after it can rely on the server.
- name: Test llamacpp-system
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
shell: bash
run: |
$VENV_PYTHON -m test.utils.reset_server_state --best-effort --label "macos-latest llamacpp-system"
$VENV_PYTHON test/test_llamacpp_system_backend.py --cli-binary "$CLI_BINARY"
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-macos-latest
# ========================================================================
# API KEY TESTS - Separate job with LEMONADE_API_KEY env var
# ========================================================================
test-cli-apikey-linux:
name: Test API Key (ubuntu-latest)
runs-on: ubuntu-latest
needs: build-lemonade-deb
container:
image: ghcr.io/lemonade-sdk/lemonade/build-environment:ubuntu24.04
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
LEMONADE_API_KEY: "test-api-key-12345"
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
LEMONADE_VERSION: ${{ needs.build-lemonade-deb.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: Configure git safe directory
run: git config --global --add safe.directory $(pwd)
- name: Install pip and test dependencies
shell: bash
run: |
set -e
if ! command -v pip3 &> /dev/null; then
apt-get update
apt-get install -y python3-pip python3-venv
fi
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r test/requirements.txt
- name: Install Lemonade (.deb)
uses: ./.github/actions/install-lemonade-deb
with:
version: ${{ env.LEMONADE_VERSION }}
- name: Verify API key enforcement
shell: bash
run: |
set -e
PASS=0
FAIL=0
# 1. No API key → must get 401
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:13305/api/v1/health 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "401" ]; then
echo "PASS: No API key → $HTTP_CODE"
PASS=$((PASS+1))
else
echo "FAIL: No API key → $HTTP_CODE (expected 401)"
FAIL=$((FAIL+1))
fi
# 2. Wrong API key → must get 401
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer wrong-key" http://127.0.0.1:13305/api/v1/health 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "401" ]; then
echo "PASS: Wrong API key → $HTTP_CODE"
PASS=$((PASS+1))
else
echo "FAIL: Wrong API key → $HTTP_CODE (expected 401)"
FAIL=$((FAIL+1))
fi
# 3. Correct API key → must get 200
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $LEMONADE_API_KEY" http://127.0.0.1:13305/api/v1/health 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
echo "PASS: Correct API key → $HTTP_CODE"
PASS=$((PASS+1))
else
echo "FAIL: Correct API key → $HTTP_CODE (expected 200)"
FAIL=$((FAIL+1))
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "ERROR: API key enforcement tests failed"
exit 1
fi
- name: Run CLI tests with API key
shell: bash
run: |
set -e
# Keep this job focused on API-key propagation. The full CLI suite
# runs elsewhere and includes model-pull/delete coverage unrelated to auth.
.venv/bin/python test/server_cli_apikey.py --cli-binary lemonade
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-apikey-ubuntu-latest
test-cli-apikey:
name: Test API Key (windows-latest)
runs-on: windows-latest
needs:
- build-lemonade-server-installer
env:
LEMONADE_CI_MODE: "True"
HF_TOKEN: ${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}
LEMONADE_API_KEY: "test-api-key-12345"
PYTHONIOENCODING: utf-8
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
# Pre-checkout cleanup is run here as a safeguard to release file locks on Windows
# in case this job is mapped to persistent self-hosted runners labeled as 'windows-latest'.
- name: Prepare Windows runner for long paths and stale workspace caches
shell: PowerShell
run: |
$ErrorActionPreference = "Continue"
# 1. Kill any running/orphaned processes to release file locks
$patterns = @("lemonade", "lemond", "llama-server", "llama", "flm", "ort-server", "moonshine-server", "wscript", "LemonadeServer")
foreach ($p in $patterns) {
Get-Process | Where-Object { $_.ProcessName -like "*$p*" } | Stop-Process -Force -ErrorAction SilentlyContinue
}
- uses: actions/checkout@v5
- name: Setup & sanitize Windows CI environment
uses: ./.github/actions/setup-windows-ci-env
with:
matrix-name: apikey-windows-latest
- name: Install Lemonade Server (Windows)
uses: ./.github/actions/install-lemonade-server-msi
with:
install-path: ${{ env.LEMONADE_INSTALL_PATH }}
- name: Set paths (Windows)
shell: powershell
run: |
echo "VENV_PYTHON=.venv/Scripts/python.exe" >> $Env:GITHUB_ENV
echo "CLI_BINARY=$Env:LEMONADE_INSTALL_PATH\bin\lemonade.exe" >> $Env:GITHUB_ENV
- name: Setup Python and virtual environment
uses: ./.github/actions/setup-venv
with:
venv-name: '.venv'
python-version: '3.10'
requirements-file: 'test/requirements.txt'
- name: Verify API key enforcement
shell: bash
run: |
set -e
PASS=0
FAIL=0
# 1. No API key → must get 401
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:13305/api/v1/health 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "401" ]; then
echo "PASS: No API key → $HTTP_CODE"
PASS=$((PASS+1))
else
echo "FAIL: No API key → $HTTP_CODE (expected 401)"
FAIL=$((FAIL+1))
fi
# 2. Wrong API key → must get 401
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer wrong-key" http://localhost:13305/api/v1/health 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "401" ]; then
echo "PASS: Wrong API key → $HTTP_CODE"
PASS=$((PASS+1))
else
echo "FAIL: Wrong API key → $HTTP_CODE (expected 401)"
FAIL=$((FAIL+1))
fi
# 3. Correct API key → must get 200
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $LEMONADE_API_KEY" http://localhost:13305/api/v1/health 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
echo "PASS: Correct API key → $HTTP_CODE"
PASS=$((PASS+1))
else
echo "FAIL: Correct API key → $HTTP_CODE (expected 200)"
FAIL=$((FAIL+1))
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "ERROR: API key enforcement tests failed"
exit 1
fi
- name: Run CLI tests with API key
shell: bash
env:
HF_HOME: ${{ env.HF_HOME }}
run: |
set -e
# Keep this job focused on API-key propagation. The full CLI suite
# runs elsewhere and includes model-pull/delete coverage unrelated to auth.
"$VENV_PYTHON" test/server_cli_apikey.py --cli-binary "$CLI_BINARY"
- name: Capture and upload server logs
if: always()
uses: ./.github/actions/capture-server-logs
with:
artifact-name: server-logs-apikey-windows-latest
- name: Teardown Windows CI environment
if: always()
uses: ./.github/actions/teardown-windows-ci-env
# ========================================================================
# RELEASE JOB - Add artifacts to GitHub release
# ========================================================================
release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs:
- sign-msi-installers
- build-lemonade-rpm
- build-lemonade-debian13
- test-lemonade-debian13-smoke
- build-lemonade-macos-dmg
- build-lemonade-embeddable-linux
- build-lemonade-embeddable-windows
- build-lemonade-embeddable-macos
- test-cli-endpoints-windows
- test-cli-endpoints-macos
- test-rpm-package
- test-embeddable-posix
- test-embeddable-macos
- test-embeddable-windows
if: startsWith(github.ref, 'refs/tags/v')
env:
LEMONADE_VERSION: ${{ needs.build-lemonade-rpm.outputs.version }}
steps:
- name: Checkout for release notes action
uses: actions/checkout@v5
with:
sparse-checkout: .github
- name: Download Signed Lemonade Server Installer (Windows)
uses: actions/download-artifact@v7
with:
name: Lemonade_Server_MSI_Signed
path: .
- name: Download Lemonade .rpm Packages (x86_64)
uses: actions/download-artifact@v7
with:
pattern: lemonade-rpm-fedora-*-x86_64
path: .
merge-multiple: true
- name: Download Lemonade .rpm Packages (ARM64)
uses: actions/download-artifact@v7
with:
pattern: lemonade-rpm-fedora-*-arm64
path: .
merge-multiple: true
- name: Download Lemonade .deb (Debian 13, X86_64)
uses: actions/download-artifact@v7
with:
name: lemonade-debian13-x86_64
path: .
- name: Download Lemonade .deb (Debian 13, ARM64)
uses: actions/download-artifact@v7
with:
name: lemonade-debian13-arm64
path: .
- name: Download Lemonade macOS .pkg Package
uses: actions/download-artifact@v7
with:
name: lemonade-macos-pkg
path: .
- name: Download Lemonade Embeddable (Linux, X86_64)
uses: actions/download-artifact@v4
with:
name: lemonade-embeddable-linux-x86_64
path: .
- name: Download Lemonade Embeddable (Linux, ARM64)
uses: actions/download-artifact@v4
with:
name: lemonade-embeddable-linux-arm64
path: .
- name: Download Lemonade Embeddable (Windows)
uses: actions/download-artifact@v4
with:
name: lemonade-embeddable-windows
path: .
- name: Download Lemonade Embeddable (macOS)
uses: actions/download-artifact@v4
with:
name: lemonade-embeddable-macos
path: .
- name: Verify release artifacts
run: |
echo "Release artifacts:"
ls -lh lemonade-server-minimal.msi
ls -lh lemonade.msi
ls -lh lemonade-server-*.x86_64.rpm
ls -lh lemonade-server-*.aarch64.rpm
ls -lh lemonade-server_${LEMONADE_VERSION}-debian13_amd64.deb
ls -lh lemonade-server_${LEMONADE_VERSION}-debian13_arm64.deb
ls -lh *.pkg
ls -lh lemonade-embeddable-${LEMONADE_VERSION}-ubuntu-x64.tar.gz
ls -lh lemonade-embeddable-${LEMONADE_VERSION}-ubuntu-arm64.tar.gz
ls -lh lemonade-embeddable-${LEMONADE_VERSION}-windows-x64.zip
ls -lh lemonade-embeddable-${LEMONADE_VERSION}-macos-arm64.tar.gz
- name: Generate release notes
id: release-notes
uses: ./.github/actions/generate-release-notes
with:
version: ${{ env.LEMONADE_VERSION }}
repo: ${{ github.repository }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
body_path: ${{ steps.release-notes.outputs.release_notes_file }}
files: |
lemonade-server-minimal.msi
lemonade.msi
lemonade-server-*.x86_64.rpm
lemonade-server-*.aarch64.rpm
lemonade-server_${{ env.LEMONADE_VERSION }}-debian13_amd64.deb
lemonade-server_${{ env.LEMONADE_VERSION }}-debian13_arm64.deb
*.pkg
lemonade-embeddable-${{ env.LEMONADE_VERSION }}-ubuntu-x64.tar.gz
lemonade-embeddable-${{ env.LEMONADE_VERSION }}-ubuntu-arm64.tar.gz
lemonade-embeddable-${{ env.LEMONADE_VERSION }}-windows-x64.zip
lemonade-embeddable-${{ env.LEMONADE_VERSION }}-macos-arm64.tar.gz
fail_on_unmatched_files: true