Skip to content

scenario: measure and gate domain-workflow coverage (#364) #742

scenario: measure and gate domain-workflow coverage (#364)

scenario: measure and gate domain-workflow coverage (#364) #742

Workflow file for this run

name: CI
on:
push:
branches: [main, master]
pull_request:
# Supersede a run that a newer commit on the same ref has already made
# obsolete. Without this every push queued a *fresh* full run of the ~16 legs
# below and left the previous one in the queue -- so a burst of six merges to
# master queued six complete generations of CI, none of which could be
# cancelled by the next.
#
# That is not only this repository's problem. GitHub's concurrency allowance is
# per ACCOUNT, not per repository, so those generations sit in one queue shared
# with every other repo in the org and starve them: `fastcached` had fifteen
# jobs waiting behind this workflow with none running, and the oldest run here
# had been queued for over five hours.
#
# `docs.yml`, `wasm-demo.yml` and `wasm-ladder.yml` already do this; `ci.yml` is
# by far the largest workflow and was the one without it.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# vcpkg's x-gha binary caching backend has been removed upstream (confirmed
# live: every Windows CI run logged "warning: The 'x-gha' binary caching
# backend has been removed" and silently cached nothing, rebuilding every
# vcpkg dependency from source on every run). Replaced with the officially
# documented successor: a NuGet-based provider backed by GitHub Packages'
# own NuGet feed, which every repo already has -- no new secret, just the
# windows job's own GITHUB_TOKEN and packages: write (see that job's
# permissions: block and its "Add NuGet source" step, which is what
# actually defines the `github` source this string names).
VCPKG_BINARY_SOURCES: "clear;nuget,github,readwrite"
SCCACHE_DIR: /home/runner/.cache/sccache
# Caps each cache family's on-disk sccache blob. Cache keys are scoped by
# preset name alone (gcc-debug, gcc-release, clang-debug, clang-release,
# clang-asan, clang-tsan, clang-ubsan, clang-coverage), not by job name --
# sccache disambiguates by the actual compiler invocation, so every job that
# builds a given preset (e.g. linux-compilers', linux-qt's, ladder-tests'
# and valgrind's gcc-debug legs) shares one cache family instead of keeping
# its own. That is 8 families total rather than one per job, so this cap
# times 8 stays comfortably inside GitHub's 10 GB per-repo actions-cache
# budget where 16 job-scoped 1 GB blobs did not, and every job's build now
# also warms the cache the next job's build reads from.
#
# The write side still only fires on a push to master (see each job's "Save
# sccache" step) so generations do not keep multiplying underneath this.
# Whether 1 GB is the right cap per family is a question for the
# `sccache --show-stats` output those steps emit -- deliberately left
# unchanged here so the effect of the cache-sharing change can be read on
# its own.
SCCACHE_CACHE_SIZE: "1G"
CLANG_VERSION: "22"
# MORPH_BUILD_FORMS_QML needs Qt 6.5+; ubuntu-24.04 apt still ships 6.4.2.
QT_VERSION: "6.8.1"
jobs:
# ── Windows: MSVC + clang-cl ──────────────────────────────────────────
windows:
name: Windows / ${{ matrix.preset }}
runs-on: windows-latest
# packages: write is what lets vcpkg push built binaries to this repo's
# GitHub Packages NuGet feed (see "Add NuGet source" below); nothing else
# in this job needs more than the default contents: read the workflow
# grants at the top level.
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
preset: [cl-debug, cl-release, clangcl-debug, clangcl-release]
steps:
- uses: actions/checkout@v4
- name: Setup MSVC environment
uses: ilammy/msvc-dev-cmd@v1
- name: Setup vcpkg
uses: lukka/run-vcpkg@v11
with:
vcpkgGitCommitId: c3867e714dd3a51c272826eea77267876517ed99
# Defines the `github` source VCPKG_BINARY_SOURCES (top-level env:)
# names. GITHUB_TOKEN is scoped to this repo and already carries
# packages: write from this job's permissions: block above -- no new
# secret needed. `dotnet nuget` rather than `nuget.exe`: it ships with
# the .NET SDK already on every GitHub-hosted Windows runner, so
# nothing extra needs installing.
- name: Add NuGet source (GitHub Packages, for vcpkg binary caching)
shell: pwsh
run: |
dotnet nuget add source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" `
--name github `
--username "${{ github.repository_owner }}" `
--password "${{ secrets.GITHUB_TOKEN }}" `
--store-password-in-clear-text
- name: Configure
run: cmake --preset ${{ matrix.preset }}
- name: Build
run: cmake --build --preset ${{ matrix.preset }}
- name: Test
if: matrix.preset == 'cl-debug' || matrix.preset == 'clangcl-debug'
run: ctest --preset ${{ matrix.preset }}
# ── Linux: GCC and Clang plain builds ────────────────────────────────
linux-compilers:
name: Linux / ${{ matrix.preset }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
preset: [gcc-debug, gcc-release, clang-debug, clang-release]
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-compilers-${{ matrix.preset }}-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-compilers-${{ matrix.preset }}-
- name: Install GCC 15 and ninja
if: startsWith(matrix.preset, 'gcc-')
run: |
sudo apt-get update -q
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt-get update -q
sudo apt-get install -y gcc-15 g++-15 ninja-build catch2
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-15 15
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 15
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
if: startsWith(matrix.preset, 'clang-')
run: |
sudo apt-get update -q
sudo apt-get install -y ninja-build catch2
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
# Keyed by preset alone, not this job's name: gcc-debug/clang-debug's
# object files are the same ones linux-qt, ladder-tests, valgrind (for
# gcc-debug) and clang-tidy (for clang-debug) compile with extra
# -DMORPH_BUILD_* flags layered on top. sccache disambiguates by the
# actual compiler invocation, not by this key, so sharing one cache
# entry per preset across jobs only raises the hit rate -- it cannot
# serve a wrong object for a different flag set.
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
restore-keys: sccache-${{ matrix.preset }}-
- name: Install sccache
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
- name: Configure (gcc)
if: startsWith(matrix.preset, 'gcc-')
run: |
cmake --preset ${{ matrix.preset }} \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
- name: Configure (clang)
if: startsWith(matrix.preset, 'clang-')
run: |
cmake --preset ${{ matrix.preset }} \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
- name: Build
run: cmake --build --preset ${{ matrix.preset }}
- name: Test
if: endsWith(matrix.preset, '-debug')
run: ctest --preset ${{ matrix.preset }}
# ── Linux: sanitizers + coverage (all clang) ─────────────────────────
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always()
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
linux-sanitizers:
name: Linux / ${{ matrix.preset }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
preset: [clang-asan, clang-tsan, clang-ubsan, clang-coverage]
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-sanitizers-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-sanitizers-
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
run: |
sudo apt-get update -q
sudo apt-get install -y ninja-build catch2 libsqlite3-dev
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
# Only the coverage leg builds the ladder: examples/common's
# hand-written GUI/testkit code is real coverage of morph's client
# stack (Bridge, backends, QtExecutor, completions — see
# examples/TESTING.md's "round-7 T4 reframe"), so it belongs in the
# coverage number the same way the models it will host later do
# (examples/IMPLEMENTATION.md rule 5). asan/tsan/ubsan skip this, same
# as before — "a GUI stack under TSan is mostly noise" — coverage
# instrumentation carries none of that risk.
- name: Install ODBC + SQLite driver (coverage leg only)
if: matrix.preset == 'clang-coverage'
run: |
# unixodbc-dev + libsqliteodbc: the application ladder (built by this
# leg only) fetches the Lightweight ORM, whose CMake runs
# `pkg_check_modules(ODBC REQUIRED odbc)`, and whose ladder fixtures
# open a real `DRIVER=SQLite3` connection at test time.
# Named explicitly rather than relied on from the runner image.
# libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt
# does `find_package(yaml-cpp)`/`find_package(libzip)` as system
# CONFIG packages, not through CPM (examples/bank/CMakeLists.txt's
# comment on the identical fetch) — without these, configure fails
# the moment this leg's MORPH_BUILD_LADDER=ON pulls Lightweight in.
# Dropped from this step by mistake when it was renamed from
# "Install Qt6 WebSockets" to "Install ODBC + SQLite driver" —
# every other job that builds the ladder on Linux (Application
# ladder, all optional features) already carries this pair.
sudo apt-get install -y libgl1-mesa-dev unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev
# Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+
# unconditionally (not gated on MORPH_BUILD_FORMS_QML) and Ubuntu
# 24.04 still ships 6.4.2 -- the identical gap the "all optional
# features" and "Application ladder" jobs' own install-qt-action steps
# already document. Named qt6-base-dev/qt6-websockets-dev/qt6-tools-dev
# used to be installed above; replaced wholesale rather than kept
# alongside aqtinstall's Qt, which would leave two Qt6 installs on the
# same runner for find_package() to pick between.
- name: Install Qt ${{ env.QT_VERSION }} (coverage leg only)
if: matrix.preset == 'clang-coverage'
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
# Keyed by preset alone, matching linux-compilers' cache above: this
# matrix's clang-asan/clang-tsan legs share the same preset name (and
# much of the same object set) as ladder-sanitizers and kanban-tsan
# respectively, which build the identical compiler+preset with extra
# -DMORPH_BUILD_* flags on top. See linux-compilers' identical comment
# for why sharing the key across jobs is safe.
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
restore-keys: sccache-${{ matrix.preset }}-
- name: Install sccache
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
# morph::net and the SQLite offline queue are opt-in, but they are also
# where the memory/threading/UB risk actually lives (raw sockets, an I/O
# thread, a hand-rolled frame reader, a C API). Left off, the sanitizers
# and the coverage number both silently skipped them. QML and the
# fuzzers stay out of this matrix entirely — they are covered by the
# linux-all-features job, and a GUI stack under TSan is mostly noise;
# the ladder (Qt6::WebSockets, no QML) is the one exception, built only
# on the coverage leg, for the reason in the Qt install step above.
- name: Configure
run: |
EXTRA_ARGS=()
if [ "${{ matrix.preset }}" = "clang-coverage" ]; then
EXTRA_ARGS+=(-DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all)
fi
cmake --preset ${{ matrix.preset }} \
-DMORPH_BUILD_NET=ON \
-DMORPH_BUILD_OFFLINE_SQLITE=ON \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache \
"${EXTRA_ARGS[@]}"
# QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's
# catch_discover_tests() runs each Qt-linked test binary once at BUILD
# time to enumerate its cases, which can abort on this headless runner
# without it — see "Linux / all optional features"'s own Build step
# for the identical failure this leg's coverage build hit once the
# ladder actually started compiling (this leg has no QML, a narrower
# Qt surface, but ladder_common_tests still links Qt6::WebSockets).
# Harmless for the non-Qt legs (nothing reads it).
- name: Build
env:
QT_QPA_PLATFORM: offscreen
run: cmake --build --preset ${{ matrix.preset }}
- name: Test
env:
# Harmless for the non-Qt legs (nothing reads it); required for the
# coverage leg's ladder tests, which open real Qt widgets/sockets
# on a runner with no display.
QT_QPA_PLATFORM: offscreen
run: |
# morph::testkit::OomInjector (tests/oom_injector.cpp) overrides
# the process-wide operator new/delete to force std::bad_alloc on
# demand -- ASan and TSan's own runtimes already interpose
# operator new/delete themselves, and linking a second, competing
# definition fails with "multiple definition of `operator
# new(unsigned long)'" (confirmed in CI). Excluded by test name on
# exactly these two legs; every other CI leg (plain clang/gcc,
# Windows, ubsan, coverage) runs these tests normally.
EXCLUDE_ARGS=()
if [ "${{ matrix.preset }}" = "clang-asan" ] || [ "${{ matrix.preset }}" = "clang-tsan" ]; then
EXCLUDE_ARGS+=(-E "OomInjector|morph#108")
fi
if [ "${{ matrix.preset }}" = "clang-coverage" ]; then
LLVM_PROFILE_FILE="build/clang-coverage/%p.profraw" ctest --preset clang-coverage "${EXCLUDE_ARGS[@]}"
else
ctest --preset ${{ matrix.preset }} "${EXCLUDE_ARGS[@]}"
fi
- name: Generate coverage report
if: matrix.preset == 'clang-coverage'
run: bash scripts/coverage.sh
- name: Upload coverage to Codecov
if: matrix.preset == 'clang-coverage'
uses: codecov/codecov-action@v5
with:
files: build/clang-coverage/coverage.lcov
# Without this, codecov-action's own auto-discovery finds every
# other *.lcov/coverage.* file under the build tree too (its own
# log says so: "Found 5 coverage files to report") -- including
# coverage.lcov.raw, the *pre-aggregation* file
# aggregate_lcov_branches.py's morph#93/#92 fixes rewrite away
# from, plus unrelated fetched-dependency fixtures
# (_deps/nlohmann_json-src/.../coverage.test). Codecov then merges
# all of them, silently re-introducing every record `coverage.lcov`
# was built to remove. `files:` alone does not disable that scan --
# this does.
disable_search: true
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage HTML
if: matrix.preset == 'clang-coverage'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: build/clang-coverage/html/
# ── Linux: kanban's concurrent-move stress test under ThreadSanitizer ──
# test_kanban_stress.cpp's [tsan]-tagged TEST_CASE drives BoardModel through
# a bare morph::bridge::Bridge/morph::backend::LocalBackend directly, on a
# real morph::exec::ThreadPoolExecutor{4}, with zero Qt frames anywhere in
# its call graph (see the test file's own header comment) -- so the "a GUI
# stack under TSan is mostly noise" rationale that keeps the ladder out of
# linux-sanitizers does not apply to this one test. This job builds only
# what that test needs -- MORPH_BUILD_LADDER=ON, MORPH_LADDER_RUNGS=kanban,
# no Qt GUI modules beyond the WebSockets backend the ladder testkit itself
# requires -- to keep it a minimal, fast, TSan-clean addition rather than
# pulling every rung's Qt Quick/QML code into the sanitizer matrix.
#
# (History: an earlier version of this test drove the same scenario through
# BackendRig{Mode::Local, ...}, whose Mode::Local unconditionally
# constructs a real morph::qt::QtExecutor for client-facing callback
# delivery -- morph#128 found 165 ThreadSanitizer warnings bottoming out in
# genuine Qt-internal frames reached through it, undetectable as real bugs
# or false positives from outside a TSan-instrumented Qt build. Rewriting
# the test to never construct a QtExecutor at all sidesteps the ambiguity
# entirely rather than resolving it.)
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always()
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
kanban-tsan:
name: Kanban / ThreadSanitizer
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-kanban-tsan-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-kanban-tsan-
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
run: |
sudo apt-get update -q
# unixodbc-dev + libsqliteodbc: the ladder (built by this job)
# fetches the Lightweight ORM, whose CMake runs
# `pkg_check_modules(ODBC REQUIRED odbc)`, and whose DbFixture
# opens a real `DRIVER=SQLite3` connection at test time (identical
# rationale to linux-sanitizers' coverage-leg step and ladder-tests'
# own install step). Named explicitly rather than relied on from the
# runner image.
# libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt does
# `find_package(yaml-cpp)`/`find_package(libzip)` as system CONFIG
# packages, not through CPM (examples/bank/CMakeLists.txt's comment
# on the identical fetch) — without these, configure fails the
# moment MORPH_BUILD_LADDER=ON pulls Lightweight in.
# libgl1-mesa-dev: every other job that configures MORPH_BUILD_QT=ON
# together with MORPH_BUILD_LADDER=ON installs this (linux-sanitizers'
# coverage leg, ladder-tests, linux-all-features) for Qt's GL platform
# integration; carried here for the same reason even though this leg's
# test run itself stays off-GUI.
sudo apt-get install -y ninja-build catch2 libsqlite3-dev \
unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev libgl1-mesa-dev
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
# Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+
# unconditionally and Ubuntu 24.04 still ships 6.4.2 — the same gap
# every other job that builds the ladder on Linux already documents.
- name: Install Qt ${{ env.QT_VERSION }}
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
# Keyed by preset alone (clang-tsan), matching linux-sanitizers' cache:
# this job's clang-tsan+Qt+kanban build shares the same compiler and
# much of the same core-library object set as that job's clang-tsan
# leg, which builds plain clang-tsan+net+offline_sqlite. See
# linux-compilers' identical comment for why sharing the key is safe.
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-clang-tsan-${{ github.sha }}
restore-keys: sccache-clang-tsan-
- name: Install sccache
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
# MORPH_LADDER_RUNGS=kanban (a single rung, not "all"): examples/CMakeLists.txt's
# rung-selection loop (`if(MORPH_LADDER_RUNGS STREQUAL "all" OR _rung IN_LIST
# MORPH_LADDER_RUNGS)`) matches a single-value list correctly, and examples/common
# (the testkit every rung's tests link) is always added regardless of which rungs
# are selected — so this configures and builds only kanban's ladder targets, not
# the whole ladder.
- name: Configure (clang-tsan, kanban only)
run: |
cmake --preset clang-tsan \
-DMORPH_BUILD_QT=ON \
-DMORPH_BUILD_LADDER=ON \
-DMORPH_LADDER_RUNGS=kanban \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
# QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's
# catch_discover_tests() runs ladder_kanban_tests once at BUILD time to
# enumerate its cases, which can abort on this headless runner (no X
# server) without it — see linux-sanitizers' and ladder-tests' own Build
# steps for the identical note.
- name: Build
env:
QT_QPA_PLATFORM: offscreen
run: cmake --build --preset clang-tsan
# Every ladder ctest case only ever carries the "ladder"/"ladder-<rung>"
# labels morph_add_rung() applies (cmake/morph_add_rung.cmake) — Catch2's
# own tags ([kanban][stress][tsan]) are never translated into ctest
# labels anywhere in this repo's CMake (no catch_discover_tests call
# passes ADD_TAGS_AS_LABELS). A "-L tsan" filter would therefore match
# zero tests and silently run nothing. This test's name is the only
# thing distinguishing it, and "ThreadSanitizer" appears in exactly one
# TEST_CASE name across the whole kanban tree (confirmed by grep), so
# -R selects it precisely.
- name: Test (kanban's TSan-tagged stress test only)
env:
QT_QPA_PLATFORM: offscreen
run: ctest --preset clang-tsan -L ladder-kanban -R ThreadSanitizer --output-on-failure
# ── Linux: Qt WebSocket backend build + tests ─────────────────────────
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always()
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-clang-tsan-${{ github.sha }}
linux-qt:
name: Linux / Qt6 WebSockets
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-qt-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-qt-
- name: Install GCC 15, ninja, catch2, Qt6 WebSockets
run: |
sudo apt-get update -q
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt-get update -q
sudo apt-get install -y gcc-15 g++-15 ninja-build catch2 \
qt6-base-dev qt6-websockets-dev qt6-tools-dev libgl1-mesa-dev
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-15 15
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 15
# Keyed by preset alone (gcc-debug), matching linux-compilers' cache:
# this job's gcc-debug+Qt build shares the same core-library object set
# as linux-compilers' plain gcc-debug leg, ladder-tests' gcc-debug+Qt+
# ladder build, and valgrind's gcc-debug+net build. See linux-compilers'
# identical comment for why sharing the key across jobs is safe.
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-gcc-debug-${{ github.sha }}
restore-keys: sccache-gcc-debug-
- name: Install sccache
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
- name: Configure (gcc-debug with MORPH_BUILD_QT=ON)
run: |
cmake --preset gcc-debug \
-DMORPH_BUILD_QT=ON \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
- name: Build
run: cmake --build --preset gcc-debug
- name: Test (offscreen Qt platform)
env:
QT_QPA_PLATFORM: offscreen
run: ctest --preset gcc-debug
# ── Linux: application ladder testkit (path-filtered) ─────────────────
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always()
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-gcc-debug-${{ github.sha }}
ladder-tests:
name: Application ladder
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need history for the changed-paths diff below
- name: Determine whether the ladder needs to run
id: filter
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
else
base="${{ github.event.before }}"
fi
if [ -z "$base" ] || ! git cat-file -e "$base" 2>/dev/null; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
changed=$(git diff --name-only "$base" HEAD)
# The pattern is generated from examples/rungs.txt, never written
# here. It used to be a hand-copied rung alternation, and it had
# drifted a full rung behind: it stopped at kanban, so a change
# confined to examples/ledger/ (rung 5) or examples/lims/ (rung 6)
# matched nothing and skipped this job -- silently, because a filter
# that matches nothing looks exactly like one that correctly decided
# there was nothing to do (morph#179). See scripts/ladder_rungs.sh
# for what the non-rung half of the pattern covers and why.
pattern="$(bash scripts/ladder_rungs.sh ci-path-regex)"
echo "ladder path filter: $pattern"
if echo "$changed" | grep -qE "$pattern"; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Cache apt packages
if: steps.filter.outputs.run == 'true'
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-qt-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-qt-
- name: Install GCC 15, ninja, catch2
if: steps.filter.outputs.run == 'true'
run: |
sudo apt-get update -q
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt-get update -q
# unixodbc-dev + libsqliteodbc: the application ladder fetches the
# Lightweight ORM, whose CMake runs
# `pkg_check_modules(ODBC REQUIRED odbc)`, and whose ladder fixtures
# open a real `DRIVER=SQLite3` connection at test time.
# Named explicitly rather than relied on from the runner image.
# libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt
# does `find_package(yaml-cpp)`/`find_package(libzip)` as system
# CONFIG packages, not through CPM (examples/bank/CMakeLists.txt's
# comment on the identical fetch) — without these, configure fails
# the moment MORPH_BUILD_LADDER=ON pulls Lightweight in.
# Qt itself is installed by the aqtinstall step below, not apt: see
# that step's comment for why the distro package is unusable here.
sudo apt-get install -y gcc-15 g++-15 ninja-build catch2 \
libyaml-cpp-dev libzip-dev libgl1-mesa-dev \
unixodbc-dev libsqliteodbc
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-15 15
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 15
# Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+
# (QQmlApplicationEngine::loadFromModule, used by MORPH_BUILD_FORMS_QML
# rungs) and Ubuntu 24.04 still ships 6.4.2 — the exact gap the "all
# optional features" job's identical step already documents. This job
# configures MORPH_BUILD_LADDER=ON without MORPH_BUILD_FORMS_QML, but
# examples/common/CMakeLists.txt's Qt6 6.5 REQUIRED applies unconditionally
# (it is not gated on MORPH_BUILD_FORMS_QML), so the floor still bites here.
- name: Install Qt ${{ env.QT_VERSION }}
if: steps.filter.outputs.run == 'true'
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
# Keyed by preset alone (gcc-debug), matching linux-qt's cache above:
# this job's gcc-debug+Qt+ladder build shares the same core-library
# objects as linux-compilers' plain gcc-debug leg and linux-qt's
# gcc-debug+Qt build.
- name: Restore sccache
if: steps.filter.outputs.run == 'true'
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-gcc-debug-${{ github.sha }}
restore-keys: sccache-gcc-debug-
- name: Install sccache
if: steps.filter.outputs.run == 'true'
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
- name: Configure (gcc-debug, ladder + Qt on)
if: steps.filter.outputs.run == 'true'
run: |
cmake --preset gcc-debug \
-DMORPH_BUILD_QT=ON \
-DMORPH_BUILD_LADDER=ON \
-DMORPH_LADDER_RUNGS=all \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
# QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's
# catch_discover_tests() runs each Qt-linked test binary once at BUILD
# time to enumerate its cases, which aborts on a headless runner (no X
# server) without it — see "Linux / all optional features"'s own Build
# step for the identical note. This job has not hit it in practice
# (its ladder test binaries' discovery apparently succeeds without a
# platform anyway), but the risk is structurally identical, so it is
# set defensively rather than left to reappear the next time a rung
# adds a Qt Quick-linked test binary here.
- name: Build
if: steps.filter.outputs.run == 'true'
env:
QT_QPA_PLATFORM: offscreen
run: cmake --build --preset gcc-debug
- name: Test (offscreen Qt platform, ladder tests only, stress excluded)
if: steps.filter.outputs.run == 'true'
env:
QT_QPA_PLATFORM: offscreen
run: ctest --preset gcc-debug -L ladder -LE stress --output-on-failure
# ── Linux: every rung's tests under AddressSanitizer + UBSan ──────────
# Before this job, no rung test ran under any sanitizer: ladder-tests above
# builds plain gcc-debug, and the linux-sanitizers matrix deliberately skips
# the ladder (only its clang-coverage leg sets MORPH_BUILD_LADDER). So every
# rung's models, presenters and QML adapters — the code the ladder exists to
# exercise — were compiled and run with no memory or UB instrumentation
# anywhere in CI.
#
# ASan and UBSan, not TSan. `apply_sanitizers(<target> asan)` compiles with
# -fsanitize=address,undefined (cmake/compiler_options.cmake), so this one
# preset delivers both and a separate ubsan leg for the ladder would re-run
# a strict subset. TSan is deliberately absent: a rung's tests drive Qt on
# every path, and against an uninstrumented system Qt that produces warnings
# bottoming out in Qt-internal frames which cannot be classified as real
# races or false positives from outside a TSan-instrumented Qt build —
# morph#128 hit exactly that, 165 warnings deep. The resolution there was to
# rewrite the one test that mattered to construct no QtExecutor at all and
# run only it under TSan, which is what kanban-tsan above does. Thread-
# sanitising a rung means following that pattern per test, not adding a
# blanket -DAF_SANITIZER=tsan leg here.
#
# -LE stress mirrors ladder-tests: the stress cases are long-running by
# design and ASan's shadow-memory overhead compounds that.
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always() && steps.filter.outputs.run == 'true'
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: steps.filter.outputs.run == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-gcc-debug-${{ github.sha }}
ladder-sanitizers:
name: Application ladder / ASan+UBSan
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need history for the changed-paths diff below
# Same filter as ladder-tests, and now literally the same generator
# rather than a second hand-copy of the same list: the two jobs build the
# identical tree and differ only in instrumentation, so a change that
# warrants running one warrants running the other, and the two patterns
# must not be able to diverge. This job is the only one in the repository
# that sanitizer-instruments a rung, which is what the drifted filter
# actually cost (morph#179).
- name: Determine whether the ladder needs to run
id: filter
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
else
base="${{ github.event.before }}"
fi
if [ -z "$base" ] || ! git cat-file -e "$base" 2>/dev/null; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
changed=$(git diff --name-only "$base" HEAD)
pattern="$(bash scripts/ladder_rungs.sh ci-path-regex)"
echo "ladder path filter: $pattern"
if echo "$changed" | grep -qE "$pattern"; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Cache apt packages
if: steps.filter.outputs.run == 'true'
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-ladder-asan-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-ladder-asan-
- name: Install Clang ${{ env.CLANG_VERSION }}, ninja, catch2, ODBC
if: steps.filter.outputs.run == 'true'
run: |
sudo apt-get update -q
# unixodbc-dev + libsqliteodbc: the ladder fetches the Lightweight
# ORM, whose CMake runs `pkg_check_modules(ODBC REQUIRED odbc)` and
# whose fixtures open a real `DRIVER=SQLite3` connection at test
# time. libyaml-cpp-dev/libzip-dev/libgl1-mesa-dev round out the
# same set every other ladder-building Linux job installs.
sudo apt-get install -y ninja-build catch2 libsqlite3-dev \
unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev libgl1-mesa-dev
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
# Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+
# unconditionally and Ubuntu 24.04 still ships 6.4.2 — the same gap
# every other job that builds the ladder on Linux already documents.
- name: Install Qt ${{ env.QT_VERSION }}
if: steps.filter.outputs.run == 'true'
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
# Keyed by preset alone (clang-asan), matching linux-sanitizers' cache:
# this job's clang-asan+Qt+ladder build shares the same compiler and
# much of the same core-library object set as that job's clang-asan leg.
- name: Restore sccache
if: steps.filter.outputs.run == 'true'
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-clang-asan-${{ github.sha }}
restore-keys: sccache-clang-asan-
- name: Install sccache
if: steps.filter.outputs.run == 'true'
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
- name: Configure (clang-asan, ladder + Qt on)
if: steps.filter.outputs.run == 'true'
run: |
cmake --preset clang-asan \
-DMORPH_BUILD_QT=ON \
-DMORPH_BUILD_LADDER=ON \
-DMORPH_LADDER_RUNGS=all \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
# QT_QPA_PLATFORM=offscreen at build time as well as test time: Catch2's
# catch_discover_tests() runs each Qt-linked test binary once during the
# build to enumerate its cases — see ladder-tests' own Build step.
- name: Build
if: steps.filter.outputs.run == 'true'
env:
QT_QPA_PLATFORM: offscreen
run: cmake --build --preset clang-asan
# A sanitizer job whose binaries are not actually instrumented is worse
# than no job: it runs the full suite, costs the full runtime and always
# passes, so a missing apply_sanitizers() call in morph_add_rung.cmake
# would read as "rungs are ASan-clean" rather than "rungs were never
# checked". That is not hypothetical — before the AF_SANITIZER blocks
# landed in cmake/morph_add_rung.cmake and examples/common/CMakeLists.txt,
# -DAF_SANITIZER=asan instrumented morph_tests and nothing under
# examples/. Assert the instrumentation is really there before trusting
# a green run.
- name: Verify rung binaries are actually instrumented
if: steps.filter.outputs.run == 'true'
run: |
set -euo pipefail
missing=0
for exe in build/clang-asan/examples/*/ladder_*_tests; do
[ -x "$exe" ] || continue
if nm -C "$exe" 2>/dev/null | grep -q "__asan_"; then
echo "instrumented: $exe"
else
echo "NOT INSTRUMENTED: $exe" >&2
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "::error::ladder test binaries were built without ASan instrumentation" >&2
exit 1
fi
# detect_leaks=0: LeakSanitizer reports allocations the Qt platform
# plugins and QML engine intentionally keep for process lifetime, which
# are not leaks this repo can fix or meaningfully suppress per-frame.
# The memory-error and UB checks — what this job is actually for — stay
# fully on. Drop this once a Qt-aware suppression file proves workable.
- name: Test (offscreen Qt platform, ladder tests only, stress excluded)
if: steps.filter.outputs.run == 'true'
env:
QT_QPA_PLATFORM: offscreen
ASAN_OPTIONS: detect_leaks=0
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: ctest --preset clang-asan -L ladder -LE stress --output-on-failure
# ── Linux: every optional feature enabled at once ─────────────────────
# Every MORPH_BUILD_* option below is off by default, and until this job
# existed no CI configuration turned any of them on — so several thousand
# lines (morph::net, the Qt/QML forms renderer, the SQLite queue, the fuzz
# harnesses, the vetted-HMAC adapters, the soak/bench targets) were never
# compiled here, let alone tested. That is how a build that cannot configure
# (MORPH_REQUIRE_VETTED_HMAC) and a replay test matching the wrong file
# extension both reached master green. Enabling them together also proves
# they compose, which building each alone would not.
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always() && steps.filter.outputs.run == 'true'
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: steps.filter.outputs.run == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-clang-asan-${{ github.sha }}
linux-all-features:
name: Linux / all optional features (${{ matrix.compiler }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
# Clang additionally builds the libFuzzer harnesses, which require it.
- compiler: clang
preset: clang-debug
fuzzers: 'ON'
- compiler: gcc
preset: gcc-debug
fuzzers: 'OFF'
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-all-features-${{ matrix.compiler }}-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-all-features-${{ matrix.compiler }}-
- name: Install toolchain and the non-Qt dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt-get update -q
# unixodbc-dev + libsqliteodbc: the application ladder (enabled in
# the configure step below) fetches the Lightweight ORM, whose CMake
# runs `pkg_check_modules(ODBC REQUIRED odbc)`, and whose ladder
# fixtures open a real `DRIVER=SQLite3` connection at test time.
# Named explicitly rather than relied on from the runner image.
# libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt
# (examples/bank/CMakeLists.txt's comment on the same fetch) does
# `find_package(yaml-cpp)`/`find_package(libzip)` as system CONFIG
# packages, not through CPM — without these, Lightweight's configure
# fails with "could not find a package configuration file" the
# moment MORPH_BUILD_LADDER=ON pulls it in here.
sudo apt-get install -y ninja-build catch2 \
libsqlite3-dev libsodium-dev libssl-dev \
unixodbc-dev libsqliteodbc \
libyaml-cpp-dev libzip-dev \
libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 \
libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0
if [ "${{ matrix.compiler }}" = "gcc" ]; then
sudo apt-get install -y gcc-15 g++-15
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-15 15
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 15
else
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
fi
# Not the distro's Qt: MORPH_BUILD_FORMS_QML needs 6.5+ (see the version
# floor in CMakeLists.txt) and Ubuntu 24.04 still ships 6.4.2, whose
# QQmlApplicationEngine has no loadFromModule.
- name: Install Qt ${{ env.QT_VERSION }}
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
# Keyed by preset alone, matching linux-compilers' cache: this matrix's
# gcc-debug/clang-debug legs (with every optional feature layered on
# top) share their core-library objects with linux-compilers' plain
# gcc-debug/clang-debug legs and every other job building the same
# preset (linux-qt, ladder-tests, valgrind for gcc-debug).
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
restore-keys: sccache-${{ matrix.preset }}-
- name: Install sccache
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
# MORPH_REQUIRE_VETTED_HMAC is deliberately combined with
# MORPH_BUILD_TESTS=ON (the preset default). docs/spec/security.md
# documents that pairing as supported; this is what keeps it that way.
- name: Configure (every optional feature ON)
run: |
EXTRA=""
if [ "${{ matrix.compiler }}" = "clang" ]; then
EXTRA="-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} -DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }}"
fi
# shellcheck disable=SC2086
# MORPH_BUILD_LADDER belongs in this job by its own charter ("every
# MORPH_BUILD_* option … enabling them together also proves they
# compose") and closes a real hole: until it was added here, *no* CI
# leg configured MORPH_BUILD_LADDER=ON together with
# MORPH_BUILD_FORMS_QML=ON. The ladder-tests job below cannot — its
# distro Qt is 6.4.2, under the 6.5 floor MORPH_BUILD_FORMS_QML
# requires — so each rung's QML module, desktop client and offscreen
# engine-load smoke test were built by nothing at all. This job has
# Qt ${{ env.QT_VERSION }} from aqtinstall, so here they are built,
# and the smoke test runs, on every push.
cmake --preset ${{ matrix.preset }} \
-DMORPH_BUILD_NET=ON \
-DMORPH_BUILD_QT=ON \
-DMORPH_BUILD_FORMS_QML=ON \
-DMORPH_BUILD_LADDER=ON \
-DMORPH_LADDER_RUNGS=all \
-DMORPH_BUILD_OFFLINE_SQLITE=ON \
-DMORPH_BUILD_LOAD_TESTS=ON \
-DMORPH_BUILD_HMAC_EXAMPLES=ON \
-DMORPH_BUILD_FUZZERS=${{ matrix.fuzzers }} \
-DMORPH_REQUIRE_VETTED_HMAC=ON \
$EXTRA \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
# QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's
# catch_discover_tests() runs each Qt-linked test binary once at BUILD
# time to enumerate its cases (CatchAddTests.cmake), not only when
# ctest later executes them — a ladder_<rung>_tests binary aborts at
# that discovery step on this headless runner (no X server, xcb
# platform plugin fails to load) without it, before any real test ever
# runs. Only bites once MORPH_BUILD_LADDER=ON actually reaches a rung's
# own Qt-linked test binary, which is why this job's build only started
# failing here after the yaml-cpp/libzip configure gap (fixed earlier
# this branch) stopped masking it.
- name: Build
env:
QT_QPA_PLATFORM: offscreen
run: cmake --build --preset ${{ matrix.preset }}
# Includes the fuzz *replay* tests on the clang leg: each committed seed
# and crash reproducer is replayed once and must not crash. This is a
# deterministic regression check, not a fuzzing campaign.
- name: Test (offscreen Qt platform)
env:
QT_QPA_PLATFORM: offscreen
run: ctest --preset ${{ matrix.preset }} --output-on-failure
# A guard that never fires is indistinguishable from one that works, so
# assert the fuzz replay actually ran the committed crash reproducers
# rather than silently matching nothing (the `.txt`-vs-`.bin` glob bug).
- name: Verify the fuzz replay covered the committed reproducers
if: matrix.fuzzers == 'ON'
run: |
ctest --preset ${{ matrix.preset }} -R fuzz -V > fuzz-replay.log 2>&1
status=0
for finding in tests/fuzz/findings/*/*; do
if ! grep -qF "$(basename "$finding")" fuzz-replay.log; then
echo "::error::fuzz replay never referenced committed reproducer $finding"
status=1
fi
done
exit "$status"
# ── Valgrind (memcheck) ───────────────────────────────────────────────
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always()
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
valgrind:
name: Valgrind memcheck
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-valgrind-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-valgrind-
- name: Install GCC 15, ninja, catch2, valgrind
run: |
sudo apt-get update -q
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt-get update -q
sudo apt-get install -y gcc-15 g++-15 ninja-build catch2 valgrind
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-15 15
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 15
# Keyed by preset alone (gcc-debug), matching linux-compilers' cache:
# this job's gcc-debug+net+offline_sqlite build shares its
# core-library objects with every other job building plain gcc-debug.
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-gcc-debug-${{ github.sha }}
restore-keys: sccache-gcc-debug-
- name: Install sccache
run: |
curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
# morph::net and the SQLite queue are where the raw pointers, manual
# buffers and C API calls live — precisely what memcheck is for — so they
# are built here rather than left to the default (Qt/QML stays out: it
# drags in a GUI stack whose own allocations dominate the report).
- name: Configure
run: |
cmake --preset gcc-debug \
-DMORPH_BUILD_NET=ON \
-DMORPH_BUILD_OFFLINE_SQLITE=ON \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
- name: Build
run: cmake --build --preset gcc-debug
- name: Run tests under Valgrind
run: |
status=0
for suite in tests/morph_tests tests/net/morph_net_tests tests/offline_sqlite/morph_offline_sqlite_tests; do
binary="build/gcc-debug/$suite"
if [ ! -x "$binary" ]; then
echo "::error::expected Valgrind suite $binary was not built"
exit 1
fi
echo "── memcheck: $suite ──"
# [oom-injector]/[issue108]: morph::testkit::OomInjector
# overrides the process-wide operator new/delete
# (tests/oom_injector.cpp) to force std::bad_alloc on demand --
# Valgrind's own memcheck instrumentation intercepts allocations
# at a layer this override does not reach, so the injector
# silently never fires under Valgrind (confirmed: even its own
# self-tests fail here). Skipped for this leg only; every other
# CI leg (plain clang/gcc, Windows) runs these tests normally.
EXTRA_ARGS=()
if [ "$suite" = "tests/morph_tests" ]; then
EXTRA_ARGS=("~[oom-injector]" "~[issue108]")
fi
valgrind \
--tool=memcheck \
--leak-check=full \
--show-leak-kinds=definite,indirect \
--errors-for-leak-kinds=definite,indirect \
--error-exitcode=1 \
--suppressions=cmake/valgrind.supp \
"$binary" "${EXTRA_ARGS[@]}" || status=1
done
exit "$status"
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always()
run: sccache --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps morph#109's property (only one
# branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that actually blow the budget. Pull
# requests still restore, read-only, and get the full benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-gcc-debug-${{ github.sha }}
# ── clang-format (whole tree) ─────────────────────────────────────────
#
# .clang-format has always been in the tree, and nothing enforced it: 471 of
# 677 tracked .hpp/.cpp files did not match it (morph#210). An unenforced
# config is worse than none, because every editor with format-on-save obeys it
# faithfully -- so touching one file rewrites parts the author never meant to
# change, and the reviewer's job becomes separating intent from churn.
#
# Whole tree, not changed lines. A changed-lines check passes as long as
# nobody touches the non-conforming lines, so it would have reported success
# over a tree that was 70% non-conforming -- a control that measures almost
# nothing. The tree was reformatted wholesale first (see
# .git-blame-ignore-revs), which is what makes the wider check possible.
#
# Pinned to the same CLANG_VERSION as clang-tidy-diff: clang-format output is
# not stable across major versions, so an unpinned check would disagree with
# itself between runners.
clang-format:
name: clang-format
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install clang-format ${{ env.CLANG_VERSION }}
run: |
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
sudo apt-get install -y clang-format-${{ env.CLANG_VERSION }}
- name: Check every tracked C++ file against .clang-format
run: |
git ls-files -z '*.hpp' '*.cpp' > cpp-files.z
COUNT=$(tr -cd '\0' < cpp-files.z | wc -c)
# An empty or truncated file list would make this job pass while
# checking nothing, which is the exact failure it exists to prevent.
# Refuse rather than report a vacuous success.
if [ "$COUNT" -lt 100 ]; then
echo "::error::only $COUNT C++ files matched -- the check would be vacuous"
exit 1
fi
echo "Checking $COUNT files"
xargs -0 clang-format-${{ env.CLANG_VERSION }} --dry-run -Werror \
< cpp-files.z 2> clang-format-report.txt || true
if [ -s clang-format-report.txt ]; then
cat clang-format-report.txt
echo "::error::tree does not match .clang-format -- run: git ls-files -z '*.hpp' '*.cpp' | xargs -0 clang-format -i"
exit 1
fi
- name: Upload clang-format report
if: always()
uses: actions/upload-artifact@v4
with:
name: clang-format-report
path: clang-format-report.txt
if-no-files-found: ignore
# ── clang-tidy-diff (changed lines only) ──────────────────────────────
clang-tidy:
name: clang-tidy-diff
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history for git diff against base
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-clang-tidy-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-clang-tidy-
- name: Install Clang ${{ env.CLANG_VERSION }}, clang-tidy, and the non-Qt deps
run: |
sudo apt-get update -q
sudo apt-get install -y ninja-build catch2 \
libsqlite3-dev libsodium-dev libssl-dev \
libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 \
libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
sudo apt-get install -y clang-tidy-${{ env.CLANG_VERSION }}
# See the linux-all-features job: the QML renderer needs Qt 6.5+.
- name: Install Qt ${{ env.QT_VERSION }}
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
# Every optional feature is ON here purely so its sources land in
# compile_commands.json. clang-tidy only ever analyses *changed lines*, so
# this costs a wider build, not a wider lint. Configured narrowly before,
# the whole opt-in surface — morph::net, the Qt transport, the QML forms
# renderer, the SQLite queue, the fuzz harnesses — was invisible to the
# one gate in this workflow that reads code rather than running it.
# No Build step: clang-tidy-diff.py reads compile_commands.json (a
# configure-time artifact, CMAKE_EXPORT_COMPILE_COMMANDS=ON in the base
# preset) and runs clang-tidy itself per translation unit -- it neither
# needs the project actually compiled nor linked. pinned_facts.cmake's
# generated header is likewise a configure_file() (configure-time), not
# a build-time add_custom_command, so it's already on disk too. Compiler
# caching (sccache) accordingly has nothing to do in this job, unlike
# every other Linux job here -- so those steps, and the Build step that
# was their only reason to run, are gone rather than merely skipped.
- name: Configure (generates compile_commands.json over every optional feature)
run: |
cmake --preset clang-debug \
-DMORPH_BUILD_NET=ON \
-DMORPH_BUILD_QT=ON \
-DMORPH_BUILD_FORMS_QML=ON \
-DMORPH_BUILD_OFFLINE_SQLITE=ON \
-DMORPH_BUILD_LOAD_TESTS=ON \
-DMORPH_BUILD_HMAC_EXAMPLES=ON \
-DMORPH_BUILD_FUZZERS=ON \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }}
- name: Run clang-tidy-diff on changed lines
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
BASE_SHA="${{ github.event.before }}"
fi
CLANG_TIDY_DIFF="$(find /usr/lib/llvm-${{ env.CLANG_VERSION }}/share/clang /usr/share/clang \
-name 'clang-tidy-diff.py' 2>/dev/null | head -1)"
if [ -z "$CLANG_TIDY_DIFF" ]; then
echo "::error::clang-tidy-diff.py not found"
exit 1
fi
echo "Base SHA: $BASE_SHA"
echo "Script: $CLANG_TIDY_DIFF"
git diff -U0 "$BASE_SHA" | \
python3 "$CLANG_TIDY_DIFF" \
-path build/clang-debug \
-clang-tidy-binary clang-tidy-${{ env.CLANG_VERSION }} \
-p1 \
-j "$(nproc)" \
-extra-arg=-std=c++23 \
-extra-arg=-warnings-as-errors=* \
-quiet \
2>&1 | tee clang-tidy-report.txt
- name: Upload clang-tidy report
if: always()
uses: actions/upload-artifact@v4
with:
name: clang-tidy-report
path: clang-tidy-report.txt
# ── Deprecation-marker format lint ────────────────────────────────────
deprecation-lint:
name: Deprecation marker format
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# The checker itself is tested first. A lint gate nobody tests reports
# green whether or not it still detects anything — this one was blind to
# both the bare `[[deprecated]]` form and any message wrapped across
# lines, which is exactly what VERSIONING.md forbids.
- name: Self-test the deprecation-marker checker
run: bash scripts/test_check_deprecated_markers.sh
- name: Check [[deprecated]] markers cite a replacement and removal version
run: bash scripts/check_deprecated_markers.sh include/morph
# ── Test file-scope type name collision lint ───────────────────────────
test-type-name-lint:
name: Test file-scope type name collisions
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# See scripts/test_check_test_type_names.sh's own comment: a lint gate
# nobody tests reports green whether or not it still detects anything.
- name: Self-test the test-type-name checker
run: bash scripts/test_check_test_type_names.sh
# Catches issue #84's bug class: two file-scope (external-linkage)
# struct/class declarations with the same name in different test
# files is an ODR violation that neither the compiler nor the linker
# diagnoses, and which resolves link-order-dependently per build.
- name: Check for file-scope struct/class name collisions across tests/
run: bash scripts/check_test_type_names.sh
# ── Journal payload-fingerprint stamp lint ─────────────────────────────
journal-stamp-lint:
name: Hand-rolled LogEntry stamps its payload fingerprint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# See scripts/test_check_journal_stamps.sh's own comment: a lint gate
# nobody tests reports green whether or not it still detects anything.
# This checker has two specific ways to go blind, and the self-test
# pins both.
- name: Self-test the journal-stamp checker
run: bash scripts/test_check_journal_stamps.sh
# Catches issue #244's bug class: a model that hand-rolls its own
# journaling records entries with no `LogEntry::schema`, which
# `journal::replay()` can only replay unverified -- so a later renamed
# field decodes to its default and the reconstruction is confidently
# wrong, which is precisely the failure the fingerprint exists to make
# loud. See docs/spec/journal/journal.md, "Payload schema fingerprint".
- name: Check hand-rolled LogEntry constructions stamp schema
run: bash scripts/check_journal_stamps.sh examples
# ── Install / export: find_package(morph CONFIG) must work ─────────────
#
# Its own job rather than a step on an existing leg. It configures, installs
# and consumes morph three times over (once for the checker, twice more for
# the self-test's slow cases) and would add several minutes to whichever
# matrix leg it was bolted onto; here it runs in parallel with them and
# costs nothing on the critical path.
#
# Nothing else in CI installs morph at all, which is how morph#232 survived:
# every ladder rung builds from inside the tree, so `cmake --install`
# exiting 0 with none of morph in the prefix was invisible to all ~16 legs.
install-export:
name: find_package(morph CONFIG) consumability
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# GCC 15 for the same reason the gcc-* presets use it: the default
# logger needs a standard library with <print>, and ubuntu-24.04 ships
# GCC 13.
- name: Install GCC 15 and ninja
run: |
sudo apt-get update -q
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt-get update -q
sudo apt-get install -y gcc-15 g++-15 ninja-build
# See scripts/test_check_install_export.sh's own comment: a gate nobody
# tests reports green whether or not it still detects anything, and this
# one guards a defect whose whole character was reporting success.
- name: Self-test the install/export checker
env:
CC: gcc-15
CXX: g++-15
run: bash scripts/test_check_install_export.sh
# The check itself: install to a scratch prefix, then configure, build
# and run a consumer project outside the tree against morph::morph and
# morph::net via find_package(morph CONFIG REQUIRED).
- name: Install morph and build a consumer against the prefix
env:
CC: gcc-15
CXX: g++-15
run: bash scripts/check_install_export.sh