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
238 changes: 135 additions & 103 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,31 @@ on: ["push", "pull_request"]

jobs:
build-linux:
if: false
continue-on-error: true
runs-on: ubuntu-22.04
name: QLCplus Linux ${{matrix.task}}
runs-on: ${{ matrix.runner }}
name: QLCplus Linux ${{matrix.task}} (${{matrix.appimage_arch}})
strategy:
#fail-fast: false
fail-fast: false
matrix:
task: [build-v4, build-v5, build-v5-mcp, coverage-build-v4]
include:
- task: build-v5-mcp
runner: ubuntu-24.04
appimage_arch: x86_64
qt_dir_name: gcc_64
qt_arch: linux_gcc_64
- task: build-v5-mcp
runner: ubuntu-24.04-arm
appimage_arch: aarch64
qt_dir_name: gcc_arm64
qt_arch: linux_gcc_arm64
env:
CI_REPO_SLUG: ${{ github.repository }}
CI_BRANCH: ${{ github.head_ref }}
CI_PULL_REQUEST: ${{ github.event.number }}
CC: gcc
CXX: g++
PACKAGES_BASE:
QT_VERSION: "6.10.2"
# libfuse2 does not exist on Ubuntu 24.04; libfuse2t64 provides libfuse.so.2
PACKAGES_ALL:
gdb
libasound2-dev
libusb-1.0-0-dev
Expand All @@ -33,12 +43,9 @@ jobs:
libxml2-utils
xvfb
chrpath
libfuse2
libfuse2t64
wget
PACKAGES_V5_BASE:
libpulse-dev
PACKAGES_COVERAGE_BASE:
lcov

defaults:
run:
Expand All @@ -52,10 +59,6 @@ jobs:

- name: Set General ENV variables
run: |
echo "PACKAGES_V4=$(echo ${PACKAGES_BASE})" >> $GITHUB_ENV
echo "PACKAGES_V5=$(echo ${PACKAGES_BASE} ${PACKAGES_V5_BASE})" >> $GITHUB_ENV
echo "PACKAGES_COVERAGE=$(echo ${PACKAGES_BASE} ${PACKAGES_V4_BASE} ${PACKAGES_COVERAGE_BASE})" >> $GITHUB_ENV
echo "PACKAGES_ALL=$(echo ${PACKAGES_BASE} ${PACKAGES_V5_BASE} ${PACKAGES_COVERAGE_BASE})" >> $GITHUB_ENV
echo "CI_BRANCH=$(echo $GITHUB_REF | cut -d '/' -f 3)" >> $GITHUB_ENV
echo "CI_SECURE_ENV_VARS=$(if [ -z '${{ secrets.something }}' ]; then echo 'false'; else echo 'true'; fi)" >> $GITHUB_ENV
echo "CI_EVENT_TYPE=$(if [ 'schedule' == '${{ github.event_name }}' ]; then echo 'cron'; else echo '${{ github.event_name }}'; fi)" >> $GITHUB_ENV
Expand All @@ -65,21 +68,11 @@ jobs:
echo "BUILD_DATE=`date -u '+%Y%m%d'`" >> $GITHUB_ENV
echo "GIT_REV=`git rev-parse --short HEAD`" >> $GITHUB_ENV

- name: Set Qt ENV variables (v4)
if: ${{ endsWith( matrix.task, 'v4') }}
run: |
echo "QT=${QT:-$(echo '${{matrix.task}}' | cut -d '-' -f 2)}" >> $GITHUB_ENV
echo "QT_INSTALL_DIR=/opt" >> $GITHUB_ENV
echo "QT_VERSION=6.9.3" >> $GITHUB_ENV
source $GITHUB_ENV && echo "QTDIR=${QT_INSTALL_DIR}/Qt/${QT_VERSION}/gcc_64" >> $GITHUB_ENV

- name: Set Qt ENV variables (v5)
if: ${{ endsWith( matrix.task, 'v5') || matrix.task == 'build-v5-mcp' }}
- name: Set Qt ENV variables
run: |
echo "QT=${QT:-$(echo '${{matrix.task}}' | cut -d '-' -f 2)}" >> $GITHUB_ENV
echo "QT=$(echo '${{matrix.task}}' | cut -d '-' -f 2)" >> $GITHUB_ENV
echo "QT_INSTALL_DIR=/opt" >> $GITHUB_ENV
echo "QT_VERSION=6.10.2" >> $GITHUB_ENV
source $GITHUB_ENV && echo "QTDIR=${QT_INSTALL_DIR}/Qt/${QT_VERSION}/gcc_64" >> $GITHUB_ENV
echo "QTDIR=/opt/Qt/${QT_VERSION}/${{ matrix.qt_dir_name }}" >> $GITHUB_ENV

- name: Print ENV vars
run: |
Expand All @@ -88,23 +81,28 @@ jobs:
echo "CI_REPO_SLUG: ${CI_REPO_SLUG}"
echo "CI_EVENT_TYPE: ${CI_EVENT_TYPE}"
echo "CI_SECURE_ENV_VARS: ${CI_SECURE_ENV_VARS}"
echo "PACKAGES_V4: ${PACKAGES_V4}"
echo "PACKAGES_V5: ${PACKAGES_V5}"
echo "PACKAGES_COVERAGE: ${PACKAGES_COVERAGE}"
echo "PACKAGES_ALL: ${PACKAGES_ALL}"
echo "TASK: ${TASK}"
echo "QT: ${QT}"
echo "QTDIR: ${QTDIR}"
echo "ARCH: ${{ matrix.appimage_arch }} / $(uname -m)"
echo "NPROC: ${NPROC}"

- name: Download, cache and install packages
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
with:
packages: ${{env.PACKAGES_ALL}}
version: ${{runner.os}}-apt
# Plain apt-get instead of a caching action: the cache action pins exact
# versions from a possibly-stale index and, worse, reports success even
# when every package fails to install.
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ${PACKAGES_ALL}
pkg-config --exists libftdi1 libusb-1.0
echo "OK: libftdi1 $(pkg-config --modversion libftdi1), libusb-1.0 $(pkg-config --modversion libusb-1.0)"

- name: Install Qt through aqt (and python)
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
arch: ${{ matrix.qt_arch }}
cache: true
dir: ${{ env.QT_INSTALL_DIR }}
modules: 'qt3d qtimageformats qtmultimedia qtserialport qtwebsockets'
Expand All @@ -115,18 +113,14 @@ jobs:
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2.23
with:
key: ${{ github.job }}-${{ matrix.task }}
key: ${{ github.job }}-${{ matrix.task }}-${{ matrix.appimage_arch }}

- name: Setup ruby cache
uses: ruby/setup-ruby@v1
with:
ruby-version: '2.7'
bundler-cache: true

- name: Install coveralls-lcov
if: ${{ startsWith( matrix.task, 'coverage') }}
run: sudo gem install coveralls-lcov

- name: Print program versions and username
run: |
echo "CXX:"
Expand All @@ -136,123 +130,161 @@ jobs:
echo "WHOAMI:"
whoami

- name: Configure for v4 build
if: ${{ matrix.task == 'build-v4' }}
run: |
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QTDIR/lib/cmake/" -Dappimage=ON -DINSTALL_ROOT=${INSTALL_ROOT} -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: webaccess/web-dmx/package-lock.json

- name: Configure for v5 build
if: ${{ matrix.task == 'build-v5' }}
run: |
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QTDIR/lib/cmake/" -Dqmlui=ON -Dappimage=ON -DINSTALL_ROOT=${INSTALL_ROOT} -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Build web-dmx web app
run: npm ci && npm run build
working-directory: webaccess/web-dmx

- name: Configure for v5 MCP build
if: ${{ matrix.task == 'build-v5-mcp' }}
run: |
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QTDIR/lib/cmake/" -Dqmlui=ON -Dmcp_server=ON -Dappimage=ON -DINSTALL_ROOT=${INSTALL_ROOT} -DCMAKE_CXX_COMPILER_LAUNCHER=ccache

- name: Configure for coverage build
if: ${{ matrix.task == 'coverage-build-v4' }}
run: |
cmake -DCMAKE_PREFIX_PATH="/opt/Qt/$QT_VERSION/gcc_64/lib/cmake" -Dcoverage=ON -DINSTALL_ROOT=${INSTALL_ROOT} -B build

- name: Build
run: |
sed -i "s/\(#define APPVERSION \".*\)\"/\1 ${{env.GIT_REV}}\"/" engine/src/qlcconfig.h
cmake --build build --parallel `nproc`

- name: Test
if: ${{ ! startsWith( matrix.task, 'coverage') }}
run: cmake --build build --target check

- name: Test with Coverage
if: ${{ startsWith( matrix.task, 'coverage') }}
run: cmake --build build --target lcov

- name: Coveralls
if: ${{ startsWith( matrix.task, 'coverage') }}
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
file: coverage/coverage.info
format: lcov

- name: Install
if: ${{ ! startsWith( matrix.task, 'coverage') }}
run: |
cmake --build build --target install
cp -v resources/icons/svg/qlcplus.svg ${INSTALL_ROOT}
cp -v platforms/linux/qlcplus.desktop ${INSTALL_ROOT}
cp -v $QTDIR/translations/qtbase_* ${INSTALL_ROOT}/share/qlcplus/translations/

- name: Set APPVERSION
if: ${{ ! startsWith( matrix.task, 'coverage') }}
run: |
APPVERSION=$(awk -F\" '/^#define APPVERSION/ {gsub(/ /,"-",$2); print $2}' engine/src/qlcconfig.h)
echo "APPVERSION=$APPVERSION" >> $GITHUB_ENV

- name: Adapt qlcplus for AppImage (v4)
if: ${{ matrix.task == 'build-v4' }}
- name: Verify web-dmx SPA is bundled
run: |
chrpath -r "../lib" ${INSTALL_ROOT}/usr/bin/qlcplus
test -f "${INSTALL_ROOT}/share/qlcplus/web/web-dmx/index.html" \
|| { echo "web-dmx/index.html missing from install root"; \
find ${INSTALL_ROOT} -name 'index.html'; exit 1; }
echo "OK: web-dmx SPA present"

- name: Adapt qlcplus for AppImage (v5)
if: ${{ matrix.task == 'build-v5' || matrix.task == 'build-v5-mcp' }}
run: |
chrpath -r "../lib" ${INSTALL_ROOT}/usr/bin/qlcplus-qml
sed -i -e 's/Exec=qlcplus --open %f/Exec=qlcplus-qml/g' ${INSTALL_ROOT}/qlcplus.desktop

- name: Store original install artifacts before stripping and AppImage
# Activate for debugging
if: ${{ false && ! startsWith( matrix.task, 'coverage') }}
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.task }}-files
path: ${{ env.INSTALL_ROOT }}

- name: Strip Binaries (v4)
if: ${{ matrix.task == 'build-v4' }}
run: |
strip -v ${INSTALL_ROOT}/usr/bin/qlcplus
find ${INSTALL_ROOT}/usr/lib/ -name libqlcplusengine.so.1.0.0 -exec strip -v {} \;

- name: Strip Binaries (v5)
if: ${{ matrix.task == 'build-v5' || matrix.task == 'build-v5-mcp' }}
run: |
strip -v ${INSTALL_ROOT}/usr/bin/qlcplus-qml
find ${INSTALL_ROOT}/usr/lib/ -name libqlcplusengine.so.1.0.0 -exec strip -v {} \;

- name: Delete unused files for AppImage
if: ${{ ! startsWith( matrix.task, 'coverage') }}
run: |
find ${INSTALL_ROOT}/usr/bin/ -name plugins.qmltypes -type f -delete
find ${INSTALL_ROOT}/usr/bin -name *.qmlc -type f -delete
rm -rf ${INSTALL_ROOT}/usr/bin/QtQuick/Extras QtQuick/Particles.2 QtQuick/XmlListModel
rm -rf ${INSTALL_ROOT}/usr/bin/QtQuick/Controls.2/designer QtQuick/Controls.2/Material
rm -rf ${INSTALL_ROOT}/usr/bin/QtQuick/Controls.2/Universal QtQuick/Controls.2/Fusion
rm -rf ${INSTALL_ROOT}/usr/bin/QtQuick/Controls.2/Imagine QtQuick/Controls.2/Scene2D
# Qt's QML modules install relocatable .o build artifacts (~1 MB). They are
# dead weight, and objdump -T rejects them, so they would break the ABI scan
# if their mode ever gained +x.
find ${INSTALL_ROOT} -type d -name 'objects-*' -prune -exec rm -rf {} +
find ${INSTALL_ROOT} -name '*.o' -type f -delete

- name: Build AppImage
if: ${{ ! startsWith( matrix.task, 'coverage') }}
env:
ARCH: ${{ matrix.appimage_arch }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: |
wget -c https://github.com/AppImage/AppImageKit/releases/download/continuous/AppRun-x86_64 -O ${INSTALL_ROOT}/AppRun
wget -c https://github.com/AppImage/AppImageKit/releases/download/continuous/AppRun-${ARCH} -O ${INSTALL_ROOT}/AppRun
chmod a+x ${INSTALL_ROOT}/AppRun
wget -c https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O ./appimagetool-x86_64.AppImage
chmod a+x ./appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage -v ${INSTALL_ROOT}
mv -v Q_Light_Controller_Plus_-_QLC+-x86_64.AppImage qlcplus-${{env.TASK}}-${{env.APPVERSION}}-${{env.BUILD_DATE}}-${{env.GIT_REV}}.AppImage
wget -c https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage -O ./appimagetool.AppImage
chmod a+x ./appimagetool.AppImage
./appimagetool.AppImage -v ${INSTALL_ROOT}
mv -v "Q_Light_Controller_Plus_-_QLC+-${ARCH}.AppImage" qlcplus-${{env.TASK}}-${{env.APPVERSION}}-${{env.BUILD_DATE}}-${{env.GIT_REV}}-${ARCH}.AppImage

- name: Verify AppImage payload architecture
env:
ARCH: ${{ matrix.appimage_arch }}
run: |
./qlcplus-${{env.TASK}}-${{env.APPVERSION}}-${{env.BUILD_DATE}}-${{env.GIT_REV}}-${ARCH}.AppImage --appimage-extract > /dev/null
file squashfs-root/usr/bin/qlcplus-qml
case "${ARCH}" in
x86_64) file squashfs-root/usr/bin/qlcplus-qml | grep -q 'ELF 64-bit.*x86-64' ;;
aarch64) file squashfs-root/usr/bin/qlcplus-qml | grep -q 'ELF 64-bit.*aarch64' ;;
esac
test -f squashfs-root/share/qlcplus/web/web-dmx/index.html
echo "OK: payload is ${ARCH} and web-dmx is bundled"

# Two ABI axes matter, not one: glibc and libstdc++. Neither is bundled.
# Ceilings are what the target actually provides (Raspberry Pi OS Trixie /
# Debian 13: glibc 2.41, GCC 14 -> GLIBCXX_3.4.33, per the GCC ABI table).
# This checks symbol-version floors only; it does not check the ~29
# unbundled host libraries (libGL, xcb, dbus, ...) the app also needs.
- name: Verify ABI floors against Raspberry Pi OS Trixie
if: ${{ matrix.appimage_arch == 'aarch64' }}
run: |
# Scan once, and let a failing objdump/find fail the step. A blanket
# '|| true' on this pipeline would let a PARTIAL scan failure report an
# under-measured maximum and pass.
syms=$(find squashfs-root -type f \( -name '*.so*' -o -perm -u+x \) -print0 \
| xargs -0 -r -n1 objdump -T 2>/dev/null) \
|| { echo "FAIL: symbol scan failed; some file could not be read"; exit 1; }
check_abi() {
prefix="$1"; allowed="$2"
# '|| true' here only absorbs "grep matched nothing", which the
# explicit test -n below then reports.
found=$(printf '%s\n' "${syms}" \
| grep -oE "${prefix}_[0-9]+(\.[0-9]+)+" | sed "s/${prefix}_//" | sort -uV | tail -1 || true)
echo "max ${prefix} required: ${found:-<none>} (Trixie provides: ${allowed})"
test -n "${found}" || { echo "FAIL: no ${prefix} symbols found; scan is broken"; exit 1; }
printf '%s\n%s\n' "${found}" "${allowed}" | sort -VC \
|| { echo "FAIL: needs ${prefix} ${found} > ${allowed} available on Trixie"; exit 1; }
}
check_abi GLIBC 2.41
check_abi GLIBCXX 3.4.33
echo "OK: glibc and libstdc++ symbol floors fit Raspberry Pi OS Trixie"

- name: Test Load AppImage
if: ${{ matrix.task == 'build-v5' }}
run: |
./qlcplus-${{env.TASK}}-${{env.APPVERSION}}-${{env.BUILD_DATE}}-${{env.GIT_REV}}.AppImage --platform offscreen --version
env:
ARCH: ${{ matrix.appimage_arch }}
run: |
rm -rf squashfs-root
# Hide the build host's Qt so this proves the AppImage is self-contained
# rather than silently linking against /opt/Qt. Use 'minimal', which is
# bundled; 'offscreen' is not shipped and would only resolve via host Qt.
# QML_IMPORT_PATH is the Qt6 spelling of QML2_IMPORT_PATH; clear both.
clean_env() {
env -u LD_LIBRARY_PATH -u QT_PLUGIN_PATH -u QML2_IMPORT_PATH -u QML_IMPORT_PATH \
-u QT_ROOT_DIR -u QT_QPA_PLATFORM_PLUGIN_PATH -u QT_QPA_PLATFORM -u QTDIR "$@"
}
echo "Qt-related variables as the child sees them:"
clean_env sh -c 'for v in LD_LIBRARY_PATH QT_PLUGIN_PATH QML2_IMPORT_PATH QML_IMPORT_PATH \
QT_ROOT_DIR QT_QPA_PLATFORM_PLUGIN_PATH QT_QPA_PLATFORM QTDIR; do
eval "val=\${$v-<unset>}"; echo " $v=$val"
done'
out=$(clean_env \
./qlcplus-${{env.TASK}}-${{env.APPVERSION}}-${{env.BUILD_DATE}}-${{env.GIT_REV}}-${ARCH}.AppImage \
--platform minimal --version 2>&1)
echo "${out}"
# APPVERSION has spaces replaced by dashes; the banner prints them as spaces.
expected=$(echo "${APPVERSION}" | tr '-' ' ')
# Without this guard an empty APPVERSION would make the grep below match anything.
test -n "${expected}" || { echo "FAIL: APPVERSION is empty; assertion would be vacuous"; exit 1; }
echo "${out}" | grep -qF "${expected}" \
|| { echo "FAIL: banner does not contain '${expected}'"; exit 1; }
echo "OK: ran standalone with host Qt hidden and printed '${expected}'"

- name: Store AppImage artifacts
if: ${{ ! startsWith( matrix.task, 'coverage') }}
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.task }}-AppImage
path: qlcplus-${{env.TASK}}-${{env.APPVERSION}}-${{env.BUILD_DATE}}-${{env.GIT_REV}}.AppImage
name: ${{ matrix.task }}-AppImage-${{ matrix.appimage_arch }}
path: qlcplus-${{env.TASK}}-${{env.APPVERSION}}-${{env.BUILD_DATE}}-${{env.GIT_REV}}-${{matrix.appimage_arch}}.AppImage

build-windows:
continue-on-error: true
Expand Down Expand Up @@ -692,7 +724,7 @@ jobs:

release-mcp:
if: github.event_name == 'push' && github.ref == 'refs/heads/mcp-server'
needs: [build-macos]
needs: [build-linux, build-macos]
runs-on: ubuntu-latest
name: Release MCP builds
permissions:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ test-reports/
.codex
.claude
.agents

# In-source CMake output (never commit: it embeds absolute machine paths)
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
Loading
Loading