Publish version-less stable-name download aliases (#45) #141
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Linux CI | |
| # Pin map (verify with `gh api repos/<owner>/<repo>/git/ref/tags/<tag>`): | |
| # actions/checkout @ v6.0.2 → de0fac2e4500dabe0009e67214ff5f5447ce83dd | |
| # actions/setup-python @ v6.2.0 → a309ff8b426b58ec0e2a45f0f869d46889d02405 | |
| # actions/upload-artifact @ v7.0.1 → 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a | |
| # actions/cache @ v4.3.0 → 0057852bfaa89a56745cba8c7296529d2fc39830 | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # The artifact upload goes through the Actions API, not contents. | |
| permissions: | |
| contents: read | |
| jobs: | |
| ci: | |
| name: Build, test, lint, format (${{ matrix.compiler }}) | |
| runs-on: ubuntu-24.04 | |
| # Cap a hung step instead of burning the 6h default on a billed runner. | |
| # This job provisions Qt, builds ~285 sources twice, runs the suite twice | |
| # and sweeps clang-tidy over src/. An hour still catches a hung step; it | |
| # does not fail the build over a mirror having a bad afternoon. | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| compiler: [gcc, clang] | |
| env: | |
| CC: ${{ matrix.compiler == 'clang' && 'clang' || 'gcc' }} | |
| CXX: ${{ matrix.compiler == 'clang' && 'clang++' || 'g++' }} | |
| # -Werror has only ever been evaluated by gcc; -Wconversion, -Wshadow and | |
| # -Wold-style-cast all diverge materially between gcc 13 and clang 18, and | |
| # clang-tidy reading the gcc compile database is not a substitute. | |
| DISH_LINTS: ${{ matrix.compiler == 'gcc' }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Set up build dependencies | |
| uses: ./.github/actions/setup-build-deps | |
| with: | |
| cache-key: ci-${{ matrix.compiler }} | |
| extra-packages: clang | |
| # Not apt: noble ships Qt 6.4.2 and this project's floor is 6.7. The | |
| # action pulls the official binaries and exports CMAKE_PREFIX_PATH. | |
| - name: Set up Qt | |
| uses: ./.github/actions/setup-qt | |
| - name: Set up Python | |
| if: env.DISH_LINTS == 'true' | |
| uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | |
| with: | |
| python-version: "3.12" | |
| # Pinned to match satellite + dish-android + dish-mac. Ubuntu 24.04 ships | |
| # clang-format 18 which disagrees with brew's 22.x on braced-init lists, | |
| # so we pull the matching wheel from PyPI to keep the four repos in sync. | |
| - name: Install clang-format (pinned 22.1.4) | |
| if: env.DISH_LINTS == 'true' | |
| run: | | |
| pipx install clang-format==22.1.4 | |
| pipx ensurepath | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| - name: clang-format (check only) | |
| if: env.DISH_LINTS == 'true' | |
| run: | | |
| find src tests -type f \( -name '*.cpp' -o -name '*.h' \) \ | |
| -print0 | xargs -0 clang-format --dry-run --Werror | |
| - name: Configure (Debug, tests on) | |
| run: | | |
| cmake -S . -B build -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=Debug \ | |
| -DDISH_BUILD_TESTS=ON \ | |
| -DDISH_REQUIRE_TRANSLATIONS=ON \ | |
| -DCMAKE_C_COMPILER_LAUNCHER=ccache \ | |
| -DCMAKE_CXX_COMPILER_LAUNCHER=ccache | |
| - name: Build | |
| run: cmake --build build --parallel | |
| - name: Run tests (Debug) | |
| working-directory: build | |
| # No display on the runner; the QML tests construct QGuiApplication. | |
| env: | |
| QT_QPA_PLATFORM: offscreen | |
| run: ctest --output-on-failure --parallel | |
| - name: qmllint (QML static analysis) | |
| if: env.DISH_LINTS == 'true' | |
| run: | | |
| # git's * crosses directory levels; 'src/qml/**/*.qml' would miss the | |
| # top-level Main and AppShell. | |
| qmllint -I build -I "${QT_ROOT_DIR}/qml" --unqualified info \ | |
| $(git ls-files 'src/qml/*.qml') | |
| # qmllint checks a QML file RESOLVES; this checks it used the design | |
| # tokens. A hard-coded `#4FE3FF` or `radius: 8` still renders, it just | |
| # stops tracking the palette — no other gate would catch it. | |
| - name: QML literal scanner | |
| if: env.DISH_LINTS == 'true' | |
| run: ./scripts/qml-lint-literals.sh --mode error | |
| # A stale .ts is not a compile error, so nothing else catches a string | |
| # added without a catalogue entry. | |
| - name: Translation catalogues in sync | |
| if: env.DISH_LINTS == 'true' | |
| run: ./scripts/check-translations.sh | |
| - name: clang-tidy | |
| if: env.DISH_LINTS == 'true' | |
| run: | | |
| # Reads the Debug tree built above rather than configuring and | |
| # building a second one. CMakeLists exports the compile database | |
| # globally, so build/ already has one whose src/ entries are the same | |
| # a tests-off tree produces, and its mocs are already generated. | |
| # --warnings-as-errors keeps the sweep-clean check set gated without | |
| # forking the fleet-canonical .clang-tidy (WarningsAsErrors: ''). | |
| find src -type f \( -name '*.cpp' -o -name '*.h' \) \ | |
| ! -path 'src/UI/*' \ | |
| ! -path 'src/qml/*' \ | |
| -print0 | xargs -0 -n1 -P"$(nproc)" \ | |
| clang-tidy -p build --quiet --warnings-as-errors='*' | |
| - name: Configure (Release, tests on) | |
| run: | | |
| cmake -S . -B build-release -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=Release \ | |
| -DDISH_BUILD_TESTS=ON \ | |
| -DDISH_REQUIRE_TRANSLATIONS=ON \ | |
| -DCMAKE_C_COMPILER_LAUNCHER=ccache \ | |
| -DCMAKE_CXX_COMPILER_LAUNCHER=ccache | |
| - name: Build release | |
| run: cmake --build build-release --parallel | |
| # -O3 plus LTO is a different compiler: undefined behaviour that Debug | |
| # tolerates surfaces here, and until now nothing ever ran the suite | |
| # against the configuration that actually ships. | |
| - name: Run tests (Release) | |
| working-directory: build-release | |
| env: | |
| QT_QPA_PLATFORM: offscreen | |
| run: ctest --output-on-failure --parallel | |
| # Ubuntu patches PIE, partial RELRO and stack protection into its gcc | |
| # defaults, so these pass by accident on this runner and would silently | |
| # stop passing anywhere else. Assert the linker actually applied them. | |
| - name: Assert hardening flags reached the binary | |
| if: env.DISH_LINTS == 'true' | |
| run: | | |
| set -euo pipefail | |
| bin=build-release/dish | |
| readelf -lW "$bin" | grep -q 'GNU_RELRO' || { echo "::error::no RELRO"; exit 1; } | |
| readelf -dW "$bin" | grep -q 'BIND_NOW\|FLAGS.*NOW' || { echo "::error::no full RELRO"; exit 1; } | |
| readelf -lW "$bin" | grep -qE 'GNU_STACK.*RW ' || { echo "::error::executable stack"; exit 1; } | |
| readelf -hW "$bin" | grep -q 'Type:.*DYN' || { echo "::error::not PIE"; exit 1; } | |
| - name: ccache statistics | |
| if: always() | |
| run: ccache --show-stats | |
| - name: Upload release binary | |
| if: env.DISH_LINTS == 'true' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: dish-linux-x86_64 | |
| path: build-release/dish | |
| retention-days: 14 | |
| sanitize: | |
| name: Sanitizers (${{ matrix.san }}) | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # thread is not optional here: the app runs four long-lived threads | |
| # (SDL bridge, two socket loops, the hidraw reader) over shared atomics | |
| # and mutexes, and nothing else in the pipeline would see a race. | |
| san: ["address+undefined", "thread"] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Set up build dependencies | |
| uses: ./.github/actions/setup-build-deps | |
| with: | |
| cache-key: san-${{ matrix.san }} | |
| - name: Set up Qt | |
| uses: ./.github/actions/setup-qt | |
| - name: Configure | |
| run: | | |
| cmake -S . -B build-san -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=RelWithDebInfo \ | |
| -DDISH_BUILD_TESTS=ON \ | |
| -DDISH_SANITIZER=${{ matrix.san }} \ | |
| -DCMAKE_C_COMPILER_LAUNCHER=ccache \ | |
| -DCMAKE_CXX_COMPILER_LAUNCHER=ccache | |
| # DishTests, not everything: the suite covers dish_core, and the Qt Quick | |
| # app target adds only qmlcachegen output, where GCC reports | |
| # -Wnull-dereference inside Qt's own qglobalstatic.h and refuses | |
| # atomic_thread_fence under TSan. Instrumenting it buys no coverage. | |
| - name: Build | |
| run: cmake --build build-san --parallel --target DishTests | |
| - name: Run tests | |
| working-directory: build-san | |
| env: | |
| QT_QPA_PLATFORM: offscreen | |
| ASAN_OPTIONS: detect_leaks=1:abort_on_error=1 | |
| UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 | |
| # Qt and glib are uninstrumented, so their own cross-thread new/delete | |
| # reads as a race. tests/tsan.suppressions says exactly what is | |
| # excluded and why. | |
| TSAN_OPTIONS: halt_on_error=1:suppressions=${{ github.workspace }}/tests/tsan.suppressions | |
| # Serial: two instrumented processes at once is what makes a sanitizer | |
| # run flake on a shared runner. | |
| run: ctest --output-on-failure --parallel 1 | |
| package: | |
| name: Package (.deb) and launch it | |
| runs-on: ubuntu-24.04 | |
| # A container job's default shell is `sh`, which has neither `<<<` nor | |
| # `pipefail`; every step here assumes bash. | |
| defaults: | |
| run: | |
| shell: bash | |
| # Debian 13's Qt is 6.8, above the 6.7 floor, and dpkg-shlibdeps must read a | |
| # binary linked against the Qt the package will resolve against. Ubuntu | |
| # 24.04 LTS is deliberately not a .deb target: its Qt is 6.4.2. | |
| container: debian:trixie | |
| timeout-minutes: 45 | |
| steps: | |
| - name: Install build and packaging dependencies | |
| run: | | |
| set -eux | |
| cat > /etc/apt/apt.conf.d/99-dish-acquire <<'CONF' | |
| Acquire::http::Timeout "15"; | |
| Acquire::https::Timeout "15"; | |
| Acquire::Retries "2"; | |
| CONF | |
| apt-get update | |
| apt-get install -y --no-install-recommends \ | |
| git ca-certificates \ | |
| build-essential cmake ninja-build pkg-config \ | |
| qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \ | |
| qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools \ | |
| libsodium-dev libsdl2-dev libdbus-1-dev \ | |
| dpkg-dev fakeroot file gzip \ | |
| librsvg2-bin desktop-file-utils appstream lintian | |
| - name: Checkout code | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| # Cheap, and they catch the two mistakes that reach users as "no icon in | |
| # the menu" and "missing from GNOME Software" rather than as a build error. | |
| - name: Validate desktop entry and AppStream metadata | |
| run: | | |
| set -euo pipefail | |
| desktop-file-validate packaging/dish.desktop | |
| appstreamcli validate --no-net packaging/com.tinkernorth.Dish.metainfo.xml | |
| - name: Configure and build | |
| run: | | |
| cmake -S . -B build-release -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=Release \ | |
| -DCMAKE_INSTALL_PREFIX=/usr \ | |
| -DDISH_BUILD_TESTS=OFF | |
| cmake --build build-release --parallel | |
| - name: Build the .deb | |
| run: | | |
| set -euo pipefail | |
| cpack --config build-release/CPackConfig.cmake -G DEB -B build-release/packages | |
| mkdir -p dist && cp build-release/packages/*.deb dist/ | |
| # A lintian error means the package is malformed, not merely untidy. | |
| - name: lintian | |
| run: lintian --fail-on error --tag-display-limit 0 dist/*.deb | |
| # The udev rule is the payload whose absence silently degrades a whole | |
| # feature, and it has shipped to the wrong path before. | |
| - name: Assert package contents | |
| run: | | |
| set -euo pipefail | |
| contents="$(dpkg-deb -c dist/*.deb)" | |
| for path in \ | |
| ./usr/bin/dish \ | |
| ./usr/lib/udev/rules.d/70-dish-hidraw.rules \ | |
| ./usr/share/applications/com.tinkernorth.Dish.desktop \ | |
| ./usr/share/metainfo/com.tinkernorth.Dish.metainfo.xml \ | |
| ./usr/share/icons/hicolor/scalable/apps/com.tinkernorth.Dish.svg \ | |
| ./usr/share/doc/dish/copyright | |
| do | |
| grep -qE " ${path}\$" <<<"${contents}" \ | |
| || { echo "::error::${path} missing from the .deb"; exit 1; } | |
| done | |
| icon="$(sed -n 's/^Icon=//p' packaging/dish.desktop)" | |
| grep -q "/apps/${icon}\." <<<"${contents}" \ | |
| || { echo "::error::Icon=${icon} names no installed icon"; exit 1; } | |
| # The gate nothing else can stand in for. Unit tests, lints and a | |
| # contents check all pass against a build tree, and a build tree is | |
| # exactly what hides a missing runtime dependency: a QML module resolves | |
| # from the Qt install during development and from a Depends: line after. | |
| - name: Install the .deb and launch it | |
| run: | | |
| set -euo pipefail | |
| apt-get install -y ./dist/*.deb | |
| # No seat, no bus, no display in a container; offscreen is enough to | |
| # prove the QML engine resolved every import it needs. | |
| export QT_QPA_PLATFORM=offscreen | |
| export XDG_RUNTIME_DIR=/tmp/dish-runtime | |
| mkdir -p "$XDG_RUNTIME_DIR" | |
| # The SVG handler is a run-time plugin, so its absence shows up as | |
| # "Unsupported image format" per glyph rather than as a link error. | |
| echo "--- Qt image-format plugins ---" | |
| ls -1 /usr/lib/*/qt6/plugins/imageformats/ 2>/dev/null || echo "(no imageformats directory)" | |
| # Costs nothing once the Depends: line is right, because it only runs | |
| # while the plugin is still missing. Naming the package from here beats | |
| # guessing it from outside the distribution. | |
| if ! ls /usr/lib/*/qt6/plugins/imageformats/libqsvg.so >/dev/null 2>&1; then | |
| echo "--- libqsvg.so is absent; asking the archive which package ships it ---" | |
| dpkg -L libqt6svg6 2>/dev/null | grep -i plugins || echo "libqt6svg6 ships no plugins" | |
| apt-get install -y --no-install-recommends apt-file >/dev/null 2>&1 \ | |
| && apt-file update >/dev/null 2>&1 \ | |
| && apt-file search -x 'qt6/plugins/imageformats/libqsvg\.so$' \ | |
| || echo "apt-file could not answer" | |
| fi | |
| # Signalled by hand rather than through `timeout`, which reports 124 | |
| # whether the child shut down cleanly or had to be killed — the one | |
| # distinction this step exists to make. Every wait below is bounded, | |
| # because an unbounded one is how this step once sat for 40 minutes | |
| # and took the job's timeout with it. | |
| set +e | |
| dish >/tmp/launch.log 2>&1 & | |
| app=$! | |
| sleep 20 | |
| started=0 | |
| kill -0 "$app" 2>/dev/null && started=1 | |
| needed_kill=0 | |
| if [ "$started" = 1 ]; then | |
| kill -TERM "$app" | |
| waited=0 | |
| while [ "$waited" -lt 15 ] && kill -0 "$app" 2>/dev/null; do | |
| sleep 1 | |
| waited=$((waited + 1)) | |
| done | |
| if kill -0 "$app" 2>/dev/null; then | |
| kill -KILL "$app" | |
| needed_kill=1 | |
| fi | |
| fi | |
| wait "$app" | |
| rc=$? | |
| set -e | |
| cat /tmp/launch.log | |
| if grep -qE 'is not installed|failed to load component|Cannot load library|no such file' /tmp/launch.log; then | |
| echo "::error::the installed package cannot load its Qt/QML runtime" | |
| exit 1 | |
| fi | |
| # Fatal: the app renders no iconography at all when this fires, and | |
| # the only symptom is a log flood nobody reads. It stayed a warning | |
| # only until the Depends: line was right. | |
| if grep -q 'Error decoding:' /tmp/launch.log; then | |
| echo "::error::the installed app cannot decode its brand SVGs — the Qt SVG image plugin is missing or unloadable" | |
| exit 1 | |
| fi | |
| if [ "$started" != 1 ]; then | |
| echo "::error::dish exited on its own (${rc}) instead of staying up" | |
| exit 1 | |
| fi | |
| # SIGTERM is what a logout sends. Ignoring it means ~AppModel never | |
| # runs, so the input thread is not stopped and QSettings never writes | |
| # what the session changed — the user loses their settings every time | |
| # they log out, and nothing else in CI would notice. | |
| if [ "$needed_kill" = 1 ]; then | |
| echo "::error::dish did not exit on SIGTERM; it needed SIGKILL" | |
| exit 1 | |
| fi | |
| if [ "$rc" != 0 ]; then | |
| echo "::error::dish shut down on SIGTERM but exited ${rc}" | |
| exit 1 | |
| fi | |
| echo "::notice::dish came up, shut down on SIGTERM and exited 0" | |
| - name: Upload the .deb | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: dish-deb-debian13 | |
| path: dist/*.deb | |
| retention-days: 14 | |
| coverage: | |
| name: Coverage | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 45 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Set up build dependencies | |
| uses: ./.github/actions/setup-build-deps | |
| with: | |
| cache-key: coverage | |
| extra-packages: lcov | |
| - name: Set up Qt | |
| uses: ./.github/actions/setup-qt | |
| # -fprofile-update=atomic: gcov's counters are non-atomic by default, so | |
| # the threaded tests race them and geninfo aborts on a negative count. | |
| # Timing-dependent, so it passes until it doesn't. | |
| - name: Configure and build | |
| run: | | |
| cmake -S . -B build-cov -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=Debug \ | |
| -DDISH_BUILD_TESTS=ON \ | |
| -DCMAKE_CXX_FLAGS="--coverage -fprofile-abs-path -fprofile-update=atomic" \ | |
| -DCMAKE_EXE_LINKER_FLAGS="--coverage" | |
| cmake --build build-cov --parallel | |
| - name: Run tests | |
| working-directory: build-cov | |
| env: | |
| QT_QPA_PLATFORM: offscreen | |
| run: ctest --output-on-failure --parallel | |
| - name: Report | |
| run: | | |
| set -euo pipefail | |
| lcov --capture --directory build-cov --output-file cov.info \ | |
| --ignore-errors mismatch,gcov,source | |
| lcov --remove cov.info '/usr/*' '*/build-cov/*' '*/tests/*' \ | |
| --output-file cov.info --ignore-errors unused | |
| { | |
| echo '### Coverage' | |
| echo '```' | |
| lcov --summary cov.info 2>&1 | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload coverage | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: coverage-lcov | |
| path: cov.info | |
| retention-days: 14 |