Skip to content

fix(ci): keep BOLT off mimalloc, trace trainer crashes with gdb (spec… #444

fix(ci): keep BOLT off mimalloc, trace trainer crashes with gdb (spec…

fix(ci): keep BOLT off mimalloc, trace trainer crashes with gdb (spec… #444

Workflow file for this run

#
# Serial Studio
# https://serial-studio.com/
#
# Copyright (C) 2020–2025 Alex Spataru
#
# This file is dual-licensed:
#
# - Under the GNU GPLv3 (or later) for builds that exclude Pro modules.
# - Under the Serial Studio Commercial License for builds that include
# any Pro functionality.
#
# You must comply with the terms of one of these licenses, depending
# on your use case.
#
# For GPL terms, see <https://www.gnu.org/licenses/gpl-3.0.html>
# For commercial terms, see LICENSES/LicenseRef-SerialStudio-Commercial.txt.
#
# SPDX-License-Identifier: GPL-3.0-or-later OR LicenseRef-SerialStudio-Commercial
#
#---------------------------------------------------------------------------------------------------
# Workflow configuration
#---------------------------------------------------------------------------------------------------
name: CI
on:
push:
paths-ignore:
- '**.md'
pull_request:
paths-ignore:
- '**.md'
#---------------------------------------------------------------------------------------------------
# Workflow environment
#---------------------------------------------------------------------------------------------------
env:
#
# Application information
#
VERSION: "4.1.0"
PUBLISHER: "Alex Spataru"
UNIXNAME: "serial-studio-pro"
EXECUTABLE: "Serial-Studio-Pro"
APPLICATION: "Serial Studio Pro"
DESCRIPTION: "Multi-purpose serial data visualization & processing program"
#
# Microsoft Store (MSIX) package identity
#
MSIX_IDENTITY_NAME: "AlexSpataru.SerialStudioPro"
MSIX_PUBLISHER: "CN=05B4181B-4085-4067-8FAF-BD59DCB9A376"
MSIX_PUBLISHER_DISPLAY: "Alex Spataru"
#
# QML location (for windeployqt/macdeployqt)
#
QML_DIR: "../../app/qml"
#
# Qt version selection
#
QT_VERSION_LINUX: 6.11.2
QT_VERSION_MACOS: 6.11.2
QT_VERSION_WINDOWS: 6.11.2
QTFRAMEWORK_BYPASS_LICENSE_CHECK: "true"
#
# gRPC selection
#
GRPC_VERSION: 1.78.1
GRPC_REPO: alex-spataru/gRPC-Builds
#
# Unity build batch size (sources per unity TU). Single source of truth for
# every configure site: both PGO stages must see the identical value or the
# unity TU layout diverges and profile data is silently dropped.
#
# Sized against the largest unity target and the narrowest runner: core/Ui has
# 213 sources, and ninja runs at nproc + 2, so 5 jobs in flight on the 3-vCPU
# macOS arm64 box. At 32 that target is 7 TUs, two waves with the second one
# mostly idle; at 48 it is 5, a single wave, and no smaller target loses a
# wave. Going past 48 only fattens the TUs without dropping a wave, and peak
# compiler RSS times 5 has to stay inside the runner's 7 GB.
#
# Note: setting batch size to 0 results in a single TU with all sources
#
SS_UNITY_BATCH: 48
#
# Excluded AppImage libraries
#
LINUXDEPLOY_EXCLUDED_LIBRARIES: "libmysqlclient.so*;libqsqlmimer.so;libqsqlmysql.so;libqsqlodbc.so;libqsqlpsql.so;libqsqloci.so;libqsqldb2.so;libqsqlibase.so"
#---------------------------------------------------------------------------------------------------
# Workflow jobs
#---------------------------------------------------------------------------------------------------
jobs:
#---------------------------------------------------------------------------------------------------
# GNU/Linux build (x86_64)
#---------------------------------------------------------------------------------------------------
build-linux:
runs-on: ubuntu-24.04
name: '🐧 Linux Build (x86_64)'
permissions:
contents: read
steps:
#
# This repo has no submodules; 'submodules: recursive' is a no-op kept for future deps.
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
submodules: 'recursive'
#
# apt payload for a Qt 6 desktop build: the xcb/xkb platform stack, GStreamer for
# QtMultimedia, and the packaging tools (rpm, fakeroot, python3-venv) linuxdeploy shells out to
#
- name: '⚙️ Install dependencies'
uses: ./.github/actions/linux-desktop-deps
#
# gRPC arrives as a prebuilt tarball from GRPC_REPO; building it from source would cost more
# than the rest of the job put together. The retry policy lives in the action, which the
# macOS legs share
#
- name: '⚙️ Download prebuilt gRPC'
uses: ./.github/actions/download-grpc
with:
version: ${{env.GRPC_VERSION}}
repo: ${{env.GRPC_REPO}}
asset: grpc-${{env.GRPC_VERSION}}-linux-x86_64.tar.gz
prefix-dir: ${{github.workspace}}/grpc-prefix
workspace: ${{github.workspace}}
github-token: ${{secrets.GITHUB_TOKEN}}
#
# Cache restore, install-on-miss, cache save and the PATH / CMAKE_PREFIX_PATH export. This
# job owns the save half of the entry the restore-only legs read
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_LINUX}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64
qt-arch: x64
qt-host: gcc_64
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_LINUX}}-x64
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
#
# get-cmake supplies a current CMake independently of whatever the runner image ships
#
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
#
# Unit tests live inside the build job so the Qt cache has one consumer per arch;
# a parallel unit job racing this restore could get denied or evict the entry
#
- name: '🚧 Configure unit-test tier (Pro)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -G Ninja -B build/unit-ci \
-DCMAKE_BUILD_TYPE=Debug \
-DSS_BUILD_TESTS=ON \
-DBUILD_GPL3=OFF \
-DBUILD_COMMERCIAL=ON \
-DENABLE_GRPC=OFF \
-DWITH_WEBENGINE=OFF \
-DSS_USE_MIMALLOC=OFF \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON
#
# Lean Debug tier: no gRPC, no WebEngine, no mimalloc, system zlib and expat; the application
# target never builds here. Pro, because this job is where the shipped Pro binary comes
# from: a GPL3 unit tier never registers the suites gated on the commercial sources, and
# never compiles the #ifdef BUILD_COMMERCIAL branches of the TUs a suite links, so both
# were proved by the sanitizer legs alone, the slowest jobs in the run and the last to
# report. BUILD_COMMERCIAL validates the license at configure time, hence the credentials
# above.
#
- name: '🚧 Build unit-test tier'
run: cmake --build build/unit-ci --target ss_unit_tests
#
# Runs on both arches: DSPSimd.h picks its SIMD lane from the target architecture
#
- name: '🧪 Run ctest'
env:
QT_QPA_PLATFORM: offscreen
run: ctest --test-dir build/unit-ci --output-on-failure
#
# Findings G12 / M7: the repo had no QML tier, so unqualified property access and binding
# loops were caught only by somebody noticing a runtime warning. The gate lives here rather
# than in `lint` because qmllint needs Qt and a configured tree, and this job has both.
# Findings are normalized without line numbers (they churn on every edit above the warning)
# and compared against app/qml/qmllint-baseline.json
#
- name: '🔬 QML lint'
run: |
set -uo pipefail
cmake --build build/unit-ci --target ss_qmllint > qmllint.txt 2>&1 || true
python3 - <<'PY'
import json, os, re, sys
BASELINE = "app/qml/qmllint-baseline.json"
text = open("qmllint.txt", encoding="utf-8", errors="replace").read()
if "No rule to make target" in text or "unknown target" in text:
sys.exit("::error::the ss_qmllint target does not exist; is Qt's qmllint installed?")
pattern = re.compile(r"^(?:Warning|Error|Info):\s+(\S+?):\d+:\d+:\s+(.*)$")
found = set()
for line in text.split("\n"):
match = pattern.match(line.strip())
if not match:
continue
# qmllint prints absolute paths; key on the repo-relative part so the baseline
# does not depend on where the workspace happened to be checked out.
path = match.group(1).replace(os.sep, "/")
marker = path.find("app/qml/")
path = path[marker:] if marker >= 0 else os.path.relpath(path, os.getcwd())
found.add(f"{path} :: {match.group(2).strip()}")
baseline = json.load(open(BASELINE, encoding="utf-8"))
accepted = set(baseline.get("accepted", []))
new = sorted(found - accepted)
print(f"{len(found)} qmllint finding(s), {len(accepted)} accepted, {len(new)} new")
for entry in new:
print(f" {entry}")
if not baseline.get("seeded"):
print("::warning::qmllint baseline is unseeded; paste the list above into "
f"{BASELINE} and set seeded=true to arm the gate")
sys.exit(0)
if new:
sys.exit(f"::error::{len(new)} qmllint finding(s) not in {BASELINE}")
gone = sorted(accepted - found)
if gone:
print("::warning::baseline entries no longer reported; prune them: "
+ ", ".join(gone))
PY
- name: '📤 Upload artifact: qmllint output'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: qmllint-Linux-x64
path: qmllint.txt
if-no-files-found: ignore
#
# Stage 1 of the two-stage PGO flow: an instrumented Release build whose only job is to emit
# profiles for the training runs below
#
- name: '🚧 Configure with CMake (PGO generate)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja \
-DSS_BUILD_COMMIT=${{ github.sha }} \
-DPRODUCTION_OPTIMIZATION=ON \
-DENABLE_HARDENING=ON \
-DENABLE_GRPC=ON \
-DENABLE_PGO=ON \
-DPGO_STAGE=GENERATE \
-DENABLE_POST_LINK_LAYOUT=ON \
-DSS_ALLOC_STATS=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON \
-DSS_INAPP_TESTS=ON \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix;${CMAKE_PREFIX_PATH}" \
-DBUILD_COMMERCIAL=ON \
-DBUILD_GPL3=OFF
#
# Finding L1: CMake composes the command line as <DEFINES> <INCLUDES> <FLAGS>, so a FORTIFY
# level passed as a compile DEFINITION lands ahead of every -U_FORTIFY_SOURCE in FLAGS and is
# cancelled by it -- which is how hardened Linux packages shipped with no FORTIFY at all.
# Reading the real compile line is the only way to know which token wins, so this step derives
# the level Hardening.cmake should have chosen and asserts the compile line agrees
#
- name: '🛡 Assert FORTIFY survives the compile line'
run: |
set -euo pipefail
GCC_MAJOR=$(cc -dumpversion | cut -d. -f1)
GLIBC=$(getconf GNU_LIBC_VERSION | awk '{print $2}')
GLIBC_MAJOR=${GLIBC%%.*}
GLIBC_MINOR=${GLIBC#*.}
GLIBC_MINOR=${GLIBC_MINOR%%.*}
EXPECT=2
if [ "$GCC_MAJOR" -ge 12 ] && [ "$GLIBC_MAJOR" -ge 2 ] && [ "$GLIBC_MINOR" -ge 34 ]; then
EXPECT=3
fi
echo "cc major $GCC_MAJOR, glibc $GLIBC -> expecting -D_FORTIFY_SOURCE=$EXPECT"
LAST=$(python3 - <<'PY'
import json, sys
db = json.load(open("build/compile_commands.json"))
rows = [e for e in db
if "app/CMakeFiles" in (e.get("output") or "") + " " + (e.get("file") or "")]
if not rows:
sys.exit("no compile_commands.json entry for the application target")
seen = set()
for e in rows:
cmd = e.get("command") or " ".join(e.get("arguments", []))
tokens = [t for t in cmd.split() if "_FORTIFY_SOURCE" in t]
if not tokens:
sys.exit("no FORTIFY token on an application compile line")
seen.add(tokens[-1])
if len(seen) != 1:
sys.exit("application TUs disagree on the FORTIFY level: %s" % sorted(seen))
print(seen.pop())
PY
)
echo "last FORTIFY token on the application compile line: $LAST"
if [ "$LAST" != "-D_FORTIFY_SOURCE=$EXPECT" ]; then
echo "::error::FORTIFY cancelled or wrong level: got '$LAST', expected -D_FORTIFY_SOURCE=$EXPECT"
exit 1
fi
#
# Builds the instrumented binary the two training runs profile
#
- name: '🚧 Build application (instrumented)'
run: cmake --build build --config Release
#
# Pro widgets are license-gated, so an unactivated training run profiles the GPL subset
# and the shipped binary is optimized for code no Pro user runs. A failed activation is
# therefore a failed build, not a warning. The two training loads follow it: --min-fps 1
# turns the benchmark into a profile generator (the real gate runs later, against the
# optimized binary), and the big_db_test load exercises the project and dashboard paths
# the hotpath benchmark never reaches
#
- name: '🏋️ PGO training runs'
uses: ./.github/actions/pgo-train-linux
with:
app: ./build/app/${{env.UNIXNAME}}
qt-lib-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/lib
license-key: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
#
# Stage 2: identical flags with PGO_STAGE=USE so the compiler consumes the profiles just
# written
#
- name: '🚧 Reconfigure with CMake (PGO use)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja \
-DSS_BUILD_COMMIT=${{ github.sha }} \
-DPRODUCTION_OPTIMIZATION=ON \
-DENABLE_HARDENING=ON \
-DENABLE_GRPC=ON \
-DENABLE_PGO=ON \
-DPGO_STAGE=USE \
-DENABLE_POST_LINK_LAYOUT=ON \
-DSS_ALLOC_STATS=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON \
-DSS_INAPP_TESTS=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix;${CMAKE_PREFIX_PATH}" \
-DBUILD_COMMERCIAL=ON \
-DBUILD_GPL3=OFF
#
# The binary that ships, and the one every gate below measures
#
- name: '🚧 Build application (PGO optimized)'
run: cmake --build build --config Release
#
# Spec 0090: BOLT instruments the PGO binary, re-runs the training loads, rewrites the
# layout (strip + sidecar included; the action is the only post-link writer) -- so every gate, package
# and signature below consumes the rewritten binary. Fail-hard: no fallback to the
# un-rewritten binary
#
- name: '🔩 Post-link layout (BOLT)'
uses: ./.github/actions/bolt-linux
with:
app: ./build/app/${{env.UNIXNAME}}
qt-lib-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/lib
#
# Spec 0090 AC2: the pre-root suites (smoke + script-unwind) prove the rewritten binary's
# exception unwind still carries a Lua error back into the host
#
- name: '🧪 Binary self-test (rewritten binary)'
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/lib
run: ./build/app/${{env.UNIXNAME}} --headless --selftest
#
# Findings G12 / M7: instantiates every compiled .qml against stubbed Cpp_* globals and
# fails on a ReferenceError -- the GPL-build-hits-a-Pro-global class of bug. This is the
# commercial leg; the sanitize job runs the same suite on a GPL build
#
- name: '🧪 QML instantiation self-test'
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/lib
run: ./build/app/${{env.UNIXNAME}} --headless --selftest-suite qml
#
# The CI throughput gate: nine tiers scaled off --min-fps. A miss fails the build
#
- name: '🚦 Hotpath throughput gate (256 kHz)'
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/lib
run: |
./build/app/${{env.UNIXNAME}} --headless --benchmark-hotpath --min-fps 256000 \
--benchmark-output benchmark.txt
- name: '📤 Upload artifact: benchmark report (Linux x64)'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: benchmark-Linux-x64
path: |
benchmark.txt
pgo-train.txt
bolt-report.txt
#
# Spec 0084: the line-table symbols objcopy split out of the stripped binary, so a profile or
# crash stack from this exact build can be symbolicated later
#
- name: '📤 Upload artifact: symbols (Linux x64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: symbols-Linux-x64
path: build/app/${{env.UNIXNAME}}.debug
if-no-files-found: ignore
#
# Replays the big_db_test load against the optimized binary; catches the teardown crashes the
# throughput benchmark never sees
#
- name: '🩺 Big project verification (optimized)'
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/lib
run: |
bash tests/benchmarks/big_db_test/run_load.sh "./build/app/${{env.UNIXNAME}}" 15
#
# always(): activations are seat-limited, so a failed job still has to release the seat
#
- name: '🔑 Deactivate Serial Studio license'
if: always()
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/lib
run: |
./build/app/${{env.UNIXNAME}} --deactivate || true
#
# The Linux packaging chain, shared with the other arch: signing identity, AppDir, the
# glibc-bundled AppImage (spec 0065, the ONLY Linux packaging path), the deb/rpm pair cut
# from that same converted tree, the glibc 2.28 smoke gate that proves the set installs on
# Debian 10 / RHEL 8, and the signatures. Only arch strings and checksums differ
#
- name: '📦 Package for Linux'
uses: ./.github/actions/package-linux
with:
workspace: ${{github.workspace}}
arch-label: x64
machine-arch: x86_64
deb-arch: amd64
config-arch: x86_64
system-lib-dir: /usr/lib/x86_64-linux-gnu
qt-plugins-path: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64/${{env.QT_VERSION_LINUX}}/gcc_64/plugins
qml-dir: ${{env.QML_DIR}}
sharun-sha256: f35d4f59f2e0b1a5ec12ef126d78197fd4fd14c6f99b4b16dee6a5ddad6baa93
appimagetool-sha256: ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
executable: ${{env.EXECUTABLE}}
version: ${{env.VERSION}}
unixname: ${{env.UNIXNAME}}
description: ${{env.DESCRIPTION}}
gpg-private-key: ${{secrets.GPG_PRIVATE_KEY}}
gpg-passphrase: ${{secrets.GPG_PASSPHRASE}}
gpg-key-id: ${{secrets.GPG_KEY_ID}}
#
# Release artifact: the Linux AppImage users download — the glibc-bundled build (spec
# 0065) runs on everything from Debian 10 / RHEL 8 up, so it is the only one shipped
#
- name: '📤 Upload artifact: AppImage'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.AppImage
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.AppImage
#
# Release artifact: Debian package
#
- name: '📤 Upload artifact: .deb (x64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.deb
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.deb
#
# Release artifact: RPM package
#
- name: '📤 Upload artifact: .rpm (x64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.rpm
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.rpm
#
# Detached signatures plus the raw binary and the public key, so a user can verify without
# trusting the release page; ignored when signing was skipped
#
- name: '📤 Upload artifact: signatures + signed binary (x64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64-signed
if-no-files-found: ignore
path: |
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.AppImage.asc
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.deb.asc
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.rpm.asc
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-x64.asc
signing-public-key.asc
#---------------------------------------------------------------------------------------------------
# GNU/Linux build (aarch64)
#---------------------------------------------------------------------------------------------------
build-linux-arm64:
runs-on: ubuntu-24.04-arm
name: '🐧 Linux Build (arm64)'
permissions:
contents: read
steps:
#
# This repo has no submodules; 'submodules: recursive' is a no-op kept for future deps.
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
submodules: 'recursive'
#
# apt payload for a Qt 6 desktop build: the xcb/xkb platform stack, GStreamer for
# QtMultimedia, and the packaging tools (rpm, fakeroot, python3-venv) linuxdeploy shells out to
#
- name: '⚙️ Install dependencies'
uses: ./.github/actions/linux-desktop-deps
#
# gRPC arrives as a prebuilt tarball from GRPC_REPO; building it from source would cost more
# than the rest of the job put together. The retry policy lives in the action, which the
# macOS legs share
#
- name: '⚙️ Download prebuilt gRPC'
uses: ./.github/actions/download-grpc
with:
version: ${{env.GRPC_VERSION}}
repo: ${{env.GRPC_REPO}}
asset: grpc-${{env.GRPC_VERSION}}-linux-aarch64.tar.gz
prefix-dir: ${{github.workspace}}/grpc-prefix
workspace: ${{github.workspace}}
github-token: ${{secrets.GITHUB_TOKEN}}
#
# Cache restore, install-on-miss, cache save and the PATH / CMAKE_PREFIX_PATH export. This
# job owns the save half of the entry the restore-only legs read
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_LINUX}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-arm64
qt-arch: arm64
qt-host: gcc_arm64
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_LINUX}}-arm64
cache-extra-path: ''
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
#
# get-cmake supplies a current CMake independently of whatever the runner image ships
#
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
#
# Unit tests live inside the build job so the Qt cache has one consumer per arch;
# a parallel unit job racing this restore could get denied or evict the entry
#
- name: '🚧 Configure unit-test tier (Pro)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -G Ninja -B build/unit-ci \
-DCMAKE_BUILD_TYPE=Debug \
-DSS_BUILD_TESTS=ON \
-DBUILD_GPL3=OFF \
-DBUILD_COMMERCIAL=ON \
-DENABLE_GRPC=OFF \
-DWITH_WEBENGINE=OFF \
-DSS_USE_MIMALLOC=OFF \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON
#
# Lean Debug tier: no gRPC, no WebEngine, no mimalloc, system zlib and expat; the application
# target never builds here. Pro, because this job is where the shipped Pro binary comes
# from: a GPL3 unit tier never registers the suites gated on the commercial sources, and
# never compiles the #ifdef BUILD_COMMERCIAL branches of the TUs a suite links, so both
# were proved by the sanitizer legs alone, the slowest jobs in the run and the last to
# report. BUILD_COMMERCIAL validates the license at configure time, hence the credentials
# above.
#
- name: '🚧 Build unit-test tier'
run: cmake --build build/unit-ci --target ss_unit_tests
#
# Runs on both arches: DSPSimd.h picks its SIMD lane from the target architecture
#
- name: '🧪 Run ctest'
env:
QT_QPA_PLATFORM: offscreen
run: ctest --test-dir build/unit-ci --output-on-failure
#
# Stage 1 of the two-stage PGO flow: an instrumented Release build whose only job is to emit
# profiles for the training runs below
#
- name: '🚧 Configure with CMake (PGO generate)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja \
-DSS_BUILD_COMMIT=${{ github.sha }} \
-DPRODUCTION_OPTIMIZATION=ON \
-DENABLE_HARDENING=ON \
-DENABLE_GRPC=ON \
-DENABLE_PGO=ON \
-DPGO_STAGE=GENERATE \
-DENABLE_POST_LINK_LAYOUT=ON \
-DSS_ALLOC_STATS=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON \
-DSS_INAPP_TESTS=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix;${CMAKE_PREFIX_PATH}" \
-DBUILD_COMMERCIAL=ON \
-DBUILD_GPL3=OFF
#
# Builds the instrumented binary the two training runs profile
#
- name: '🚧 Build application (instrumented)'
run: cmake --build build --config Release
#
# Pro widgets are license-gated, so an unactivated training run profiles the GPL subset
# and the shipped binary is optimized for code no Pro user runs. A failed activation is
# therefore a failed build, not a warning. The two training loads follow it: --min-fps 1
# turns the benchmark into a profile generator (the real gate runs later, against the
# optimized binary), and the big_db_test load exercises the project and dashboard paths
# the hotpath benchmark never reaches
#
- name: '🏋️ PGO training runs'
uses: ./.github/actions/pgo-train-linux
with:
app: ./build/app/${{env.UNIXNAME}}
qt-lib-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-arm64/${{env.QT_VERSION_LINUX}}/gcc_arm64/lib
license-key: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
#
# Stage 2: identical flags with PGO_STAGE=USE so the compiler consumes the profiles just
# written
#
- name: '🚧 Reconfigure with CMake (PGO use)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja \
-DSS_BUILD_COMMIT=${{ github.sha }} \
-DPRODUCTION_OPTIMIZATION=ON \
-DENABLE_HARDENING=ON \
-DENABLE_GRPC=ON \
-DENABLE_PGO=ON \
-DPGO_STAGE=USE \
-DENABLE_POST_LINK_LAYOUT=ON \
-DSS_ALLOC_STATS=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON \
-DSS_INAPP_TESTS=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix;${CMAKE_PREFIX_PATH}" \
-DBUILD_COMMERCIAL=ON \
-DBUILD_GPL3=OFF
#
# The binary that ships, and the one every gate below measures
#
- name: '🚧 Build application (PGO optimized)'
run: cmake --build build --config Release
#
# Spec 0090: same BOLT stage as the x86_64 job; arm64 is BOLT's least-battle-tested arch,
# so this job is the rollout canary. Fail-hard: no fallback to the un-rewritten binary
#
- name: '🔩 Post-link layout (BOLT)'
uses: ./.github/actions/bolt-linux
with:
app: ./build/app/${{env.UNIXNAME}}
qt-lib-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-arm64/${{env.QT_VERSION_LINUX}}/gcc_arm64/lib
#
# Spec 0090 AC2: the pre-root suites (smoke + script-unwind) prove the rewritten binary's
# exception unwind still carries a Lua error back into the host
#
- name: '🧪 Binary self-test (rewritten binary)'
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-arm64/${{env.QT_VERSION_LINUX}}/gcc_arm64/lib
run: ./build/app/${{env.UNIXNAME}} --headless --selftest
#
# The CI throughput gate: nine tiers scaled off --min-fps. A miss fails the build
#
- name: '🚦 Hotpath throughput gate (256 kHz)'
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-arm64/${{env.QT_VERSION_LINUX}}/gcc_arm64/lib
run: |
./build/app/${{env.UNIXNAME}} --headless --benchmark-hotpath --min-fps 256000 \
--benchmark-output benchmark.txt
- name: '📤 Upload artifact: benchmark report (Linux arm64)'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: benchmark-Linux-arm64
path: |
benchmark.txt
pgo-train.txt
bolt-report.txt
- name: '📤 Upload artifact: symbols (Linux arm64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: symbols-Linux-arm64
path: build/app/${{env.UNIXNAME}}.debug
if-no-files-found: ignore
#
# always(): activations are seat-limited, so a failed job still has to release the seat
#
- name: '🔑 Deactivate Serial Studio license'
if: always()
env:
QT_QPA_PLATFORM: offscreen
LD_LIBRARY_PATH: >-
${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-arm64/${{env.QT_VERSION_LINUX}}/gcc_arm64/lib
run: |
./build/app/${{env.UNIXNAME}} --deactivate || true
#
# The Linux packaging chain, shared with the other arch: signing identity, AppDir, the
# glibc-bundled AppImage (spec 0065, the ONLY Linux packaging path), the deb/rpm pair cut
# from that same converted tree, the glibc 2.28 smoke gate that proves the set installs on
# Debian 10 / RHEL 8, and the signatures. Only arch strings and checksums differ
#
- name: '📦 Package for Linux'
uses: ./.github/actions/package-linux
with:
workspace: ${{github.workspace}}
arch-label: arm64
machine-arch: aarch64
deb-arch: arm64
config-arch: arm64
system-lib-dir: /usr/lib/aarch64-linux-gnu
qt-plugins-path: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-arm64/${{env.QT_VERSION_LINUX}}/gcc_arm64/plugins
qml-dir: ${{env.QML_DIR}}
sharun-sha256: bf3b8cc04e3025ef9dcd2718ee4728ccc68c941dadeeac7fdf778fc2c99d8b31
appimagetool-sha256: f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158
executable: ${{env.EXECUTABLE}}
version: ${{env.VERSION}}
unixname: ${{env.UNIXNAME}}
description: ${{env.DESCRIPTION}}
gpg-private-key: ${{secrets.GPG_PRIVATE_KEY}}
gpg-passphrase: ${{secrets.GPG_PASSPHRASE}}
gpg-key-id: ${{secrets.GPG_KEY_ID}}
#
# Release artifact: the Linux AppImage users download — the glibc-bundled build (spec
# 0065) runs on everything from Debian 10 / RHEL 8 up, so it is the only one shipped
#
- name: '📤 Upload artifact: AppImage'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.AppImage
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.AppImage
#
# Release artifact: Debian package
#
- name: '📤 Upload artifact: .deb (arm64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.deb
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.deb
#
# Release artifact: RPM package
#
- name: '📤 Upload artifact: .rpm (arm64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.rpm
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.rpm
#
# Detached signatures plus the raw binary and the public key, so a user can verify without
# trusting the release page; ignored when signing was skipped
#
- name: '📤 Upload artifact: signatures + signed binary (arm64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64-signed
if-no-files-found: ignore
path: |
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.AppImage.asc
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.deb.asc
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.rpm.asc
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64
${{env.EXECUTABLE}}-${{env.VERSION}}-Linux-arm64.asc
signing-public-key.asc
#---------------------------------------------------------------------------------------------------
# macOS arm64 slice
#---------------------------------------------------------------------------------------------------
build-macos-arm64:
runs-on: macos-latest
name: '🍎 macOS Build (arm64)'
permissions:
contents: read
steps:
#
# This repo has no submodules; 'submodules: recursive' is a no-op kept for future deps.
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
submodules: 'recursive'
#
# One universal tarball feeds both macOS slices, so the arm64 and x86_64 jobs stay in sync.
# Retried for the same reason as the Linux legs; see the x86_64 job.
#
- name: '⚙️ Download prebuilt gRPC (universal)'
uses: ./.github/actions/download-grpc
with:
version: ${{env.GRPC_VERSION}}
repo: ${{env.GRPC_REPO}}
asset: grpc-${{env.GRPC_VERSION}}-macos-universal.tar.gz
prefix-dir: ${{github.workspace}}/grpc-prefix
workspace: ${{github.workspace}}
github-token: ${{secrets.GITHUB_TOKEN}}
#
# Restore-only half of the cache pair; the save step below is gated on a miss so a hit never
# rewrites the entry
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_MACOS}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64
qt-arch: x64
qt-host: macos
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_MACOS}}-arm64
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
#
# get-cmake supplies a current CMake independently of whatever the runner image ships
#
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
#
# Stage 1 of the two-stage PGO flow, pinned to arm64: this job builds only the native slice
#
- name: '🚧 Configure with CMake (PGO generate, arm64)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja \
-DSS_BUILD_COMMIT=${{ github.sha }} \
-DPRODUCTION_OPTIMIZATION=ON \
-DENABLE_HARDENING=ON \
-DENABLE_GRPC=ON \
-DENABLE_PGO=ON \
-DPGO_STAGE=GENERATE \
-DENABLE_POST_LINK_LAYOUT=ON \
-DSS_ALLOC_STATS=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \
-DCMAKE_OSX_ARCHITECTURES="arm64" \
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix;${CMAKE_PREFIX_PATH}" \
-DBUILD_COMMERCIAL=ON \
-DBUILD_GPL3=OFF
#
# Builds the instrumented arm64 binary the training runs profile
#
- name: '🚧 Build application (instrumented, arm64)'
run: cmake --build build --config Release
#
# Pro widgets are license-gated, so an unactivated training run profiles the GPL subset and
# the shipped binary is optimized for code no Pro user runs. A failed activation is therefore
# a failed build, not a warning
#
- name: '🔑 Activate Serial Studio license (Pro hotpath training)'
env:
QT_QPA_PLATFORM: offscreen
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
DYLD_FRAMEWORK_PATH: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64/${{env.QT_VERSION_MACOS}}/macos/lib
run: |
./build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}} --activate "$SERIAL_STUDIO_LICENSE_KEY"
#
# --min-fps 1 turns the benchmark into a profile generator: the real gate runs later, against
# the optimized binary
#
- name: '🏋️ PGO training run (hotpath)'
env:
QT_QPA_PLATFORM: offscreen
DYLD_FRAMEWORK_PATH: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64/${{env.QT_VERSION_MACOS}}/macos/lib
run: |
./build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}} --headless --benchmark-hotpath --min-fps 1 \
--benchmark-output pgo-train.txt
#
# Second profile: the big_db_test load exercises the project and dashboard paths that the
# hotpath benchmark never reaches
#
- name: '🏋️ PGO training run (big project)'
env:
QT_QPA_PLATFORM: offscreen
DYLD_FRAMEWORK_PATH: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64/${{env.QT_VERSION_MACOS}}/macos/lib
run: |
bash tests/benchmarks/big_db_test/run_load.sh "./build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}}" 25
#
# Stage 2: LLVM_PROFDATA comes from xcrun so the merge tool matches the toolchain that wrote
# the raw profiles
#
- name: '🚧 Reconfigure with CMake (PGO use, arm64)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja \
-DSS_BUILD_COMMIT=${{ github.sha }} \
-DPRODUCTION_OPTIMIZATION=ON \
-DENABLE_HARDENING=ON \
-DENABLE_GRPC=ON \
-DENABLE_PGO=ON \
-DPGO_STAGE=USE \
-DENABLE_POST_LINK_LAYOUT=ON \
-DSS_ALLOC_STATS=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} \
-DLLVM_PROFDATA=$(xcrun -f llvm-profdata) \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \
-DCMAKE_OSX_ARCHITECTURES="arm64" \
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix;${CMAKE_PREFIX_PATH}" \
-DBUILD_COMMERCIAL=ON \
-DBUILD_GPL3=OFF
#
# The arm64 half of the universal binary the merge job assembles
#
- name: '🚧 Build application (PGO optimized, arm64)'
run: cmake --build build --config Release
#
# The CI throughput gate: nine tiers scaled off --min-fps. A miss fails the build
#
- name: '🚦 Hotpath throughput gate (256 kHz)'
env:
QT_QPA_PLATFORM: offscreen
DYLD_FRAMEWORK_PATH: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64/${{env.QT_VERSION_MACOS}}/macos/lib
run: |
./build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}} \
--headless --benchmark-hotpath --min-fps 256000 --benchmark-output benchmark.txt
- name: '📤 Upload artifact: benchmark report (macOS arm64)'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: benchmark-macOS-arm64
path: |
benchmark.txt
pgo-train.txt
build/hot-order.txt
- name: '📤 Upload artifact: symbols (macOS arm64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: symbols-macOS-arm64
path: build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}}.dSYM
if-no-files-found: ignore
#
# Replays the big_db_test load against the optimized binary; catches the teardown crashes the
# throughput benchmark never sees
#
- name: '🩺 Big project verification (optimized)'
env:
QT_QPA_PLATFORM: offscreen
DYLD_FRAMEWORK_PATH: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64/${{env.QT_VERSION_MACOS}}/macos/lib
run: |
bash tests/benchmarks/big_db_test/run_load.sh "./build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}}" 15
#
# always(): activations are seat-limited, so a failed job still has to release the seat
#
- name: '🔑 Deactivate Serial Studio license'
if: always()
env:
QT_QPA_PLATFORM: offscreen
DYLD_FRAMEWORK_PATH: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64/${{env.QT_VERSION_MACOS}}/macos/lib
run: |
./build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}} --deactivate || true
#
# cpack builds the DMG from the deployed bundle
#
- name: '📦 Package application'
run: |
cd build
cpack --verbose
#
# The merge job needs a bundle, not a disk image, so the .app is pulled back out of the DMG
#
- name: '💿 Mount DMG and copy application'
run: |
VOLUME=$(yes | hdiutil attach ./build/*.dmg -nobrowse | grep "Volumes" | awk '{print $3}')
cp -a "$VOLUME/${{env.EXECUTABLE}}.app" "${{env.APPLICATION}}.app"
hdiutil detach "$VOLUME"
#
# The deploy tools bundle only the native platform plugin; the integration tests run with
# QT_QPA_PLATFORM=offscreen
#
- name: '🧩 Bundle offscreen platform plugin (headless CI tests)'
run: |
QT_PLUGINS_PATH="${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64/${{env.QT_VERSION_MACOS}}/macos/plugins"
DEST="${{env.APPLICATION}}.app/Contents/PlugIns/platforms"
mkdir -p "$DEST"
if [ -f "$QT_PLUGINS_PATH/platforms/libqoffscreen.dylib" ]; then
cp "$QT_PLUGINS_PATH/platforms/libqoffscreen.dylib" "$DEST/"
else
echo "Warning: offscreen platform plugin not found at $QT_PLUGINS_PATH/platforms"
fi
#
# COPYFILE_DISABLE=1 keeps AppleDouble ._ files out of the tarball, so the bundle survives the
# round trip through the artifact store intact
#
- name: '🗜 Archive deployed .app'
run: |
COPYFILE_DISABLE=1 tar -czf macos-arm64-app.tar.gz "${{env.APPLICATION}}.app"
#
# retention-days: 1 marks this as an intermediate for the universal merge job, not a release
# artifact
#
- name: '📤 Upload artifact: arm64 deployed .app'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: macos-arm64-app
path: macos-arm64-app.tar.gz
retention-days: 1
#---------------------------------------------------------------------------------------------------
# macOS x86_64
#---------------------------------------------------------------------------------------------------
build-macos-intel:
runs-on: macos-latest
name: '🍎 macOS Build (x86_64)'
permissions:
contents: read
steps:
#
# This repo has no submodules; 'submodules: recursive' is a no-op kept for future deps.
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
submodules: 'recursive'
#
# One universal tarball feeds both macOS slices, so the arm64 and x86_64 jobs stay in sync.
# Retried for the same reason as the Linux legs; see the x86_64 job.
#
- name: '⚙️ Download prebuilt gRPC (universal)'
uses: ./.github/actions/download-grpc
with:
version: ${{env.GRPC_VERSION}}
repo: ${{env.GRPC_REPO}}
asset: grpc-${{env.GRPC_VERSION}}-macos-universal.tar.gz
prefix-dir: ${{github.workspace}}/grpc-prefix
workspace: ${{github.workspace}}
github-token: ${{secrets.GITHUB_TOKEN}}
#
# Restore-only half of the cache pair; the save step below is gated on a miss so a hit never
# rewrites the entry
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_MACOS}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_MACOS}}-${{runner.os}}-x64
qt-arch: x64
qt-host: macos
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_MACOS}}-x86_64
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
#
# get-cmake supplies a current CMake independently of whatever the runner image ships
#
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
#
# No PGO here: this job only supplies the x86_64 half of the universal binary, and the arm64
# runner can only train a profile through emulation, which would describe the emulator
#
- name: '🚧 Configure with CMake (production, x86_64)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja \
-DSS_BUILD_COMMIT=${{ github.sha }} \
-DPRODUCTION_OPTIMIZATION=ON \
-DENABLE_HARDENING=ON \
-DENABLE_GRPC=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \
-DCMAKE_OSX_ARCHITECTURES="x86_64" \
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix;${CMAKE_PREFIX_PATH}" \
-DBUILD_COMMERCIAL=ON \
-DBUILD_GPL3=OFF
#
# Builds the x86_64 slice
#
- name: '🚧 Build application (x86_64)'
run: cmake --build build --config Release
#
# Lifts the slice out of the bundle; the upload itself is the next step
#
- name: '📤 Upload artifact: x86_64 executable'
run: cp "build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}}" "${{env.EXECUTABLE}}-x86_64"
#
# retention-days: 1, an intermediate consumed by the universal merge job
#
- name: '📤 Upload artifact: x86_64 slice'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: macos-intel-exe
path: ${{env.EXECUTABLE}}-x86_64
retention-days: 1
- name: '📤 Upload artifact: symbols (macOS x86_64)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: symbols-macOS-x86_64
path: build/app/${{env.EXECUTABLE}}.app/Contents/MacOS/${{env.EXECUTABLE}}.dSYM
#---------------------------------------------------------------------------------------------------
# macOS universal merge + sign + notarize.
#---------------------------------------------------------------------------------------------------
build-macos:
runs-on: macos-latest
name: '🍎 macOS Build (universal)'
needs:
- build-macos-arm64
- build-macos-intel
permissions:
contents: read
steps:
#
# No submodules: this job merges, signs and notarizes prebuilt artifacts and never compiles
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
#
# create-dmg is an npm package
#
- name: '⚙️ Install Node'
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
#
# The deployed arm64 bundle produced by build-macos-arm64
#
- name: '📥 Download arm64 deployed .app'
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: macos-arm64-app
path: .
#
# The bare x86_64 executable produced by build-macos-intel
#
- name: '📥 Download x86_64 slice'
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: macos-intel-exe
path: ./intel-slice
#
# Unpacks the bundle the lipo step edits in place
#
- name: '🗜 Unpack deployed .app'
run: tar -xzf macos-arm64-app.tar.gz
#
# lipo fuses the two slices into the bundle's existing executable; the greps assert both
# architectures survived the merge
#
- name: '🔗 Lipo arm64 + x86_64 into a universal binary'
run: |
APP_EXE="${{env.APPLICATION}}.app/Contents/MacOS/${{env.EXECUTABLE}}"
X64_EXE="intel-slice/${{env.EXECUTABLE}}-x86_64"
lipo -create "$APP_EXE" "$X64_EXE" -output "$APP_EXE.universal"
mv "$APP_EXE.universal" "$APP_EXE"
chmod +x "$APP_EXE"
lipo -info "$APP_EXE"
lipo -info "$APP_EXE" | grep -q "arm64" && lipo -info "$APP_EXE" | grep -q "x86_64"
#
# Every dynamically-linked dependency inside the bundle needs the same treatment as the
# executable: without both slices the DMG would advertise itself as universal while that
# library failed to load on Intel. dlopen failing at runtime is the only symptom: the build,
# the signature and the notarisation all pass. Qt's own bundled dylibs (the FFmpeg set behind
# Qt Multimedia) ship fat from the online installer, so a library already carrying both
# slices only needs the assertion; the merge branch exists for a future arm64-only dependency
# and expects the intel job to publish a matching slice under intel-frameworks/.
#
- name: '🔗 Lipo bundled dynamic libraries'
run: |
FW="${{env.APPLICATION}}.app/Contents/Frameworks"
if [ ! -d "$FW" ]; then
echo "No Frameworks directory; nothing to merge."
exit 0
fi
shopt -s nullglob
for LIB in "$FW"/*.dylib; do
BASE="$(basename "$LIB")"
if lipo -info "$LIB" | grep -q "x86_64" && lipo -info "$LIB" | grep -q "arm64"; then
echo "$BASE is already universal; nothing to merge."
continue
fi
SLICE="intel-frameworks/$BASE"
if [ ! -f "$SLICE" ]; then
echo "::error::$BASE has no x86_64 slice; the universal bundle would ship it arm64-only."
exit 1
fi
lipo -create "$LIB" "$SLICE" -output "$LIB.universal"
mv "$LIB.universal" "$LIB"
lipo -info "$LIB"
lipo -info "$LIB" | grep -q "arm64"
lipo -info "$LIB" | grep -q "x86_64"
done
#
# The stamp goes in before codesign: any edit to the bundle afterwards invalidates the
# signature
#
- name: '📝 Stamp packaging metadata (before codesign)'
run: |
printf '{"packageType":"dmg","arch":"universal"}\n' \
> "${{env.APPLICATION}}.app/Contents/Resources/ss-config.json"
#
# Loads the Developer ID identity from the p12 secret into the runner keychain
#
- name: '🪪 Import Certificates'
uses: apple-actions/import-codesign-certs@fe74d46e82474f87e1ba79832ad28a4013d0e33a # v6.1.0
with:
p12-file-base64: ${{secrets.APPLE_CERTIFICATES_P12}}
p12-password: ${{secrets.APPLE_CERTIFICATES_P12_PASSWORD}}
#
# --options runtime is what notarization requires; the entitlements are the ones shipped in
# app/deploy/macOS
#
- name: '✍🏻 Sign Application with Entitlements'
env:
APPLE_APPID_TEAM_ID: ${{secrets.APPLE_APPID_TEAM_ID}}
run: |
codesign --force --deep --options runtime \
--entitlements "${GITHUB_WORKSPACE}/app/deploy/macOS/Serial-Studio.entitlements" \
--sign "$APPLE_APPID_TEAM_ID" \
"${{env.APPLICATION}}.app"
#
# create-dmg builds the release image; LICENSE.md is removed first so it is not folded into
# the DMG layout
#
- name: '💽 Create nice DMG'
run: |
npm install --global create-dmg
rm LICENSE.md
create-dmg "${{env.APPLICATION}}.app" --dmg-title="${{env.APPLICATION}}"
mv "${{env.APPLICATION}} ${{env.VERSION}}.dmg" "${{env.EXECUTABLE}}-${{env.VERSION}}-macOS.dmg"
#
# --wait blocks on Apple's verdict, so a rejected build fails the job here
#
- name: '📋 Notarize'
shell: bash
env:
PRODUCT_PATH: ${{env.EXECUTABLE}}-${{env.VERSION}}-macOS.dmg
APPLE_ID: ${{secrets.APPLE_NOTARIZATION_USERNAME}}
APP_PASSWORD: ${{secrets.APPLE_NOTARIZATION_PASSWORD}}
TEAM_ID: ${{secrets.APPLE_NOTARIZATION_TEAMID}}
run: |
echo "Submitting $PRODUCT_PATH for notarization..."
xcrun notarytool submit "$PRODUCT_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_PASSWORD" \
--team-id "$TEAM_ID" \
--wait \
--output-format json
#
# Stapling the ticket lets Gatekeeper clear the DMG on a machine that is offline
#
- name: '📌 Staple'
shell: bash
run: |
PRODUCT="${{env.EXECUTABLE}}-${{env.VERSION}}-macOS.dmg"
echo "Stapling $PRODUCT..."
xcrun stapler staple "$PRODUCT"
#
# Release artifact: the signed, notarized, stapled universal DMG
#
- name: '📤 Upload artifact: DMG'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-macOS.dmg
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-macOS.dmg
#---------------------------------------------------------------------------------------------------
# Windows build (MSVC 2022 x86_64)
#---------------------------------------------------------------------------------------------------
build-windows:
runs-on: windows-latest
name: '🧊 Windows Build (x86_64)'
permissions:
contents: read
steps:
#
# core.autocrlf=input keeps the checkout LF-only, which is what code-verify and clang-format
# assume
#
- run: git config --global core.autocrlf input
#
# This repo has no submodules; 'submodules: recursive' is a no-op kept for future deps.
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
submodules: 'recursive'
#
# Restore-only half of the cache pair; the save step below is gated on a miss so a hit never
# rewrites the entry
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_WINDOWS}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_WINDOWS}}-${{runner.os}}-x64
qt-arch: x64
qt-host: msvc2022_64
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_WINDOWS}}-x64-msvc2022_64-v2
installer-shell: pwsh
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
#
# get-cmake supplies a current CMake; both of its caches are off on this image
#
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: false
useCloudCache: false
#
# Sets the x64 MSVC environment that the Ninja generator and clang-cl inherit
#
- name: '🛠 Setup MSVC Development Environment'
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9 # v4.1.0
with:
arch: x64
#
# The VS-bundled clang-cl is used in preference to any LLVM on PATH, and the step throws
# rather than silently falling back
#
- name: '🛠 Use Microsoft (VS-bundled) clang-cl'
run: |
$clangDir = Join-Path $env:VCINSTALLDIR 'Tools\Llvm\x64\bin'
if (-not (Test-Path (Join-Path $clangDir 'clang-cl.exe'))) { throw "VS clang-cl not found at $clangDir" }
Add-Content -Path $env:GITHUB_PATH -Value $clangDir
& (Join-Path $clangDir 'clang-cl.exe') --version
#
# gRPC arrives as a prebuilt tarball from GRPC_REPO; building it from source would cost more
# than the rest of the job put together.
#
# Retried: the release-asset endpoint answers HTTP 500 often enough to redden a run on its own
# (2026-09-03, Linux arm64), and a single failed GET here costs the whole job. --clobber so a
# partial download from the previous attempt does not make the retry fail on an existing file.
#
- name: '⚙️ Download prebuilt gRPC'
run: |
for ($attempt = 1; $attempt -le 5; $attempt++) {
gh release download v${{env.GRPC_VERSION}} `
-R ${{env.GRPC_REPO}} `
-p "grpc-${{env.GRPC_VERSION}}-windows-x86_64.zip" `
--clobber
if ($LASTEXITCODE -eq 0) { break }
if ($attempt -eq 5) { throw "gh release download failed after $attempt attempts" }
Start-Sleep -Seconds ($attempt * 15)
}
mkdir "${{github.workspace}}\grpc-prefix"
7z x grpc-${{env.GRPC_VERSION}}-windows-x86_64.zip -o"${{github.workspace}}\grpc-prefix"
env:
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
#
# Stage 1 of the two-stage PGO flow: an instrumented Release build whose only job is to emit
# profiles for the training runs below
#
- name: '🚧 Configure with CMake (PGO generate)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja `
-DSS_BUILD_COMMIT=${{ github.sha }} `
-DCMAKE_CXX_COMPILER=clang-cl `
-DCMAKE_C_COMPILER=clang-cl `
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix" `
-DPRODUCTION_OPTIMIZATION=ON `
-DENABLE_HARDENING=ON `
-DENABLE_GRPC=ON `
-DENABLE_PGO=ON `
-DPGO_STAGE=GENERATE `
-DENABLE_POST_LINK_LAYOUT=ON `
-DSS_ALLOC_STATS=ON `
-DSS_UNITY_BUILD=ON `
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} `
-DCMAKE_BUILD_TYPE=Release `
-DBUILD_COMMERCIAL=ON `
-DBUILD_GPL3=OFF
#
# Builds the instrumented binary the two training runs profile
#
- name: '🚧 Build application (instrumented)'
run: cmake --build build --config Release
#
# Pro widgets are license-gated, so an unactivated training run profiles the GPL subset and
# the shipped binary is optimized for code no Pro user runs. A failed activation is therefore
# a failed build, not a warning
#
- name: '🔑 Activate Serial Studio license (Pro hotpath training)'
env:
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
run: |
$p = Start-Process -FilePath "build/app/${{env.EXECUTABLE}}.exe" `
-ArgumentList '--activate',"$env:SERIAL_STUDIO_LICENSE_KEY" `
-WorkingDirectory $PWD -PassThru -NoNewWindow
if (-not $p.WaitForExit(120000)) { $p.Kill(); throw 'license activation timed out' }
if ($p.ExitCode -ne 0) { throw "license activation failed (exit $($p.ExitCode))" }
#
# --min-fps 1 turns the benchmark into a profile generator: the real gate runs later, against
# the optimized binary
#
- name: '🏋️ PGO training run (hotpath)'
run: |
$p = Start-Process -FilePath "build/app/${{env.EXECUTABLE}}.exe" `
-ArgumentList '--headless','--benchmark-hotpath','--min-fps','1','--benchmark-output','pgo-train.txt' `
-WorkingDirectory $PWD -PassThru -NoNewWindow
if (-not $p.WaitForExit(300000)) { $p.Kill(); throw 'benchmark timed out (no PGO profile)' }
if (-not (Select-String -Path pgo-train.txt -Pattern 'HOTPATH_PASS=1' -Quiet)) { throw 'hotpath allocation gate failed (see pgo-train.txt)' }
#
# continue-on-error: the second profile is a bonus, and this load is the flakiest step in the
# job on Windows
#
- name: '🏋️ PGO training run (big project)'
continue-on-error: true
run: |
./tests/benchmarks/big_db_test/run_load.ps1 -App "build/app/${{env.EXECUTABLE}}.exe" -Seconds 25
#
# Stage 2: identical flags with PGO_STAGE=USE so the compiler consumes the profiles just
# written
#
- name: '🚧 Reconfigure with CMake (PGO use)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -B build -G Ninja `
-DSS_BUILD_COMMIT=${{ github.sha }} `
-DCMAKE_CXX_COMPILER=clang-cl `
-DCMAKE_C_COMPILER=clang-cl `
-DCMAKE_PREFIX_PATH="${{github.workspace}}/grpc-prefix" `
-DPRODUCTION_OPTIMIZATION=ON `
-DENABLE_HARDENING=ON `
-DENABLE_GRPC=ON `
-DENABLE_PGO=ON `
-DPGO_STAGE=USE `
-DENABLE_POST_LINK_LAYOUT=ON `
-DSS_ALLOC_STATS=ON `
-DSS_UNITY_BUILD=ON `
-DSS_UNITY_BATCH_SIZE=${{ env.SS_UNITY_BATCH }} `
-DSS_PACKAGE_TYPE=msi `
-DCMAKE_BUILD_TYPE=Release `
-DBUILD_COMMERCIAL=ON `
-DBUILD_GPL3=OFF
#
# The binary that ships, and the one every gate below measures
#
- name: '🚧 Build application (PGO optimized)'
run: cmake --build build --config Release
#
# Spec 0090 R5/AC4: lld-link sorts functions by the PGO call-graph profile; the
# /print-symbol-order evidence file proves the sort actually ran on this link. An absent or
# near-empty file means the profile stopped reaching the linker -- fail, do not weaken this
# check (the fallback is a generated /call-graph-ordering-file, a follow-up task, never a
# softer assertion)
#
- name: '🔎 Assert PGO call-graph ordering (lld-link)'
run: |
$f = 'build/symbol-order.txt'
if (-not (Test-Path $f)) { throw 'symbol-order.txt missing: call-graph profile sort did not run' }
$lines = (Get-Content $f | Measure-Object -Line).Lines
Write-Host "lld-link symbol order: $lines entries"
if ($lines -lt 1000) { throw "symbol-order.txt has only $lines entries; call-graph profile sort looks inactive" }
#
# The throughput gate reads HOTPATH_PASS out of benchmark.txt: Start-Process loses the child's
# exit code, so the verdict travels through the file. A miss fails the build
#
- name: '🚦 Hotpath throughput gate (256 kHz)'
run: |
$p = Start-Process -FilePath "build/app/${{env.EXECUTABLE}}.exe" `
-ArgumentList '--headless','--benchmark-hotpath','--min-fps','256000','--benchmark-output','benchmark.txt' `
-WorkingDirectory $PWD -PassThru -NoNewWindow
if (-not $p.WaitForExit(300000)) { $p.Kill(); throw 'benchmark timed out' }
if (Select-String -Path benchmark.txt -Pattern 'HOTPATH_PASS=1' -Quiet) { exit 0 } else { exit 1 }
- name: '📤 Upload artifact: benchmark report (Windows)'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: benchmark-Windows
path: |
benchmark.txt
pgo-train.txt
build/symbol-order.txt
- name: '📤 Upload artifact: symbols (Windows)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: symbols-Windows
path: build/app/${{env.EXECUTABLE}}.pdb
if-no-files-found: ignore
#
# always(): activations are seat-limited, so a failed job still has to release the seat
#
- name: '🔑 Deactivate Serial Studio license'
if: always()
run: |
try {
$p = Start-Process -FilePath "build/app/${{env.EXECUTABLE}}.exe" `
-ArgumentList '--deactivate' `
-WorkingDirectory $PWD -PassThru -NoNewWindow
if (-not $p.WaitForExit(120000)) { $p.Kill() }
} catch {
Write-Host "License deactivation error: $_"
}
exit 0
#
# msbuild is what the WiX toolchain shells out to
#
- name: '⚙️ Add msbuild to PATH'
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0
#
# WiX is the CPack backend for the MSI
#
- name: '⚙️ Install WiX'
run: dotnet tool install --global wix
#
# cpack drives WiX; the installer is renamed to the release naming scheme
#
- name: '📦 Create MSI installer'
run: |
cd build
cpack --verbose
mv *.msi ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msi
mv ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msi ../
#
# The same build packaged as a plain archive, the base of the portable ZIP
#
- name: '📦 Create portable TGZ'
run: |
cd build
cpack -G TGZ
mv *.tar.gz ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.tgz
#
# Unpacks the TGZ so the portable ZIP can be assembled with a launcher and a license beside it
#
- name: '📦 Extract TGZ contents'
shell: bash
run: |
cd build
7z x ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.tgz
7z x ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.tar
DIRNAME=$(tar -tf ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.tar 2>/dev/null | head -1 | cut -f1 -d"/" || true)
echo $DIRNAME > dirname.txt
cp ../app/deploy/windows/license.rtf ./License.rtf
#
# The deploy tools bundle only the native platform plugin; the integration tests run with
# QT_QPA_PLATFORM=offscreen
#
- name: '🧩 Bundle offscreen platform plugin (headless CI tests)'
shell: bash
run: |
cd build
DIRNAME=$(cat dirname.txt)
QT_PLUGINS_PATH="${{github.workspace}}/Qt-${{env.QT_VERSION_WINDOWS}}-${{runner.os}}-x64/${{env.QT_VERSION_WINDOWS}}/msvc2022_64/plugins"
DEST="$DIRNAME/plugins/platforms"
mkdir -p "$DEST"
if [ -f "$QT_PLUGINS_PATH/platforms/qoffscreen.dll" ]; then
cp "$QT_PLUGINS_PATH/platforms/qoffscreen.dll" "$DEST/"
else
echo "Warning: offscreen platform plugin not found at $QT_PLUGINS_PATH/platforms"
fi
#
# Portable entry point: a .bat next to the extracted tree, so users do not have to dig into
# bin\ for the executable
#
- name: '📝 Create Windows batch launcher'
shell: bash
run: |
cd build
DIRNAME=$(cat dirname.txt)
echo "@echo off" > "${{env.APPLICATION}}.bat"
echo "start \"\" \"%~dp0${DIRNAME}\\bin\\${{env.EXECUTABLE}}.exe\"" >> "${{env.APPLICATION}}.bat"
#
# Stamps ss-config.json as portable and reads it straight back out of the archive as a check
#
- name: '📦 Add files to ZIP'
shell: bash
run: |
cd build
DIRNAME=$(cat dirname.txt)
printf '{"packageType":"portable","arch":"x86_64"}\n' > "$DIRNAME/bin/ss-config.json"
7z a ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows-Portable.zip "${{env.APPLICATION}}.bat" License.rtf $DIRNAME/*
7z e -so ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows-Portable.zip "$DIRNAME/bin/ss-config.json" | grep -q '"packageType":"portable"'
mv ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows-Portable.zip ..
#
# Store package: the logo variants are rendered from PackageIcon.png onto transparent square
# canvases, so no plate bleeds behind the icon. The taskbar and the Start "all apps" list read
# the target-size variants, and they are shipped in their unplated form so Windows draws the
# bare icon instead of stamping it on the accent-colour plate. resources.pri is what makes
# those qualified variants resolve at runtime; without the index Windows only sees the neutral
# logo and plates it. The manifest is generated inline, and makepri/makeappx are located in the
# installed Windows SDK rather than assumed on PATH
#
- name: '📦 Create MSIX package (Microsoft Store)'
run: |
$dirname = (Get-Content build/dirname.txt -TotalCount 1).Trim()
$staging = "build/msix"
New-Item -ItemType Directory -Force "$staging/Assets" | Out-Null
Copy-Item -Recurse -Force "build/$dirname/*" $staging
Set-Content -Path "$staging/bin/ss-config.json" `
-Value '{"packageType":"msix","arch":"x86_64"}' -Encoding utf8 -NoNewline
Add-Type -AssemblyName System.Drawing
$icon = [System.Drawing.Image]::FromFile("$PWD/app/deploy/PackageIcon.png")
function Save-Logo($size, $name) {
$bmp = New-Object System.Drawing.Bitmap($size, $size)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$gfx.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$gfx.DrawImage($icon, 0, 0, $size, $size)
$bmp.Save("$PWD/$staging/Assets/$name", [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
}
Save-Logo 50 'StoreLogo.png'
Save-Logo 150 'Square150x150Logo.png'
Save-Logo 44 'Square44x44Logo.png'
foreach ($s in 16, 24, 32, 48, 256) {
Save-Logo $s "Square44x44Logo.targetsize-$s.png"
Save-Logo $s "Square44x44Logo.targetsize-${s}_altform-unplated.png"
}
$icon.Dispose()
$description = $env:DESCRIPTION -replace '&', '&amp;'
$manifest = @"
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities">
<Identity Name="$($env:MSIX_IDENTITY_NAME)"
Publisher="$($env:MSIX_PUBLISHER)"
Version="$($env:VERSION).0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>$($env:APPLICATION)</DisplayName>
<PublisherDisplayName>$($env:MSIX_PUBLISHER_DISPLAY)</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Resources>
<Resource Language="en-US" />
</Resources>
<Applications>
<Application Id="SerialStudioPro"
Executable="bin\$($env:EXECUTABLE).exe"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="$($env:APPLICATION)"
Description="$description"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png" />
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
"@
Set-Content -Path "$staging/AppxManifest.xml" -Value $manifest -Encoding utf8
$makepri = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.*\x64\makepri.exe" -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $makepri) { throw 'makepri.exe not found in any installed Windows SDK' }
& $makepri.FullName createconfig /cf "build/priconfig.xml" /dq en-US /o
if ($LASTEXITCODE -ne 0) { throw "makepri createconfig failed with exit code $LASTEXITCODE" }
& $makepri.FullName new /pr "$staging" /mn "$staging/AppxManifest.xml" /cf "build/priconfig.xml" /of "$staging/resources.pri" /o
if ($LASTEXITCODE -ne 0) { throw "makepri new failed with exit code $LASTEXITCODE" }
Remove-Item "build/priconfig.xml" -Force
$makeappx = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.*\x64\makeappx.exe" -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $makeappx) { throw 'makeappx.exe not found in any installed Windows SDK' }
& $makeappx.FullName pack /d $staging /p "${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msix" /o
if ($LASTEXITCODE -ne 0) { throw "makeappx failed with exit code $LASTEXITCODE" }
$stamp = & 7z e -so "${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msix" "bin/ss-config.json"
if ($stamp -notmatch '"packageType":"msix"') { throw 'MSIX ss-config.json stamp mismatch' }
#
# Release artifact: the MSI installer
#
- name: '📤 Upload artifact: MSI installer'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msi
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msi
#
# Release artifact: the portable ZIP
#
- name: '📤 Upload artifact: Portable ZIP'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows-Portable
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows-Portable.zip
#
# Release artifact: the MSIX package uploaded to the Microsoft Store
#
- name: '📤 Upload artifact: MSIX package (Store upload)'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msix
path: ${{env.EXECUTABLE}}-${{env.VERSION}}-Windows.msix
#---------------------------------------------------------------------------------------------------
# Create release
#---------------------------------------------------------------------------------------------------
upload:
name: '🗂 Publish (continuous)'
#
# Publication waits on the builds and the linters only: the release goes out as soon as the
# last package exists, and the pytest suite reports on it in parallel without holding it.
# Every dependency must succeed (the default needs semantics), so a red build or a missed
# throughput gate still blocks the publish.
#
needs:
- lint
- build-linux
- build-linux-arm64
- build-macos
- build-windows
#
# Publication is a side effect on a shared, mutable tag: the step below deletes the
# 'continuous' release and recreates it. Without the branch guard every push on every
# branch (and every same-repo PR) republished it, and two runs racing each other left the
# tag pointing at one build with another's assets.
#
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || 'continuous' }}
steps:
#
# No submodules: this job only needs the workflow checkout to run gh
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
#
# merge-multiple flattens every build job's artifact into one ./artifacts directory
#
- name: '📥 Download artifacts'
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
path: ./artifacts
merge-multiple: true
#
# The continuous release is recreated from scratch on every run; deleting the tag with it
# keeps the release page from accumulating stale assets
#
- name: '🗑️ Delete previous release and tag if exists'
run: |
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
echo "Release '$RELEASE_TAG' exists. Deleting..."
gh release delete "$RELEASE_TAG" --cleanup-tag --yes --repo "$GITHUB_REPOSITORY" || echo "Failed to delete release. Ignoring."
else
echo "No release named '$RELEASE_TAG' found. Skipping delete."
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
#
# prerelease + allowUpdates: 'continuous' is a moving target, while a pushed tag publishes
# under its own name
#
- name: '🚀 Create Release'
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
with:
tag: ${{ env.RELEASE_TAG }}
commit: ${{ github.sha }}
prerelease: true
allowUpdates: true
replacesArtifacts: true
name: 'Continuous Build'
artifacts: |
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.dmg
artifacts/*.msi
artifacts/*.zip
#---------------------------------------------------------------------------------------------------
# Static analysis (fail-fast, no build)
#---------------------------------------------------------------------------------------------------
lint:
runs-on: ubuntu-24.04
name: '🔬 Lint'
permissions:
contents: read
steps:
#
# No submodules: the linters only read first-party sources and scripts
#
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
#
# 3.11 with pip caching: every gate in this job is a Python script
#
- name: '🐍 Set up Python'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.11'
cache: 'pip'
#
# requirements.lock is the hash-pinned resolution of tests/requirements.txt; --require-hashes
# is what makes a CI run reproducible instead of a function of the day it ran
#
- name: '🐍 Install Python dependencies'
run: pip install --require-hashes -r tests/requirements.lock
#
# The repo is REUSE-compliant (REUSE.toml + LICENSES/); a file without an SPDX header fails
# here
#
- name: '⚖️ Verify REUSE licensing compliance'
run: reuse lint
#
# scripts/code-verify.py is the style contract: errors block the job, advisories do not
#
- name: '🔬 Verify code conventions'
run: python3 scripts/code-verify.py --check
#
# clang-format is pinned in the lock installed above, so this compares the tree against one
# exact LLVM release rather than against whatever the runner ships. Without this gate a
# contributor running a different clang-format lands a tree-wide restyle as a side effect of
# sanitize-commit.py, which is how 54 unrelated files rode in on one commit (2026-09-10)
#
- name: '🎨 Verify clang-format'
run: python3 scripts/sanitize-commit.py --check-format
#
# Spec 0076: the core/ static libraries only stay layered if it is mechanical. This resolves
# every quoted include, rejects an upward one, and catches a core/ source no target owns
#
- name: '🧱 Verify core layering'
run: python3 scripts/layer-verify.py
#
# Growth ratchets. Each has a checked-in baseline and fails only on an increase, so the
# existing debt does not block CI while new debt does. Until 2026-08-25 the singleton census
# ran only inside sanitize-commit.py, which made it a gate somebody had to remember to run
#
- name: '🔒 Ratchet global state (spec 0039)'
run: python3 scripts/code-verify.py --singleton-census --check
- name: '🔒 Ratchet translation-unit size'
run: python3 scripts/code-verify.py --tu-census --check
#
# Clone families are invisible to a per-file linter: every file passes on its own. This
# ratchets the summed count of shared 10-line windows across first-party file pairs
#
- name: '🔒 Ratchet duplicated code'
run: python3 scripts/code-verify.py --dup-census --check
#
# Spec 0077: every Core::Bus topic has a publisher and a subscriber, no token names a topic
# Messages.h does not declare, and no bus token sits in a hotpath translation unit
#
- name: '🔒 Verify bus topics'
run: python3 scripts/code-verify.py --bus-census --check
#
# The tooling's own tests: 8000+ lines of source-rewriting Python and a 3600-line workflow
# had zero coverage until spec 0075, so a rule regression rewrote files silently and CI
# drift was invisible
#
- name: '🔬 Test the repository tooling'
run: python3 -m pytest scripts/tests/ -q
#
# The AI-facing docs are the tier that rots silently; this checks their paths, symbols and
# pinned constants against the tree
#
- name: '🔬 Verify AI documentation claims'
run: python3 scripts/claim-verify.py --quiet
#
# Markdown AI-narration scan over the user-facing docs
#
- name: '🔬 Verify documentation conventions'
run: python3 scripts/documentation-verify.py --quiet
#
# The .code-report cleanup checklist, uploaded even on success so the baseline debt stays
# visible
#
- name: '📤 Upload code-verify report'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: code-verify-report
path: |
.code-report
.doc-report
.claim-report
if-no-files-found: ignore
#
# Spec 0037: every drift gate the repo owns has a caller. The snapshot projection is --strict
# here (locally it warns, because only a build can refresh api-schema.json); its failure
# message names the ordered fix
#
- name: '🧬 Verify generated API surfaces'
run: |
python3 scripts/registry-verify.py
python3 scripts/generate-command-strings.py --check
python3 scripts/generate-property-registry.py --check
python3 scripts/generate-property-registry.py --check-snapshot --strict
python3 scripts/generate-sdk.py --check
#---------------------------------------------------------------------------------------------------
# Sanitizers, fuzz corpus and the GPLv3 build gate
#---------------------------------------------------------------------------------------------------
#
# cmake/Sanitizers.cmake names TSan the only tool that proves the lock-free SPSC hotpath
# invariants, and until spec 0075 nothing ran it. These jobs are that proof plus the ASan/UBSan
# leg, and they are where the fuzz corpora replay under instrumentation. They are deliberately
# NOT in upload's needs, and both carry continue-on-error at the job level: a sanitizer finding
# must neither hold the release hostage nor redden a run whose product is fine. GitHub renders
# such a job green, so each leg ends with an annotation step that turns any failed step into a
# warning on the run summary -- that annotation, not the job icon, is the signal to read.
#
# Both legs were disabled from 2026-09-04 to 2026-09-11. What the last enabled run reported, and
# what fixed it: UBSan findings inside vendored code (open62541's comparator dispatch, LuaJIT's
# tagged pointers and shifts) now opt those two archives out of the one check each trips;
# libplctag's Clang Debug branch hardwired ASan into its sources, a hard error next to
# -fsanitize=thread, and lib/libplctag/CMakeLists.txt strips it; two enums without a fixed
# underlying type made the out-of-range fallbacks the suites exercise undefined; and the CSV
# fuzz harness fed the scanners the empty input libFuzzer always runs first.
#
# Three jobs, not one. ASan and TSan cannot coexist in a build, so a single job compiled the tree
# twice back to back -- 60+ minutes of wall clock on the critical path, and on 2026-09-03 the
# runner was killed mid-link twice before ctest ever started. Split, they run in parallel, and
# each one links with lld against -gline-tables-only objects through a 2-slot link pool, which is
# what brought the peak link footprint back under the runner's memory.
#
# Both sanitizer legs build Pro (BUILD_GPL3=OFF): the Pro modules -- Modbus, CAN, OPC UA, S7comm,
# EtherNet/IP, IEC 60870-5-104, Sparkplug B, MDF4 -- are the ones customers pay for, and they were
# the only part of the tree no sanitizer ever looked at. The global BUILD_COMMERCIAL also reaches
# the unit tier, so every #ifdef BUILD_COMMERCIAL branch in a shared TU is instrumented too. No
# license is activated here: the tier inspects code, not entitlement, and the five build jobs
# already hold the concurrent activations this account allows.
#
# build-gpl3 is what keeps the GPLv3 half honest. With both sanitizer legs on Pro, nothing else in
# the workflow compiles the GPL application, and a stray Pro symbol outside its #ifdef would ship
# a source tarball that does not build.
#
#---------------------------------------------------------------------------------------------------
sanitize:
continue-on-error: true
runs-on: ubuntu-24.04
name: '🧫 Sanitizers (ASan + UBSan, Pro)'
permissions:
contents: read
env:
QT_QPA_PLATFORM: offscreen
#
# detect_leaks=0: Qt's plugin and QML machinery leaks by design at exit, so leak reports
# here would be pure noise. The memory-error half is what this job is for.
#
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
steps:
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- name: '⚙️ Install dependencies'
uses: ./.github/actions/linux-toolchain-deps
#
# Restore-only: build-linux owns the save half of this entry, so two jobs never race to
# write it
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_LINUX}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64
qt-arch: x64
qt-host: gcc_64
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_LINUX}}-x64
save-cache: 'false'
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
#
# Clang because ENABLE_FUZZERS needs libFuzzer; mimalloc off because an allocator override
# and ASan cannot both own malloc. SS_INAPP_TESTS is what exposes --benchmark-hotpath.
# The credentials travel through env: -- CMake reads them with $ENV{} -- and a Pro configure
# validates them against Lemon Squeezy before it generates the license guards
#
- name: '🚧 Configure ASan + UBSan tier (Pro)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -G Ninja -B build/asan \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_BUILD_TYPE=Debug \
-DDEBUG_SANITIZER=ON \
-DENABLE_FUZZERS=ON \
-DSS_BUILD_TESTS=ON \
-DSS_INAPP_TESTS=ON \
-DBUILD_GPL3=OFF \
-DBUILD_COMMERCIAL=ON \
-DENABLE_GRPC=OFF \
-DWITH_WEBENGINE=OFF \
-DSS_USE_MIMALLOC=OFF \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON \
-DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld \
-DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld \
-DCMAKE_MODULE_LINKER_FLAGS=-fuse-ld=lld \
-DCMAKE_JOB_POOLS=link=2 \
-DCMAKE_JOB_POOL_LINK=link \
-DSS_SANITIZER_DEBUG_LEVEL=-gline-tables-only
- name: '🚧 Build ASan + UBSan tier'
run: cmake --build build/asan
#
# Step-level continue-on-error on the four run steps: one failing suite must not skip the
# corpus replay, the pipeline pass and the QML pass, or a single finding hides three others.
# The annotation step at the end reads their outcomes
#
- name: '🧪 Run ctest (ASan + UBSan)'
id: asan_ctest
continue-on-error: true
run: ctest --test-dir build/asan --output-on-failure
#
# --no-tests=ignore: the fuzz set is empty until the defect packages land their targets, and
# an empty selection is not a failure
#
- name: '🧬 Replay fuzz corpora'
id: asan_fuzz
continue-on-error: true
run: ctest --test-dir build/asan -R '^fuzz_' --output-on-failure --no-tests=ignore
#
# --min-fps 1: instrumented throughput is meaningless as a number, but the run exercises the
# whole parse pipeline under ASan/UBSan, which is what this leg is for
#
- name: '🏋️ Hotpath pipeline under ASan + UBSan'
id: asan_hotpath
continue-on-error: true
run: ./build/asan/app/${{env.UNIXNAME}} --headless --benchmark-hotpath --min-fps 1
#
# The Pro file set is a superset of the GPL one, so this instantiates every .qml the product
# ships -- Plot3D, Waterfall, ImageView, the output widgets and the Pro driver panes included
#
- name: '🧪 QML instantiation self-test (Pro build)'
id: asan_qml
continue-on-error: true
run: ./build/asan/app/${{env.UNIXNAME}} --headless --selftest-suite qml
#
# The job stays green whatever happened above, so this is where a finding becomes visible:
# one warning per failed step on the run summary, and a build or configure failure (which
# skips the run steps) reports through the job status the same way
#
- name: '⚠️ Annotate sanitizer findings'
if: always()
run: |
failed=0
for step in \
'ctest:${{steps.asan_ctest.outcome}}' \
'fuzz corpus replay:${{steps.asan_fuzz.outcome}}' \
'hotpath pipeline:${{steps.asan_hotpath.outcome}}' \
'QML self-test:${{steps.asan_qml.outcome}}'; do
name="${step%%:*}"
outcome="${step##*:}"
if [ "$outcome" = "failure" ]; then
echo "::warning title=ASan + UBSan (${name})::the step reported findings; see the job log"
failed=1
fi
done
if [ "${{job.status}}" = "failure" ] && [ "$failed" = "0" ]; then
echo "::warning title=ASan + UBSan (build)::the tier did not build; see the job log"
fi
#---------------------------------------------------------------------------------------------------
# ThreadSanitizer
#---------------------------------------------------------------------------------------------------
#
# TSan is its own build: it cannot coexist with ASan. The unit tier alone, because the
# SPSC/DirectConnection invariants it proves live in the suites, not the GUI.
#
#---------------------------------------------------------------------------------------------------
sanitize-tsan:
continue-on-error: true
runs-on: ubuntu-24.04
name: '🧫 Sanitizers (TSan, Pro)'
permissions:
contents: read
env:
QT_QPA_PLATFORM: offscreen
TSAN_OPTIONS: halt_on_error=1:suppressions=${{github.workspace}}/app/tests/tsan.supp
steps:
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- name: '⚙️ Install dependencies'
uses: ./.github/actions/linux-toolchain-deps
#
# Restore-only: build-linux owns the save half of this entry, so two jobs never race to
# write it
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_LINUX}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64
qt-arch: x64
qt-host: gcc_64
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_LINUX}}-x64
save-cache: 'false'
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
#
# link=1, not the ASan leg's 2: the whole unit tier links one instrumented executable per
# suite, and serializing those links costs minutes, not the job. It was introduced for the
# 2026-09-04 VM shutdown "mid-link" (test executable 1457 of 1482), but ninja prints an edge
# when it FINISHES, and the runs of 2026-09-11/12 died the same way with every link done: the
# edge still in flight each time was the TSan compile of lib/open62541/open62541.c, which
# lib/open62541/CMakeLists.txt now leaves uninstrumented under ENABLE_TSAN
#
- name: '🚧 Configure TSan tier (Pro)'
env:
ARCGIS_API_KEY: ${{secrets.ARCGIS_API_KEY}}
SERIAL_STUDIO_LICENSE_KEY: ${{secrets.SERIAL_STUDIO_LICENSE_KEY}}
SERIAL_STUDIO_INSTANCE_ID: ${{secrets.SERIAL_STUDIO_INSTANCE_ID}}
run: |
cmake -G Ninja -B build/tsan \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_BUILD_TYPE=Debug \
-DENABLE_TSAN=ON \
-DSS_BUILD_TESTS=ON \
-DBUILD_GPL3=OFF \
-DBUILD_COMMERCIAL=ON \
-DENABLE_GRPC=OFF \
-DWITH_WEBENGINE=OFF \
-DSS_USE_MIMALLOC=OFF \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON \
-DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld \
-DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld \
-DCMAKE_MODULE_LINKER_FLAGS=-fuse-ld=lld \
-DCMAKE_JOB_POOLS=link=1 \
-DCMAKE_JOB_POOL_LINK=link \
-DSS_SANITIZER_DEBUG_LEVEL=-gline-tables-only
- name: '🚧 Build TSan tier'
run: cmake --build build/tsan --target ss_unit_tests
- name: '🧪 Run ctest (TSan)'
id: tsan_ctest
continue-on-error: true
run: ctest --test-dir build/tsan --output-on-failure
#
# Same contract as the ASan leg: the job is green by construction, the annotation is the
# signal
#
- name: '⚠️ Annotate sanitizer findings'
if: always()
run: |
if [ "${{steps.tsan_ctest.outcome}}" = "failure" ]; then
echo "::warning title=TSan (ctest)::the suites reported findings; see the job log"
elif [ "${{job.status}}" = "failure" ]; then
echo "::warning title=TSan (build)::the tier did not build; see the job log"
fi
#---------------------------------------------------------------------------------------------------
# GPLv3 build gate
#---------------------------------------------------------------------------------------------------
#
# The only job that compiles the GPL application. Every other build in this workflow is Pro, so
# without it a Pro symbol referenced outside its #ifdef BUILD_COMMERCIAL guard -- or a QML file
# added to the shared set but registered only in the commercial resource list -- would reach the
# published source tarball as a build failure nobody saw.
#
# No sanitizers: this is a compile, link and start-up gate, and the instrumented legs above
# already cover the runtime. Release with the unity build for the same reason the PGO stages use
# it -- it is the fastest way through ~800 first-party translation units on a 4-core runner. The
# stock linker on purpose, too: the lld the sanitizer legs need is a memory workaround, and a gate
# that exists to prove the GPL tarball builds should not build it a way nobody else does.
#
#---------------------------------------------------------------------------------------------------
build-gpl3:
runs-on: ubuntu-24.04
name: '🐧 GPLv3 Build Gate'
permissions:
contents: read
env:
QT_QPA_PLATFORM: offscreen
steps:
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- name: '⚙️ Install dependencies'
uses: ./.github/actions/linux-toolchain-deps
#
# Restore-only: build-linux owns the save half of this entry, so two jobs never race to
# write it
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_LINUX}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64
qt-arch: x64
qt-host: gcc_64
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_LINUX}}-x64
save-cache: 'false'
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
- name: '🚧 Configure GPLv3 build'
run: |
cmake -G Ninja -B build/gpl3 \
-DCMAKE_BUILD_TYPE=Release \
-DSS_INAPP_TESTS=ON \
-DBUILD_GPL3=ON \
-DENABLE_GRPC=OFF \
-DWITH_WEBENGINE=OFF \
-DSS_USE_MIMALLOC=OFF \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON \
-DSS_UNITY_BUILD=ON \
-DSS_UNITY_BATCH_SIZE=${{env.SS_UNITY_BATCH}}
- name: '🚧 Build GPLv3 application'
run: cmake --build build/gpl3
#
# Instantiating every compiled .qml is the cheapest proof that the GPL binary starts and its
# QML tree resolves against the globals this build actually registers
#
- name: '🧪 QML instantiation self-test (GPL build)'
run: ./build/gpl3/app/serial-studio-gpl3 --headless --selftest-suite qml
#---------------------------------------------------------------------------------------------------
# Per-library build (spec 0077)
#---------------------------------------------------------------------------------------------------
#
# Not in upload's needs: this job proves the layering, it does not produce a package. It mirrors
# build-gpl3's toolchain and Qt-cache steps (restore-only; build-linux owns the save half).
#
build-core-libraries:
runs-on: ubuntu-24.04
name: '🧱 Core Libraries Build Alone'
permissions:
contents: read
env:
QT_QPA_PLATFORM: offscreen
steps:
- name: '🧰 Checkout'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- name: '⚙️ Install dependencies'
uses: ./.github/actions/linux-toolchain-deps
#
# Restore-only: build-linux owns the save half of this entry, so two jobs never race to
# write it
#
- name: '⚙️ Set up Qt'
uses: ./.github/actions/setup-qt
with:
qt-version: ${{env.QT_VERSION_LINUX}}
qt-dir: ${{github.workspace}}/Qt-${{env.QT_VERSION_LINUX}}-${{runner.os}}-x64
qt-arch: x64
qt-host: gcc_64
cache-key: qt-${{runner.os}}-${{env.QT_VERSION_LINUX}}-x64
save-cache: 'false'
qt-username: ${{secrets.QT_USERNAME}}
qt-password: ${{secrets.QT_PASSWORD}}
- name: '⚙️ Install CMake'
uses: lukka/get-cmake@fffaaafeea488556c2c12dad60690008bc1caacb # v4.4.2
with:
useLocalCache: true
#
# Spec 0077 AC2: the seven static libraries under core/ configure and build with no application
# and no test target in the graph. SerialStudioUi pulls the other six in dependency order, so a
# library that reaches a layer above it, or app/src, fails here before the executable links it
#
- name: '🚧 Configure the library tier'
run: |
cmake -G Ninja -B build/core-libs \
-DCMAKE_BUILD_TYPE=Debug \
-DBUILD_GPL3=ON \
-DENABLE_GRPC=OFF \
-DWITH_WEBENGINE=OFF \
-DSS_USE_MIMALLOC=OFF \
-DUSE_SYSTEM_ZLIB=ON \
-DUSE_SYSTEM_EXPAT=ON
- name: '🧱 Build the seven core libraries'
run: cmake --build build/core-libs --target SerialStudioUi
#---------------------------------------------------------------------------------------------------
# Integration tests
#---------------------------------------------------------------------------------------------------
#
# One caller per leg rather than one matrixed job: a matrix carries a single `needs` list, so every
# leg used to wait for the slowest build. The suite consumes the build jobs' artifacts directly,
# never the Release: downloading from the Release (the pre-0075 shape) let one run's job pick up
# another run's binary. It runs alongside 'upload' and reports on the published build without
# gating it.
#
# secrets: inherit forwards SERIAL_STUDIO_LICENSE_KEY, which the activation step needs.
#
test-linux:
name: '🧪 Test'
needs: build-linux
permissions:
contents: read
uses: ./.github/workflows/test-suite.yml
secrets: inherit
with:
label: Linux
os: linux
runner: ubuntu-24.04
asset: Linux-x64.AppImage
broker: true
opcua_sim: true
marker_expr: "not destructive and not dos and not audio"
timeout_method: signal
test-linux-arm64:
name: '🧪 Test'
needs: build-linux-arm64
permissions:
contents: read
uses: ./.github/workflows/test-suite.yml
secrets: inherit
with:
label: Linux-arm64
os: linux
runner: ubuntu-24.04-arm
asset: Linux-arm64.AppImage
broker: true
opcua_sim: true
marker_expr: "not destructive and not dos and not audio"
timeout_method: signal
test-macos:
name: '🧪 Test'
needs: build-macos
permissions:
contents: read
uses: ./.github/workflows/test-suite.yml
secrets: inherit
with:
label: macOS
os: macos
runner: macos-latest
asset: macOS.dmg
broker: true
opcua_sim: true
marker_expr: "not destructive and not dos and not audio"
timeout_method: signal
test-windows:
name: '🧪 Test'
needs: build-windows
permissions:
contents: read
uses: ./.github/workflows/test-suite.yml
secrets: inherit
with:
label: Windows
os: windows
runner: windows-latest
asset: Windows-Portable
broker: false
opcua_sim: false
marker_expr: "not destructive and not dos and not audio and not requires_broker and not requires_opcua_sim"
timeout_method: thread